diff --git a/docker-compose.yaml b/docker-compose.yaml index cbbb18a8..fd8e035a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -102,5 +102,16 @@ services: networks: - demo + keycloak: + container_name: keycloak + image: jboss/keycloak:latest + ports: + - "8180:8080" + environment: + KEYCLOAK_USER: admin + KEYCLOAK_PASSWORD: admin + networks: + - demo + networks: demo: \ No newline at end of file diff --git a/documentation/Keycloak/keycloak_add_user.png b/documentation/Keycloak/keycloak_add_user.png new file mode 100644 index 00000000..8fa1df3e Binary files /dev/null and b/documentation/Keycloak/keycloak_add_user.png differ diff --git a/documentation/Keycloak/keycloak_authentication.asciidoc b/documentation/Keycloak/keycloak_authentication.asciidoc new file mode 100644 index 00000000..ea5e02c5 --- /dev/null +++ b/documentation/Keycloak/keycloak_authentication.asciidoc @@ -0,0 +1,135 @@ +:toc: macro +toc::[] + += Authentication and authorization + +Authentication and authorization are important aspects to consider when implementing microservice architectures. A brief overview of these two aspects can be found in the devonfw solutions browser or in the devon4j documentation: + +* link:https://github.com/devonfw/solutions/blob/master/solutions/security_authentication/index.asciidoc[Authentication] +* link:https://github.com/devonfw/solutions/blob/master/solutions/security_authorization/index.asciidoc[Authorization] +* link:https://github.com/devonfw/devon4j/blob/master/documentation/guide-access-control.asciidoc[devon4j documentation] + +This guide shows how to enable role-based access control using JWTs (link:https://github.com/devonfw/devon4j/blob/master/documentation/guide-jwt.asciidoc[JSON Web Tokens]) and authentication using Keycloak as centralized IAM (Identity and Access Management) solution. + +== Setup and configure Keycloak + +The `docker-compose.yaml` file already includes a configuration for the setup of Keycloak. Use the `docker-compose up` command to deploy Keycloak in your Docker environment. +You can also install Keycloak on your local machine without Docker. For this, follow the instructions on the official link:https://www.keycloak.org/docs/latest/server_installation/index.html[Keycloak website]. + +You can access Keycloak by navigating to http://localhost:8180/auth/ in your browser. Open the Administration console and log in with the default username "admin" and password "admin". + +The first step is to create a new realm. A realm in Keycloak is used to manage a set of users with their credentials, groups and roles. Click on "Add Realm" (displayed when you hover over "Master" in the upper left corner) and enter "devon4quarkus-product" as the realm name. + +Then create a new client named "devon4quarkus-product-cli" with "openid-connect" as the client protocol. We will use this client later to request the access token. + +image::keycloak_client.png[Add a new client] + +In the next step, add a new user. Click on the "Users" section in the outline, then on "Add user" and fill in the form fields. + +image::keycloak_add_user.png[Add a new user] + +After this, open the "Credentials" tab and enter the credentials of the user. The newly created user is now able to authenticate himself in Keycloak. By default, new users must enter a new password themselves the first time they log in to fully set up the new account. So open http://localhost:8180/auth/realms/devon4quarkus-product/account, sign in with the new user and enter new credentials for him. + +== Enable the access control in Quarkus + +To secure the Quarkus application, we use role-based access control (RBAC) using JWTs, which is the common approach in devon4j. We use the Smallrye JWT extension to implement it. For more documentation, see the official link:https://quarkus.io/guides/security-jwt[Quarkus guide on JWT RBAC]. + +.**Smallrye JWT extension** +[source,xml] +---- + + io.quarkus + quarkus-smallrye-jwt + +---- + +The extension is already included in the dependencies. To enable it, set `quarkus.smallrye-jwt.enabled=true` in the `application.properties` file. + +Additionally, the issuer of the JWT and the location of the public key must be configured. To do this, the following two properties must be set: + +[source,properties] +---- +mp.jwt.verify.issuer=http://localhost:8180/auth/realms/devon4quarkus-product +mp.jwt.verify.publickey.location=http://localhost:8180/auth/realms/devon4quarkus-product/protocol/openid-connect/certs +---- + +== Test the application + +Now test whether the access control work. Run the application with `mvn clean compile quarkus:dev`. + +First, try to make a request to get all products: + +.*Bash command* +[source, bash] +---- +curl -X GET http://localhost:8080/products -v +---- + +.*Powershell command* +[source, powershell] +---- +Invoke-WebRequest -method GET http://localhost:8080/products +---- + +You should receive a response with status code 200 containing a list of all products. This is because the corresponding method in the `ProductRestService.java` file is annotated with `@PermitAll`, which causes anyone (even without having a specific role) to be able to access this method. + +In the next step, try to delete a product: + +.*Bash command* +[source, bash] +---- +curl -X DELETE http://localhost:8080/products/100 -v +---- + +.*Powershell command* +[source, powershell] +---- +Invoke-WebRequest -method DELETE http://localhost:8080/products/100 +---- + +You will get an `401 Unauthorized` response. This is because the corresponding method is annotated with `@RolesAllowed(ApplicationAccessControlConfig.PERMISSION_DELETE_PRODUCT)`, which means that only users with a valid token and the corresponding role can access this method. + +--- + +So, in the next step, add the appropriate role to the user. Log in to Keycloak again with the admin credentials, create a new role `devon4quarkus-product.DeleteProduct`, edit the previously created user and add the role to the field "Assigned Roles". + +image::keycloak_user_role.png[Configure the role] + +After that, you need to obtain a token from Keycloak that you can pass in the request. To do this, use the following commands: + +.*Bash command* +[source, bash] +---- +curl -d 'client_id=devon4quarkus-product-cli' -d 'username=john doe' -d 'password=demo' -d 'grant_type=password' http://localhost:8180/auth/realms/devon4quarkus-product/protocol/openid-connect/token +---- + +.*Powershell command* +[source, powershell] +---- +$body = @{ + "client_id" = "devon4quarkus-product-cli" + "grant_type" = "password" + "username" = "john doe" + "password" = "demo" +} +Invoke-WebRequest -method POST -body $body -contenttype "application/x-www-form-urlencoded" http://localhost:8180/auth/realms/devon4quarkus-product/protocol/openid-connect/token | Select-Object -Expand content +---- + +Finally, we can test the delete method again. Pass the "access_token" from the previous response as Bearer token in the Authorization header of the request. + +.*Bash command* +[source, bash] +---- +curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:8080/products/100 -v +---- + +.*Powershell command* +[source, powershell] +---- +$headers = @{ + Authorization="Bearer $TOKEN" +} +Invoke-WebRequest -method DELETE -headers $headers http://localhost:8080/products/100 +---- + +Now you should be able to delete the product. \ No newline at end of file diff --git a/documentation/Keycloak/keycloak_client.png b/documentation/Keycloak/keycloak_client.png new file mode 100644 index 00000000..f5bc0d25 Binary files /dev/null and b/documentation/Keycloak/keycloak_client.png differ diff --git a/documentation/Keycloak/keycloak_user_role.png b/documentation/Keycloak/keycloak_user_role.png new file mode 100644 index 00000000..3a6357b1 Binary files /dev/null and b/documentation/Keycloak/keycloak_user_role.png differ diff --git a/pom.xml b/pom.xml index 209d9e14..9aecf40b 100644 --- a/pom.xml +++ b/pom.xml @@ -82,7 +82,10 @@ io.quarkus quarkus-smallrye-fault-tolerance - + + io.quarkus + quarkus-smallrye-jwt + org.hibernate hibernate-jpamodelgen @@ -137,7 +140,7 @@ provided - + @@ -151,6 +154,11 @@ rest-assured test + + io.quarkus + quarkus-test-security + test + org.testcontainers testcontainers @@ -165,7 +173,7 @@ org.testcontainers postgresql test - + diff --git a/src/main/java/com/devonfw/quarkus/general/rest/security/ApplicationAccessControlConfig.java b/src/main/java/com/devonfw/quarkus/general/rest/security/ApplicationAccessControlConfig.java new file mode 100644 index 00000000..37a1bf67 --- /dev/null +++ b/src/main/java/com/devonfw/quarkus/general/rest/security/ApplicationAccessControlConfig.java @@ -0,0 +1,14 @@ +package com.devonfw.quarkus.general.rest.security; + +public class ApplicationAccessControlConfig { + + public static final String APP_ID = "devon4quarkus-product"; + + private static final String PREFIX = APP_ID + "."; + + public static final String PERMISSION_FIND_PRODUCT = PREFIX + "FindProduct"; + + public static final String PERMISSION_SAVE_PRODUCT = PREFIX + "SaveProduct"; + + public static final String PERMISSION_DELETE_PRODUCT = PREFIX + "DeleteProduct"; +} \ No newline at end of file diff --git a/src/main/java/com/devonfw/quarkus/productmanagement/rest/v1/ProductRestService.java b/src/main/java/com/devonfw/quarkus/productmanagement/rest/v1/ProductRestService.java index 42c2220a..8b1f50a8 100644 --- a/src/main/java/com/devonfw/quarkus/productmanagement/rest/v1/ProductRestService.java +++ b/src/main/java/com/devonfw/quarkus/productmanagement/rest/v1/ProductRestService.java @@ -6,6 +6,8 @@ import java.util.Optional; +import javax.annotation.security.PermitAll; +import javax.annotation.security.RolesAllowed; import javax.inject.Inject; import javax.ws.rs.Consumes; import javax.ws.rs.DELETE; @@ -25,6 +27,7 @@ import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; import org.springframework.data.domain.Page; +import com.devonfw.quarkus.general.rest.security.ApplicationAccessControlConfig; import com.devonfw.quarkus.productmanagement.domain.model.ProductEntity; import com.devonfw.quarkus.productmanagement.domain.repo.ProductRepository; import com.devonfw.quarkus.productmanagement.rest.v1.mapper.ProductMapper; @@ -46,6 +49,7 @@ public class ProductRestService { UriInfo uriInfo; @GET + @PermitAll public Page getAllOrderedByTitle() { Page products = this.productRepository.findAllByOrderByTitle(); @@ -56,6 +60,7 @@ public Page getAllOrderedByTitle() { } @POST + @RolesAllowed(ApplicationAccessControlConfig.PERMISSION_SAVE_PRODUCT) public Response createNewProduct(ProductDto product) { if (isEmpty(product.getTitle())) { @@ -80,6 +85,7 @@ public Page findProducts(ProductSearchCriteriaDto searchCriteria) { } @GET + @PermitAll @Path("{id}") public ProductDto getProductById(@Parameter(description = "Product unique id") @PathParam("id") String id) { @@ -91,6 +97,7 @@ public ProductDto getProductById(@Parameter(description = "Product unique id") @ } @GET + @PermitAll @Path("title/{title}") public ProductDto getProductByTitle(@PathParam("title") String title) { @@ -98,6 +105,7 @@ public ProductDto getProductByTitle(@PathParam("title") String title) { } @DELETE + @RolesAllowed(ApplicationAccessControlConfig.PERMISSION_DELETE_PRODUCT) @Path("{id}") public Response deleteProductById(@Parameter(description = "Product unique id") @PathParam("id") String id) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 83344518..4455ee13 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -42,4 +42,10 @@ quarkus.micrometer.binder.http-server.enabled=true %test.quarkus.flyway.clean-at-start=true -ryuk.container.image=testcontainersofficial/ryuk \ No newline at end of file +ryuk.container.image=testcontainersofficial/ryuk + +#access control settings +#to enable role-based access control based on JWTs, see the keycloak_authentication guide in the documentation/Keycloak folder +quarkus.smallrye-jwt.enabled=false +#mp.jwt.verify.issuer=http://localhost:8180/auth/realms/devon4quarkus-product +#mp.jwt.verify.publickey.location=http://localhost:8180/auth/realms/devon4quarkus-product/protocol/openid-connect/certs \ No newline at end of file diff --git a/src/test/java/com/devonfw/demoquarkus/rest/v1/ProductRestServiceTest.java b/src/test/java/com/devonfw/demoquarkus/rest/v1/ProductRestServiceTest.java index dc3a7325..c5d786f9 100644 --- a/src/test/java/com/devonfw/demoquarkus/rest/v1/ProductRestServiceTest.java +++ b/src/test/java/com/devonfw/demoquarkus/rest/v1/ProductRestServiceTest.java @@ -14,10 +14,12 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; +import com.devonfw.quarkus.general.rest.security.ApplicationAccessControlConfig; import com.devonfw.quarkus.productmanagement.rest.v1.model.ProductDto; import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.TestSecurity; import io.restassured.response.Response; @QuarkusTest @@ -43,6 +45,7 @@ void getNonExistingTest() { } @Test + @TestSecurity(user = "testuser", roles = { ApplicationAccessControlConfig.PERMISSION_SAVE_PRODUCT }) @Order(3) void createNewProduct() { @@ -68,6 +71,7 @@ public void testGetById() { } @Test + @TestSecurity(user = "testuser", roles = { ApplicationAccessControlConfig.PERMISSION_DELETE_PRODUCT }) @Order(5) public void deleteById() {