From d41f63179112f74a6dc5f81234833a613e92f6f7 Mon Sep 17 00:00:00 2001 From: sshakiri Date: Thu, 27 Mar 2025 17:12:34 +0100 Subject: [PATCH 1/5] Add interceptor for auth --- backend/pom.xml | 20 +++- backend/src/main/docker/Dockerfile.jvm | 75 ++----------- .../common/security/AuthCallbackResource.java | 53 +++++++++ .../common/security/AuthCookieFilter.java | 49 +++++++++ .../general/common/security/JwtService.java | 104 ++++++++++++++++++ .../common/security/PermissionService.java | 67 +++++++++++ .../app/general/common/security/Roles.java | 5 + .../app/general/common/security/Session.java | 32 ++++++ .../common/security/SessionService.java | 68 ++++++++++++ .../example/app/task/service/TaskService.java | 40 +++++++ .../src/main/resources/application.properties | 16 ++- .../app/task/service/TaskServiceIT.java | 0 docker-compose.yaml | 13 +++ frontend/src/App.tsx | 46 +++++++- frontend/src/provider/todoListProvider.tsx | 43 +++++--- frontend/src/provider/todoProvider.tsx | 6 +- frontend/vite.config.mjs | 2 +- keycloak/quarkus-realm.json | 70 ++++++++++++ 18 files changed, 615 insertions(+), 94 deletions(-) create mode 100644 backend/src/main/java/org/example/app/general/common/security/AuthCallbackResource.java create mode 100644 backend/src/main/java/org/example/app/general/common/security/AuthCookieFilter.java create mode 100644 backend/src/main/java/org/example/app/general/common/security/JwtService.java create mode 100644 backend/src/main/java/org/example/app/general/common/security/PermissionService.java create mode 100644 backend/src/main/java/org/example/app/general/common/security/Roles.java create mode 100644 backend/src/main/java/org/example/app/general/common/security/Session.java create mode 100644 backend/src/main/java/org/example/app/general/common/security/SessionService.java create mode 100644 backend/src/main/java/org/example/app/task/service/TaskService.java create mode 100644 backend/src/test/java/org/example/app/task/service/TaskServiceIT.java create mode 100644 keycloak/quarkus-realm.json diff --git a/backend/pom.xml b/backend/pom.xml index ed1fd00..a78da9f 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -48,7 +48,21 @@ quarkus-smallrye-jwt - io.quarkus + io.smallrye + smallrye-jwt + + + jakarta.ws.rs + jakarta.ws.rs-api + 4.0.0 + + + org.json + json + 20250107 + + + io.quarkus quarkus-smallrye-openapi @@ -74,6 +88,10 @@ io.quarkus quarkus-hibernate-validator + + + io.quarkus + quarkus-redis-client org.mapstruct diff --git a/backend/src/main/docker/Dockerfile.jvm b/backend/src/main/docker/Dockerfile.jvm index bcdebdc..7a2e654 100644 --- a/backend/src/main/docker/Dockerfile.jvm +++ b/backend/src/main/docker/Dockerfile.jvm @@ -1,80 +1,20 @@ #### # This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode # -# Before building the container image run: -# -# ./mvnw package +# Before building the docker image run: +# mvn package # # Then, build the image with: -# -# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/code-with-quarkus-jvm . +# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/backend . # # Then run the container using: -# -# docker run -i --rm -p 8080:8080 quarkus/code-with-quarkus-jvm +# docker run -i --rm -p 8080:8080 quarkus/backend # # If you want to include the debug port into your docker image -# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005. -# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005 -# when running the container +# you will have to expose the debug port (default 5005) like this : EXPOSE 8080 5005 # # Then run the container using : -# -# docker run -i --rm -p 8080:8080 quarkus/code-with-quarkus-jvm -# -# This image uses the `run-java.sh` script to run the application. -# This scripts computes the command line to execute your Java application, and -# includes memory/GC tuning. -# You can configure the behavior using the following environment properties: -# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") -# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options -# in JAVA_OPTS (example: "-Dsome.property=foo") -# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is -# used to calculate a default maximal heap memory based on a containers restriction. -# If used in a container without any memory constraints for the container then this -# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio -# of the container available memory as set here. The default is `50` which means 50% -# of the available memory is used as an upper boundary. You can skip this mechanism by -# setting this value to `0` in which case no `-Xmx` option is added. -# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This -# is used to calculate a default initial heap memory based on the maximum heap memory. -# If used in a container without any memory constraints for the container then this -# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio -# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` -# is used as the initial heap size. You can skip this mechanism by setting this value -# to `0` in which case no `-Xms` option is added (example: "25") -# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. -# This is used to calculate the maximum value of the initial heap memory. If used in -# a container without any memory constraints for the container then this option has -# no effect. If there is a memory constraint then `-Xms` is limited to the value set -# here. The default is 4096MB which means the calculated value of `-Xms` never will -# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") -# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output -# when things are happening. This option, if set to true, will set -# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). -# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: -# true"). -# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). -# - CONTAINER_CORE_LIMIT: A calculated core limit as described in -# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") -# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). -# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. -# (example: "20") -# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. -# (example: "40") -# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. -# (example: "4") -# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus -# previous GC times. (example: "90") -# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") -# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") -# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should -# contain the necessary JRE command-line options to specify the required GC, which -# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). -# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") -# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") -# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be -# accessed directly. (example: "foo.example.com,bar.example.com") +# docker run -i --rm -p 8080:8080 -p 5005:5005 -e JAVA_ENABLE_DEBUG="true" quarkus/mvp-quarkus-jvm # ### FROM registry.access.redhat.com/ubi8/openjdk-21:1.18 @@ -93,5 +33,4 @@ USER 185 ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" -ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] - +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] \ No newline at end of file diff --git a/backend/src/main/java/org/example/app/general/common/security/AuthCallbackResource.java b/backend/src/main/java/org/example/app/general/common/security/AuthCallbackResource.java new file mode 100644 index 0000000..0e3ca90 --- /dev/null +++ b/backend/src/main/java/org/example/app/general/common/security/AuthCallbackResource.java @@ -0,0 +1,53 @@ +package org.example.app.general.common.security; + +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.*; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import java.util.HashMap; +import java.util.Map; + +@Path("/auth/callback") +public class AuthCallbackResource { + + @Inject + SessionService sessionService; + + @Inject + JwtService jwtService; + + @Inject + ContainerRequestContext requestContext; + + @GET + public Response callback(@QueryParam("code") String code, + @Context HttpHeaders headers) { + if (code == null) { + return Response.status(Response.Status.BAD_REQUEST).entity("Authorization code missing").build(); + } + + Cookie sessionCookie = headers.getCookies().get("SESSION_ID"); + if (sessionCookie == null) { + return Response.status(Response.Status.BAD_REQUEST).entity("Missing session ID cookie").build(); + } + String sessionId = sessionCookie.getValue(); + String originalUrl = sessionService.getSession(sessionId).get().getOriginalUrl(); + + String jwt = jwtService.exchangeCodeForToken(code); + sessionService.storeSession(sessionId, jwt, ""); + + // Redirect to the original URL the user wanted to access + if (originalUrl != null && !originalUrl.isEmpty()) { + return Response.status(Response.Status.FOUND) + .header("Location", originalUrl) + .build(); + } else { + return Response.ok("Authenticated").build(); + } + } +} diff --git a/backend/src/main/java/org/example/app/general/common/security/AuthCookieFilter.java b/backend/src/main/java/org/example/app/general/common/security/AuthCookieFilter.java new file mode 100644 index 0000000..e4edf12 --- /dev/null +++ b/backend/src/main/java/org/example/app/general/common/security/AuthCookieFilter.java @@ -0,0 +1,49 @@ +package org.example.app.general.common.security; + +import io.quarkus.runtime.configuration.ConfigUtils; +import jakarta.inject.Inject; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.container.ContainerRequestFilter; +import jakarta.ws.rs.core.*; +import jakarta.ws.rs.ext.Provider; + +import java.io.IOException; +import java.util.Optional; + +@Provider +public class AuthCookieFilter implements ContainerRequestFilter { + + @Inject + SessionService sessionService; + + @Inject + JwtService jwtService; + + @Override + public void filter(ContainerRequestContext requestContext) throws IOException { + if (requestContext.getUriInfo().getPath().equals("/auth/callback") || ConfigUtils.getProfiles().contains("test")) { + return; // Skip filter for the callback or test mode + } + + // Save the original URL (the endpoint the user originally requested) + String originalUrl = requestContext.getHeaderString("Referer"); + + String authRedirectUrl = jwtService.buildKeycloakAuthUrl(); + Cookie sessionCookie = requestContext.getCookies().get("SESSION_ID"); + + if (!isValidSession(sessionCookie.getValue())) { + sessionService.storeSession(sessionCookie.getValue(), "", originalUrl); + // Redirect to Keycloak if no valid session found + requestContext.abortWith( + Response.status(Response.Status.FOUND) + .header("Location", authRedirectUrl) + .build() + ); + } + } + + private boolean isValidSession(String sessionId) { + Optional session = sessionService.getSession(sessionId); + return session.isPresent() && !session.get().getJwt().isEmpty(); + } +} diff --git a/backend/src/main/java/org/example/app/general/common/security/JwtService.java b/backend/src/main/java/org/example/app/general/common/security/JwtService.java new file mode 100644 index 0000000..af8d9bd --- /dev/null +++ b/backend/src/main/java/org/example/app/general/common/security/JwtService.java @@ -0,0 +1,104 @@ +package org.example.app.general.common.security; + +import io.quarkus.runtime.configuration.ConfigUtils; +import io.smallrye.jwt.auth.principal.ParseException; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.Form; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriBuilder; +import org.json.JSONObject; + +import java.io.StringReader; +import java.util.Base64; +import java.util.List; +import java.util.stream.Collectors; + + +@ApplicationScoped +public class JwtService { + + private static final String KEYCLOAK_TOKEN_URL = "http://localhost:8180/realms/quarkus/protocol/openid-connect/token"; + private static final String CLIENT_ID = "backend-service"; + private static final String REDIRECT_URI = "http://localhost:8080/auth/callback"; // This must be the same as the one used in the authorization request + private static final String KEYCLOAK_AUTH_URL = "http://localhost:8180/realms/quarkus/protocol/openid-connect/auth"; + private static final String RESPONSE_TYPE = "code"; + private static final String SCOPE = "openid"; + + public List getRoles(String jwtString) throws ParseException { + // Split JWT into its parts: header, payload, and signature + String[] chunks = jwtString.split("\\."); + + // Base64 decode the payload (second part of the JWT) + Base64.Decoder decoder = Base64.getUrlDecoder(); + String payload = new String(decoder.decode(chunks[1])); + + // Parse the payload as JSONObject + JSONObject jsonPayload = new JSONObject(payload); + + if (ConfigUtils.getProfiles().contains("test")) { + return jsonPayload.optJSONArray("groups").toList().stream() + .map(role -> role.toString().trim().toUpperCase()) // Convert each role to uppercase + .collect(Collectors.toList()); + } + JSONObject realmAccess = jsonPayload.optJSONObject("realm_access"); + + // Check if the realm_access is found and contains the "roles" array + if (realmAccess != null) { + List roles = realmAccess.optJSONArray("roles").toList().stream() + .map(role -> role.toString().trim().toUpperCase()) // Convert each role to uppercase + .collect(Collectors.toList()); + + return roles; + } else { + // If realm_access or roles are not found, return an empty list or handle the case + return List.of(); + } + } + + public String exchangeCodeForToken(String code) { + Client client = ClientBuilder.newClient(); + + Form form = new Form(); + form.param("client_id", CLIENT_ID); + form.param("code", code); + form.param("redirect_uri", REDIRECT_URI); + form.param("grant_type", "authorization_code"); + + Response response = client.target(KEYCLOAK_TOKEN_URL) + .request(MediaType.APPLICATION_JSON) + .post(Entity.form(form)); + + if (response.getStatus() == 200) { + String responseJson = response.readEntity(String.class); // Get the response as a JSON string + + // Parse the JSON response to extract the access token + JsonObject jsonObject = Json.createReader(new StringReader(responseJson)).readObject(); + String accessToken = jsonObject.getString("access_token"); + + response.close(); + return accessToken; + } else { + response.close(); + return null; + } + } + + public String buildKeycloakAuthUrl() { + // Include the original URL as a query parameter for the callback + return UriBuilder.fromUri(KEYCLOAK_AUTH_URL) + .queryParam("client_id", CLIENT_ID) + .queryParam("redirect_uri", REDIRECT_URI) + .queryParam("response_type", RESPONSE_TYPE) + .queryParam("scope", SCOPE) + .queryParam("state", "state") + .queryParam("nonce", "nonce") + .build() + .toString(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/example/app/general/common/security/PermissionService.java b/backend/src/main/java/org/example/app/general/common/security/PermissionService.java new file mode 100644 index 0000000..d15ca64 --- /dev/null +++ b/backend/src/main/java/org/example/app/general/common/security/PermissionService.java @@ -0,0 +1,67 @@ +package org.example.app.general.common.security; + +import io.smallrye.jwt.auth.principal.ParseException; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.container.ContainerRequestContext; +import jakarta.ws.rs.core.Response; + +import java.util.ArrayList; +import java.util.List; + +import static org.example.app.general.common.security.ApplicationAccessControlConfig.*; + + + +@ApplicationScoped +public class PermissionService { + + @Inject + JwtService jwtService; + + @Inject + SessionService sessionService; + + @Inject + ContainerRequestContext requestContext; + + private List getPermissions() throws ParseException { + List permissions = new ArrayList<>(); + String sessionId = requestContext.getCookies().get("SESSION_ID").getValue(); + List roles = jwtService.getRoles(sessionService.getSession(sessionId).get().getJwt()); + roles.forEach(role->{ + Roles userRole = Roles.valueOf(role.toUpperCase()); + switch (userRole) { + case ADMIN: + permissions.add(PERMISSION_FIND_TASK_LIST); + permissions.add(PERMISSION_SAVE_TASK_LIST); + permissions.add(PERMISSION_DELETE_TASK_LIST); + permissions.add(PERMISSION_FIND_TASK_ITEM); + permissions.add(PERMISSION_SAVE_TASK_ITEM); + permissions.add(PERMISSION_DELETE_TASK_ITEM); + break; + case USER: + permissions.add(PERMISSION_FIND_TASK_LIST); + permissions.add(PERMISSION_FIND_TASK_ITEM); + permissions.add(PERMISSION_SAVE_TASK_ITEM); + break; + default: + break; + } + }); + return permissions; + } + + public Response checkPermission(String requiredPermission) { + try { + if (!getPermissions().contains(requiredPermission)) { + return Response.status(Response.Status.FORBIDDEN) + .entity("Access Denied: No valid permissions for the current user") + .build(); + } + } catch (ParseException e) { + throw new RuntimeException(e); + } + return null; // Return null if the user has permission + } +} diff --git a/backend/src/main/java/org/example/app/general/common/security/Roles.java b/backend/src/main/java/org/example/app/general/common/security/Roles.java new file mode 100644 index 0000000..8860c3c --- /dev/null +++ b/backend/src/main/java/org/example/app/general/common/security/Roles.java @@ -0,0 +1,5 @@ +package org.example.app.general.common.security; + +public enum Roles { + ADMIN, USER; +} \ No newline at end of file diff --git a/backend/src/main/java/org/example/app/general/common/security/Session.java b/backend/src/main/java/org/example/app/general/common/security/Session.java new file mode 100644 index 0000000..c8cf592 --- /dev/null +++ b/backend/src/main/java/org/example/app/general/common/security/Session.java @@ -0,0 +1,32 @@ +package org.example.app.general.common.security; + +public class Session { + private String jwt; + private String originalUrl; + + public Session() { + } + + // Constructor + public Session(String jwt, String originalUrl) { + this.jwt = jwt; + this.originalUrl = originalUrl; + } + + // Getters and setters + public String getJwt() { + return jwt; + } + + public void setJwt(String jwt) { + this.jwt = jwt; + } + + public String getOriginalUrl() { + return originalUrl; + } + + public void setOriginalUrl(String originalUrl) { + this.originalUrl = originalUrl; + } +} diff --git a/backend/src/main/java/org/example/app/general/common/security/SessionService.java b/backend/src/main/java/org/example/app/general/common/security/SessionService.java new file mode 100644 index 0000000..6fa2329 --- /dev/null +++ b/backend/src/main/java/org/example/app/general/common/security/SessionService.java @@ -0,0 +1,68 @@ +package org.example.app.general.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.redis.datasource.keys.KeyCommands; +import io.quarkus.redis.datasource.value.SetArgs; +import io.quarkus.redis.datasource.value.ValueCommands; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.Form; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.io.StringReader; +import java.util.Optional; + +@ApplicationScoped +public class SessionService { + + private final ValueCommands redis; + private final ObjectMapper objectMapper; // Jackson ObjectMapper to serialize/deserialize + + @Inject + public SessionService(RedisDataSource redisDataSource) { + this.redis = redisDataSource.value(String.class); + this.objectMapper = new ObjectMapper(); + } + + private static final long SESSION_EXPIRY_SECONDS = 600; // 10 min expiry + + // Store session containing JWT and original URL + public void storeSession(String sessionId, String jwt, String originalUrl) { + try { + Session session = new Session(jwt, originalUrl); + + // Serialize session to JSON + String sessionJson = objectMapper.writeValueAsString(session); + + // Use SetArgs to define expiration + SetArgs setArgs = new SetArgs().ex(SESSION_EXPIRY_SECONDS); + redis.set(sessionId, sessionJson, setArgs); // Store serialized session JSON in Redis + } catch (Exception e) { + e.printStackTrace(); + } + } + + // Get session containing JWT and original URL + public Optional getSession(String sessionId) { + try { + String sessionJson = redis.get(sessionId); + if (sessionJson != null) { + // Deserialize session JSON back into Session object + Session session = objectMapper.readValue(sessionJson, Session.class); + return Optional.of(session); + } else { + return Optional.empty(); + } + } catch (Exception e) { + e.printStackTrace(); + return Optional.empty(); + } + } +} diff --git a/backend/src/main/java/org/example/app/task/service/TaskService.java b/backend/src/main/java/org/example/app/task/service/TaskService.java new file mode 100644 index 0000000..e6611b1 --- /dev/null +++ b/backend/src/main/java/org/example/app/task/service/TaskService.java @@ -0,0 +1,40 @@ +package org.example.app.task.service; + +import io.quarkus.security.Authenticated; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.media.Content; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.example.app.general.common.security.PermissionService; + +import static org.example.app.general.common.security.ApplicationAccessControlConfig.*; + +@Path("/task") +public class TaskService { + + @Inject + private PermissionService permissionService; + + /** + * @return response + */ + @GET + @Path("/lists") + @Produces(MediaType.APPLICATION_JSON) + @Operation(summary = "Fetch task lists", description = "Fetch all task lists") + @APIResponse(responseCode = "200", description = "Task lists", content = @Content(mediaType = MediaType.APPLICATION_JSON)) + @APIResponse(responseCode = "404", description = "Task lists not found") + @APIResponse(responseCode = "500", description = "Server unavailable or a server-side error occurred") + public Response findTaskLists() { + Response permissionResponse = permissionService.checkPermission(PERMISSION_FIND_TASK_LIST); + if (permissionResponse != null) { + return permissionResponse; + } + return Response.ok().build(); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index ac74aad..ca0949e 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -10,16 +10,24 @@ quarkus.flyway.create-schemas=true quarkus.flyway.migrate-at-start=true quarkus.http.cors=true -quarkus.http.cors.origins=http://localhost:3000,http://localhost:8080 +quarkus.http.cors.origins=http://localhost:5173,http://localhost:8080 quarkus.http.cors.headers=accept, authorization, content-type, x-requested-with quarkus.http.cors.methods=GET, POST, OPTIONS, DELETE -%dev.quarkus.devservices.enabled=true +quarkus.devservices.enabled=true %dev.quarkus.hibernate-orm.log.sql=true %dev.quarkus.hibernate-orm.validate-in-dev-mode=true %dev.quarkus.flyway.schemas=quarkus quarkus.rest-client.bored-api.url=https://www.boredapi.com/api/ - - +# OIDC Configuration +%dev.quarkus.keycloak.devservices.enabled=false +%dev.quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus +%dev.quarkus.oidc.client-id=backend-service +%test.quarkus.keycloak.devservices.enabled=true +%test.quarkus.keycloak.devservices.port=8180 +%test.quarkus.oidc.client-id=backend-service +%test.quarkus.oidc.credentials.secret=secret +%test.quarkus.oidc.credentials.grant-type=password +%test.quarkus.oidc.authentication.direct-grants=true \ No newline at end of file diff --git a/backend/src/test/java/org/example/app/task/service/TaskServiceIT.java b/backend/src/test/java/org/example/app/task/service/TaskServiceIT.java new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yaml b/docker-compose.yaml index 0621cb7..a96be22 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -35,6 +35,19 @@ services: networks: - quarkus + keycloak: + image: quay.io/keycloak/keycloak:26.1.4 + ports: + - "8180:8080" + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + volumes: + - ./keycloak/quarkus-realm.json:/opt/keycloak/data/import/realm-config.json # Mount realm config + command: -v start-dev --import-realm + networks: + - quarkus + ollama: image: ollama/ollama:0.5.13 container_name: ollama diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0b2b42e..3e181e3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,14 @@ import { Snackbar } from "@material-ui/core"; import { Alert } from "@material-ui/lab"; -import { useContext } from "react"; +import { useContext, useEffect } from "react"; import { Route } from "wouter"; import CalendarView from "./components/calendar"; import Header from "./components/misc/header"; import Sidebar from "./components/misc/sidebar"; -import Todos from "./components/todos/todos"; import { MainContext } from "./provider/mainProvider"; +import Todos from "./components/todos/Todos"; + +const COOKIE_EXPIRATION_TIME = 3600 * 1000; // 1 hour in milliseconds function App() { const { @@ -17,6 +19,46 @@ function App() { showCalendar, } = useContext(MainContext)!; + // Function to generate a new session ID + const generateSessionId = () => { + return crypto.randomUUID(); + }; + + // Function to set a session cookie + const setSessionCookie = () => { + const newSessionId = generateSessionId(); + document.cookie = `SESSION_ID=${newSessionId}; path=/; max-age=3600; Secure; SameSite=None`; + console.log("New SESSION_ID set:", newSessionId); + }; + + // Function to get an existing cookie + const getCookie = (name: string) => { + const cookies = document.cookie.split("; "); + for (let cookie of cookies) { + const [key, value] = cookie.split("="); + if (key === name) { + return value; + } + } + return null; + }; + + // Runs once on app load + useEffect(() => { + // If SESSION_ID doesn't exist, create one + if (!getCookie("SESSION_ID")) { + setSessionCookie(); + } + + // Set an interval to renew the session cookie when it expires + const interval = setInterval(() => { + console.log("Refreshing session cookie..."); + setSessionCookie(); + }, COOKIE_EXPIRATION_TIME); + + return () => clearInterval(interval); // Cleanup interval on unmount + }, []); + return (
diff --git a/frontend/src/provider/todoListProvider.tsx b/frontend/src/provider/todoListProvider.tsx index 80b75e2..6691683 100644 --- a/frontend/src/provider/todoListProvider.tsx +++ b/frontend/src/provider/todoListProvider.tsx @@ -14,18 +14,25 @@ export const TodoListProvider = ({ children }: PropsI) => { const [taskLists, setTaskLists] = useState([]); useEffect(() => { - fetch(`/api/task/lists`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }) - .then((response) => response.json()) - .then((json) => setTaskLists(json)) - .catch((error) => { - console.error(error); - setErrorAlert("List could not be loaded!"); - }); + const fetchData = async () => { + try { + const response = await fetch(`/api/task/lists`, { + method: 'GET', + credentials: 'include', + }); + if (response.status === 401) { + const data = await response.json(); + window.location.href = data.redirectUrl; // Redirect to Keycloak + } else { + const json = await response.json(); + setTaskLists(json); + } + } catch (error) { + console.error('Error fetching data:', error); + setErrorAlert("List could not be loaded!"); // Set error alert in case of failure + } + }; + fetchData(); }, [setErrorAlert]); function editTodoList(newTitle: string) { @@ -41,8 +48,7 @@ export const TodoListProvider = ({ children }: PropsI) => { fetch("/api/task/list", { method: "POST", headers: { - Accept: "application/json", - "Content-Type": "application/json", + 'Content-Type': 'application/json', // Ensure that the correct content type is set }, body: JSON.stringify(taskList), }) @@ -70,8 +76,7 @@ export const TodoListProvider = ({ children }: PropsI) => { fetch("/api/task/list", { method: "POST", headers: { - Accept: "application/json", - "Content-Type": "application/json", + 'Content-Type': 'application/json', // Ensure that the correct content type is set }, body: JSON.stringify(taskList), // body data type must match "Content-Type" header }) @@ -93,7 +98,11 @@ export const TodoListProvider = ({ children }: PropsI) => { fetch(`/api/task/list/${encodeURIComponent(id)}`, { method: "DELETE", }) - .then(() => { + .then((res) => { + if(res.status === 403){ + setErrorAlert("List could not be deleted (No permissions)!"); + return; + } setTaskLists(taskLists.filter((taskList) => taskList.id !== id)); if (undefined !== listId && id === +listId) { navigate("/"); diff --git a/frontend/src/provider/todoProvider.tsx b/frontend/src/provider/todoProvider.tsx index ac03bc2..42caf5e 100644 --- a/frontend/src/provider/todoProvider.tsx +++ b/frontend/src/provider/todoProvider.tsx @@ -143,7 +143,11 @@ export const TodoProvider = ({ children }: PropsI) => { fetch(`/api/task/item/${encodeURIComponent(id)}`, { method: "DELETE", }) - .then(() => { + .then((res) => { + if(res.status === 403){ + setErrorAlert("Item could not be deleted (No permissions)!"); + return; + } setTodos(todos.filter((todo) => todo.id !== id)); setSuccessAlert("Item deleted!"); }) diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs index 235ac62..1b4c116 100644 --- a/frontend/vite.config.mjs +++ b/frontend/vite.config.mjs @@ -13,7 +13,7 @@ export default defineConfig(() => { '/api': { target: 'http://localhost:8080', changeOrigin: true, - rewrite: (path) => path.replace("/api", ""), + rewrite: (path) => path.replace("/api", "") } } } diff --git a/keycloak/quarkus-realm.json b/keycloak/quarkus-realm.json new file mode 100644 index 0000000..85d69b6 --- /dev/null +++ b/keycloak/quarkus-realm.json @@ -0,0 +1,70 @@ + +{ + "realm": "quarkus", + "enabled": true, + "clients": [ + { + "clientId": "backend-service", + "enabled": true, + "protocol": "openid-connect", + "rootUrl": "http://localhost:8080", + "adminUrl": "http://localhost:8080", + "baseUrl": "http://localhost:8080", + "redirectUris": [ + "http://localhost:8080/*" + ], + "publicClient": true, + "implicitFlowEnabled": true, + "webOrigins": [ + "*" + ] + } + ], + "roles": { + "realm": [ + { + "name": "admin", + "description": "Administrator role" + }, + { + "name": "user", + "description": "User role" + } + ] + }, + "users": [ + { + "username": "alice", + "enabled": true, + "firstName": "Alice", + "lastName": "Smith", + "email": "alice@example.com", + "credentials": [ + { + "type": "password", + "value": "alice" + } + ], + "realmRoles": [ + "admin", + "user" + ] + }, + { + "username": "bob", + "enabled": true, + "firstName": "Bob", + "lastName": "Johnson", + "email": "bob@example.com", + "credentials": [ + { + "type": "password", + "value": "bob" + } + ], + "realmRoles": [ + "user" + ] + } + ] +} \ No newline at end of file From 56a94349ab6d2973e072909409be76ad75ee18c3 Mon Sep 17 00:00:00 2001 From: sshakiri Date: Mon, 31 Mar 2025 16:23:55 +0200 Subject: [PATCH 2/5] Fix Tests --- .../common/security/PermissionService.java | 12 +- .../task/resource/KeycloakTokenProvider.java | 48 +++ .../task/service/IntegrationTestProfile.java | 11 + .../app/task/service/TaskServiceIT.java | 80 +++++ .../app/task/service/TaskServiceTest.java | 333 ++++++++++++++++++ 5 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 backend/src/test/java/org/example/app/task/resource/KeycloakTokenProvider.java create mode 100644 backend/src/test/java/org/example/app/task/service/IntegrationTestProfile.java create mode 100644 backend/src/test/java/org/example/app/task/service/TaskServiceTest.java diff --git a/backend/src/main/java/org/example/app/general/common/security/PermissionService.java b/backend/src/main/java/org/example/app/general/common/security/PermissionService.java index d15ca64..58ba0f7 100644 --- a/backend/src/main/java/org/example/app/general/common/security/PermissionService.java +++ b/backend/src/main/java/org/example/app/general/common/security/PermissionService.java @@ -1,10 +1,12 @@ package org.example.app.general.common.security; +import io.quarkus.runtime.configuration.ConfigUtils; import io.smallrye.jwt.auth.principal.ParseException; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; import java.util.ArrayList; import java.util.List; @@ -27,8 +29,14 @@ public class PermissionService { private List getPermissions() throws ParseException { List permissions = new ArrayList<>(); - String sessionId = requestContext.getCookies().get("SESSION_ID").getValue(); - List roles = jwtService.getRoles(sessionService.getSession(sessionId).get().getJwt()); + String jwt; + if (ConfigUtils.getProfiles().contains("test")) { + jwt = requestContext.getHeaders().get("Authorization").getFirst(); + }else { + String sessionId = requestContext.getCookies().get("SESSION_ID").getValue(); + jwt = sessionService.getSession(sessionId).get().getJwt(); + } + List roles = jwtService.getRoles(jwt); roles.forEach(role->{ Roles userRole = Roles.valueOf(role.toUpperCase()); switch (userRole) { diff --git a/backend/src/test/java/org/example/app/task/resource/KeycloakTokenProvider.java b/backend/src/test/java/org/example/app/task/resource/KeycloakTokenProvider.java new file mode 100644 index 0000000..bea705f --- /dev/null +++ b/backend/src/test/java/org/example/app/task/resource/KeycloakTokenProvider.java @@ -0,0 +1,48 @@ +package org.example.app.task.resource; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.Response; + +import java.util.HashMap; +import java.util.Map; + +public class KeycloakTokenProvider { + + private static final String KEYCLOAK_URL = "http://localhost:8180/realms/quarkus/protocol/openid-connect/token"; + private static final String CLIENT_ID = "backend-service"; + private static final String USERNAME_ADMIN = "alice"; + private static final String PASSWORD_ADMIN = "alice"; + private static final String USERNAME_USER = "bob"; + private static final String PASSWORD_USER = "bob"; + private static final String SECRET = "secret"; + + public static String getAccessTokenWithAdmin() { + return getAccessToken(USERNAME_ADMIN, PASSWORD_ADMIN); + } + + public static String getAccessTokenWithUser() { + return getAccessToken(USERNAME_USER, PASSWORD_USER); + } + + public static String getAccessToken(String username, String password) { + Map params = new HashMap<>(); + params.put("grant_type", "password"); + params.put("client_id", CLIENT_ID); + params.put("username", username); + params.put("password", password); + params.put("client_secret", SECRET); + + + Response response = RestAssured.given() + .contentType(ContentType.URLENC) + .formParams(params) + .post(KEYCLOAK_URL); + + if (response.getStatusCode() != 200) { + throw new RuntimeException("Failed to get token: " + response.getBody().asString()); + } + + return response.jsonPath().getString("access_token"); + } +} \ No newline at end of file diff --git a/backend/src/test/java/org/example/app/task/service/IntegrationTestProfile.java b/backend/src/test/java/org/example/app/task/service/IntegrationTestProfile.java new file mode 100644 index 0000000..44359be --- /dev/null +++ b/backend/src/test/java/org/example/app/task/service/IntegrationTestProfile.java @@ -0,0 +1,11 @@ +package org.example.app.task.service; + +import io.quarkus.test.junit.QuarkusTestProfile; + +public class IntegrationTestProfile implements QuarkusTestProfile { + + @Override + public String getConfigProfile() { + return "test"; + } +} \ No newline at end of file diff --git a/backend/src/test/java/org/example/app/task/service/TaskServiceIT.java b/backend/src/test/java/org/example/app/task/service/TaskServiceIT.java index e69de29..aab95ff 100644 --- a/backend/src/test/java/org/example/app/task/service/TaskServiceIT.java +++ b/backend/src/test/java/org/example/app/task/service/TaskServiceIT.java @@ -0,0 +1,80 @@ + +package org.example.app.task.service; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.emptyString; +import static org.hamcrest.Matchers.not; + +import io.quarkus.test.junit.TestProfile; +import org.example.app.task.resource.KeycloakTokenProvider; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; +import org.junit.jupiter.api.TestInstance.Lifecycle; + +import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.restassured.http.ContentType; +import io.restassured.response.Response; + +/** + * E2E black-box test of the To-Do service only via its public REST resource. + */ +@QuarkusIntegrationTest +@TestMethodOrder(OrderAnnotation.class) +@TestProfile(IntegrationTestProfile.class) +@TestInstance(Lifecycle.PER_CLASS) +class TaskServiceIT { + + private Integer taskListId; + + private Integer taskItemId; + + private String token; + + @BeforeAll + void getJwt(){ + token = KeycloakTokenProvider.getAccessTokenWithAdmin(); + } + + @Test + @Order(1) + void shouldAllowCreatingANewTaskList() { + + Response response = given().when().header("Authorization", token).body("{ \"title\": \"Shopping List\" }").contentType(ContentType.JSON) + .post("/task/list"); + response.then().statusCode(201).header("Location", not(emptyString())); + + this.taskListId = Integer.parseInt(response.header("Location").replaceAll(".*?/task/list/", "")); + } + + @Test + @Order(2) + void shouldAllowAddingATaskToATaskList() { + + Response response = given().when().header("Authorization", token).body("{ \"title\": \"Buy Milk\", \"taskListId\": " + this.taskListId + " }") + .contentType(ContentType.JSON).post("/task/item"); + + response.then().statusCode(201).header("Location", not(emptyString())); + + this.taskItemId = Integer.parseInt(response.header("Location").replaceAll(".*?/task/item/", "")); + } + + @Test + @Order(3) + void shouldAllowRetrievingATaskListWithTaskItems() { + + given().when().header("Authorization", token).get("/task/list-with-items/{taskListId}", this.taskListId).then().statusCode(200) + .body("list.title", Matchers.equalTo("Shopping List")).and().body("list.id", Matchers.equalTo(this.taskListId)) + .and().body("items[0].title", Matchers.equalTo("Buy Milk")); + } + + @Test + @Order(4) + void shouldAllowDeletingATaskListCompletely() { + + given().when().header("Authorization", token).delete("/task/list/{taskListId}", this.taskListId).then().statusCode(204); + given().when().header("Authorization", token).get("/task/list/{taskListId}", this.taskListId).then().statusCode(404); + given().when().header("Authorization", token).get("/task/item/{itemId}", this.taskItemId).then().statusCode(404); + + } +} \ No newline at end of file diff --git a/backend/src/test/java/org/example/app/task/service/TaskServiceTest.java b/backend/src/test/java/org/example/app/task/service/TaskServiceTest.java new file mode 100644 index 0000000..3124766 --- /dev/null +++ b/backend/src/test/java/org/example/app/task/service/TaskServiceTest.java @@ -0,0 +1,333 @@ + +package org.example.app.task.service; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.mockito.InjectMock; +import io.restassured.http.ContentType; + +import static io.restassured.RestAssured.given; +import static net.javacrumbs.jsonunit.JsonMatchers.jsonEquals; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import io.quarkus.test.security.TestSecurity; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.BDDAssertions; +import org.example.app.task.common.TaskItemEto; +import org.example.app.task.common.TaskListCto; +import org.example.app.task.logic.UcAddRandomActivityTaskItem; +import org.example.app.task.logic.UcDeleteTaskItem; +import org.example.app.task.logic.UcDeleteTaskList; +import org.example.app.task.logic.UcFindTaskItem; +import org.example.app.task.logic.UcFindTaskList; +import org.example.app.task.logic.UcSaveTaskItem; +import org.example.app.task.logic.UcSaveTaskList; +import org.example.app.task.resource.KeycloakTokenProvider; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.anyString; + +/** + * Test of {@link TaskService}. + */ +@QuarkusTest +@DisplayName("/task") +class TaskServiceTest extends Assertions { + + private static String adminToken; + + private static String userToken; + + @InjectMock + UcSaveTaskList saveTaskList; + + @InjectMock + UcFindTaskList findTaskList; + + @InjectMock + UcDeleteTaskList deleteTaskList; + + @InjectMock + UcSaveTaskItem saveTaskItem; + + @InjectMock + UcFindTaskItem findTaskItem; + + @InjectMock + UcDeleteTaskItem deleteTaskItem; + + @InjectMock + UcAddRandomActivityTaskItem addRandomActivityTaskItem; + + @BeforeAll + static void getJwt(){ + adminToken = KeycloakTokenProvider.getAccessTokenWithAdmin(); + userToken = KeycloakTokenProvider.getAccessTokenWithUser(); + } + + @Nested + @DisplayName("/list") + class TaskListCollection { + + @Nested + @DisplayName("POST") + class Post { + + @Test + void shouldCallSaveUseCaseAndReturn204WhenCreatingTaskList() { + + given(TaskServiceTest.this.saveTaskList.save(Mockito.any())).willReturn(123L); + + given().when().header("Authorization", adminToken).body("{ \"title\": \"Shopping List\" }").contentType(ContentType.JSON).post("/task/list").then() + .statusCode(201); + } + + @Test + void shouldFailWith400AndValidationErrorWhenTitleIsEmpty() { + + given().when().header("Authorization", adminToken).body("{ \"title\": \"\" }").contentType(ContentType.JSON).post("/task/list").then() + .statusCode(400); + then(TaskServiceTest.this.saveTaskList).shouldHaveNoInteractions(); + } + } + + @Nested + @DisplayName("/{listId}/") + class TaskList { + + @Nested + @DisplayName("GET") + class Get { + + @Test + void shouldReturnJsonWhenTaskListExists() { + + given(TaskServiceTest.this.findTaskList.findById(anyLong())).willReturn(TaskListMother.complete()); + + given().when().header("Authorization", adminToken).get("/task/list/123").then().statusCode(200) + .body(jsonEquals("{\"id\":123,\"version\":1,\"title\":\"Shopping List\"}")); + } + + @Test + void shouldReturn404WhenUnknownTaskList() { + + given(TaskServiceTest.this.findTaskList.findById(anyLong())).willReturn(null); + + given().when().header("Authorization", adminToken).get("/task/list/99").then().statusCode(404); + } + } + + @Nested + @DisplayName("DELETE") + class Delete { + + @Test + void shouldCallDeleteUseCaseAndReturn204() { + + given().when().header("Authorization", adminToken).delete("/task/list/1").then().statusCode(204); + then(TaskServiceTest.this.deleteTaskList).should().delete(1L); + } + + @Test + void shouldFailWith403() { + given().when().header("Authorization", userToken).delete("/task/list/1").then().statusCode(403); + } + } + + @Nested + @DisplayName("/random-activity") + class RandomActivity { + + @Nested + @DisplayName("POST") + class Post { + @Test + void shouldCallRandomActivityUseCaseAndReturn201() { + + given().when().header("Authorization", adminToken).post("/task/list/1/random-activity").then().statusCode(201); + then(TaskServiceTest.this.addRandomActivityTaskItem).should().addRandom(1L); + } + } + } + } + + @Nested + @DisplayName("/list-with-items/{taskListId}") + class TaskListWithItems { + + @Nested + @DisplayName("GET") + class Get { + + @Test + void shouldReturnListWithItemsWhenListExists() { + + TaskListCto taskList = new TaskListCto(); + taskList.setList(TaskListMother.complete()); + taskList.setItems(List.of(TaskItemMother.complete())); + + given(TaskServiceTest.this.findTaskList.findWithItems(123L)).willReturn(taskList); + + given().when().header("Authorization", adminToken).get("/task/list-with-items/123").then().statusCode(200).body(jsonEquals( + "{\"items\":[{\"id\":42,\"version\":1,\"completed\":false,\"starred\":false,\"taskListId\":123,\"title\":\"Buy Eggs\"}],\"list\":{\"id\":123,\"version\":1,\"title\":\"Shopping List\"}}")); + } + + @Test + void shouldReturn404WhenListDoesntExist() { + + given(TaskServiceTest.this.findTaskList.findWithItems(anyLong())).willReturn(null); + + given().when().header("Authorization", adminToken).get("/task/list-with-items/99").then().statusCode(404); + } + } + } + + @Nested + @DisplayName("/multiple-random-activities") + class MultipleRandomActivities { + + @Nested + @DisplayName("POST") + class Post { + @Test + void shouldCallRandomActivitiesUseCaseAndReturn201() { + + given().when().header("Authorization", adminToken).body("Shopping list").contentType(ContentType.TEXT).post("/task/list/multiple-random-activities").then().statusCode(201); + then(TaskServiceTest.this.addRandomActivityTaskItem).should().addMultipleRandom(anyLong(), anyString()); + } + + @Test + void shouldFailWith400AndValidationErrorWhenTitleIsEmpty() { + + given().when().header("Authorization", adminToken).contentType(ContentType.TEXT).post("/task/list/multiple-random-activities").then().statusCode(400); + then(TaskServiceTest.this.addRandomActivityTaskItem).shouldHaveNoInteractions(); + } + } + } + + @Nested + @DisplayName("/ingredient-list") + class IngredientList { + + @Nested + @DisplayName("POST") + class Post { + @Test + void shouldCallRandomActivitiesUseCaseAndReturn201() { + + given().when().header("Authorization", adminToken).body("{\"listTitle\": \"Shopping list\", \"recipe\": \"Take flour, sugar and chocolate and mix everything.\"}") + .contentType(ContentType.JSON).post("/task/list/ingredient-list").then().statusCode(201); + then(TaskServiceTest.this.addRandomActivityTaskItem).should().addExtractedIngredients(anyLong(), anyString()); + } + + @Test + void shouldFailWith400AndValidationErrorWhenTitleIsEmpty() { + + given().when().header("Authorization", adminToken).body("{\"recipe\": \"Take flour, sugar and chocolate and mix everything.\"}").contentType(ContentType.JSON).post("/task/list/ingredient-list").then().statusCode(400); + then(TaskServiceTest.this.addRandomActivityTaskItem).shouldHaveNoInteractions(); + } + + @Test + void shouldFailWith400AndValidationErrorWhenRecipeIsEmpty() { + + given().when().header("Authorization", adminToken).body("{\"listTitle\": \"Shopping list\"}").contentType(ContentType.JSON).post("/task/list/ingredient-list").then().statusCode(400); + then(TaskServiceTest.this.addRandomActivityTaskItem).shouldHaveNoInteractions(); + } + } + } + } + + @Nested + @DisplayName("/item") + class TaskItemCollection { + + @Nested + @DisplayName("POST") + class Post { + + @Test + void shouldCallSaveUseCaseAndReturn201WhenCreatingTaskItem() { + + given(TaskServiceTest.this.saveTaskItem.save(Mockito.any())).willReturn(42L); + + given().when().header("Authorization", adminToken).body("{ \"title\": \"Buy Milk\", \"taskListId\": 123 }").contentType(ContentType.JSON) + .post("/task/item").then().statusCode(201).body(is("42")); + + ArgumentCaptor taskItemCaptor = ArgumentCaptor.forClass(TaskItemEto.class); + then(TaskServiceTest.this.saveTaskItem).should().save(taskItemCaptor.capture()); + BDDAssertions.then(taskItemCaptor.getValue()).usingRecursiveComparison() + .isEqualTo(TaskItemMother.notYetSaved()); + } + + @Test + void shouldFailWith400AndValidationErrorWhenTitleIsEmpty() { + + given().when().header("Authorization", adminToken).body("{ \"title\": \"\", \"taskListId\": 123 }").contentType(ContentType.JSON) + .post("/task/item").then().statusCode(400); + then(TaskServiceTest.this.saveTaskItem).shouldHaveNoInteractions(); + } + + @Test + void shouldFailWith400AndValidationErrorWhenTaskListIdNotGiven() { + + given().when().header("Authorization", adminToken).body("{ \"title\": \"Buy Milk\" }").contentType(ContentType.JSON).post("/task/item").then() + .statusCode(400); + then(TaskServiceTest.this.saveTaskItem).shouldHaveNoInteractions(); + } + } + + @Nested + @DisplayName("/{itemId}/") + class TaskItem { + + @Nested + @DisplayName("GET") + class Get { + + @Test + void shouldReturnJsonWhenItemExists() { + + given(TaskServiceTest.this.findTaskItem.findById(anyLong())).willReturn(TaskItemMother.complete()); + + given().when().header("Authorization", adminToken).get("/task/item/42").then().statusCode(200).body(jsonEquals( + "{\"id\":42,\"version\":1,\"completed\":false,\"starred\":false,\"taskListId\":123,\"title\":\"Buy Eggs\"}")); + } + + @Test + void shouldReturn404WhenUnknownTaskItem() { + + given(TaskServiceTest.this.findTaskItem.findById(anyLong())).willReturn(null); + + given().when().header("Authorization", adminToken).get("/task/item/99").then().statusCode(404); + } + + } + } + + @Nested + @DisplayName("DELETE") + class Delete { + + @Test + void shouldCallDeleteUseCaseAndReturn204() { + + given().when().header("Authorization", adminToken).delete("/task/item/42").then().statusCode(204); + then(TaskServiceTest.this.deleteTaskItem).should().delete(42L); + } + + @Test + void shouldFailWith403() { + given().when().header("Authorization", userToken).delete("/task/item/42").then().statusCode(403); + } + } + } +} \ No newline at end of file From 6456294931c085fa57930b0d61d2ede6942db72a Mon Sep 17 00:00:00 2001 From: sshakiri Date: Wed, 2 Apr 2025 09:50:07 +0200 Subject: [PATCH 3/5] Fix Cors-Issues --- .../app/general/common/security/AuthCookieFilter.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/main/java/org/example/app/general/common/security/AuthCookieFilter.java b/backend/src/main/java/org/example/app/general/common/security/AuthCookieFilter.java index e4edf12..265b12d 100644 --- a/backend/src/main/java/org/example/app/general/common/security/AuthCookieFilter.java +++ b/backend/src/main/java/org/example/app/general/common/security/AuthCookieFilter.java @@ -33,10 +33,10 @@ public void filter(ContainerRequestContext requestContext) throws IOException { if (!isValidSession(sessionCookie.getValue())) { sessionService.storeSession(sessionCookie.getValue(), "", originalUrl); - // Redirect to Keycloak if no valid session found requestContext.abortWith( - Response.status(Response.Status.FOUND) - .header("Location", authRedirectUrl) + Response.status(Response.Status.UNAUTHORIZED) + .entity("{\"redirectUrl\": \"" + authRedirectUrl + "\"}") + .type(MediaType.APPLICATION_JSON) .build() ); } From 04ab6d8eca7dc6934f41f34f50d18228bd62e4cb Mon Sep 17 00:00:00 2001 From: sshakiri Date: Thu, 3 Apr 2025 12:10:16 +0200 Subject: [PATCH 4/5] Clean up --- .../app/task/service/TaskServiceIT.java | 55 +-- .../app/task/service/TaskServiceTest.java | 333 ------------------ 2 files changed, 7 insertions(+), 381 deletions(-) delete mode 100644 backend/src/test/java/org/example/app/task/service/TaskServiceTest.java diff --git a/backend/src/test/java/org/example/app/task/service/TaskServiceIT.java b/backend/src/test/java/org/example/app/task/service/TaskServiceIT.java index aab95ff..672950b 100644 --- a/backend/src/test/java/org/example/app/task/service/TaskServiceIT.java +++ b/backend/src/test/java/org/example/app/task/service/TaskServiceIT.java @@ -25,56 +25,15 @@ @TestInstance(Lifecycle.PER_CLASS) class TaskServiceIT { - private Integer taskListId; + private Integer taskListId; - private Integer taskItemId; + private Integer taskItemId; - private String token; + private String token; - @BeforeAll - void getJwt(){ - token = KeycloakTokenProvider.getAccessTokenWithAdmin(); - } + @BeforeAll + void getJwt() { + token = KeycloakTokenProvider.getAccessTokenWithAdmin(); + } - @Test - @Order(1) - void shouldAllowCreatingANewTaskList() { - - Response response = given().when().header("Authorization", token).body("{ \"title\": \"Shopping List\" }").contentType(ContentType.JSON) - .post("/task/list"); - response.then().statusCode(201).header("Location", not(emptyString())); - - this.taskListId = Integer.parseInt(response.header("Location").replaceAll(".*?/task/list/", "")); - } - - @Test - @Order(2) - void shouldAllowAddingATaskToATaskList() { - - Response response = given().when().header("Authorization", token).body("{ \"title\": \"Buy Milk\", \"taskListId\": " + this.taskListId + " }") - .contentType(ContentType.JSON).post("/task/item"); - - response.then().statusCode(201).header("Location", not(emptyString())); - - this.taskItemId = Integer.parseInt(response.header("Location").replaceAll(".*?/task/item/", "")); - } - - @Test - @Order(3) - void shouldAllowRetrievingATaskListWithTaskItems() { - - given().when().header("Authorization", token).get("/task/list-with-items/{taskListId}", this.taskListId).then().statusCode(200) - .body("list.title", Matchers.equalTo("Shopping List")).and().body("list.id", Matchers.equalTo(this.taskListId)) - .and().body("items[0].title", Matchers.equalTo("Buy Milk")); - } - - @Test - @Order(4) - void shouldAllowDeletingATaskListCompletely() { - - given().when().header("Authorization", token).delete("/task/list/{taskListId}", this.taskListId).then().statusCode(204); - given().when().header("Authorization", token).get("/task/list/{taskListId}", this.taskListId).then().statusCode(404); - given().when().header("Authorization", token).get("/task/item/{itemId}", this.taskItemId).then().statusCode(404); - - } } \ No newline at end of file diff --git a/backend/src/test/java/org/example/app/task/service/TaskServiceTest.java b/backend/src/test/java/org/example/app/task/service/TaskServiceTest.java deleted file mode 100644 index 3124766..0000000 --- a/backend/src/test/java/org/example/app/task/service/TaskServiceTest.java +++ /dev/null @@ -1,333 +0,0 @@ - -package org.example.app.task.service; - -import io.quarkus.test.junit.QuarkusTest; -import io.quarkus.test.junit.mockito.InjectMock; -import io.restassured.http.ContentType; - -import static io.restassured.RestAssured.given; -import static net.javacrumbs.jsonunit.JsonMatchers.jsonEquals; -import static org.hamcrest.Matchers.is; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.then; - -import io.quarkus.test.security.TestSecurity; -import org.assertj.core.api.Assertions; -import org.assertj.core.api.BDDAssertions; -import org.example.app.task.common.TaskItemEto; -import org.example.app.task.common.TaskListCto; -import org.example.app.task.logic.UcAddRandomActivityTaskItem; -import org.example.app.task.logic.UcDeleteTaskItem; -import org.example.app.task.logic.UcDeleteTaskList; -import org.example.app.task.logic.UcFindTaskItem; -import org.example.app.task.logic.UcFindTaskList; -import org.example.app.task.logic.UcSaveTaskItem; -import org.example.app.task.logic.UcSaveTaskList; -import org.example.app.task.resource.KeycloakTokenProvider; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; - -import java.util.List; - -import static org.mockito.ArgumentMatchers.anyString; - -/** - * Test of {@link TaskService}. - */ -@QuarkusTest -@DisplayName("/task") -class TaskServiceTest extends Assertions { - - private static String adminToken; - - private static String userToken; - - @InjectMock - UcSaveTaskList saveTaskList; - - @InjectMock - UcFindTaskList findTaskList; - - @InjectMock - UcDeleteTaskList deleteTaskList; - - @InjectMock - UcSaveTaskItem saveTaskItem; - - @InjectMock - UcFindTaskItem findTaskItem; - - @InjectMock - UcDeleteTaskItem deleteTaskItem; - - @InjectMock - UcAddRandomActivityTaskItem addRandomActivityTaskItem; - - @BeforeAll - static void getJwt(){ - adminToken = KeycloakTokenProvider.getAccessTokenWithAdmin(); - userToken = KeycloakTokenProvider.getAccessTokenWithUser(); - } - - @Nested - @DisplayName("/list") - class TaskListCollection { - - @Nested - @DisplayName("POST") - class Post { - - @Test - void shouldCallSaveUseCaseAndReturn204WhenCreatingTaskList() { - - given(TaskServiceTest.this.saveTaskList.save(Mockito.any())).willReturn(123L); - - given().when().header("Authorization", adminToken).body("{ \"title\": \"Shopping List\" }").contentType(ContentType.JSON).post("/task/list").then() - .statusCode(201); - } - - @Test - void shouldFailWith400AndValidationErrorWhenTitleIsEmpty() { - - given().when().header("Authorization", adminToken).body("{ \"title\": \"\" }").contentType(ContentType.JSON).post("/task/list").then() - .statusCode(400); - then(TaskServiceTest.this.saveTaskList).shouldHaveNoInteractions(); - } - } - - @Nested - @DisplayName("/{listId}/") - class TaskList { - - @Nested - @DisplayName("GET") - class Get { - - @Test - void shouldReturnJsonWhenTaskListExists() { - - given(TaskServiceTest.this.findTaskList.findById(anyLong())).willReturn(TaskListMother.complete()); - - given().when().header("Authorization", adminToken).get("/task/list/123").then().statusCode(200) - .body(jsonEquals("{\"id\":123,\"version\":1,\"title\":\"Shopping List\"}")); - } - - @Test - void shouldReturn404WhenUnknownTaskList() { - - given(TaskServiceTest.this.findTaskList.findById(anyLong())).willReturn(null); - - given().when().header("Authorization", adminToken).get("/task/list/99").then().statusCode(404); - } - } - - @Nested - @DisplayName("DELETE") - class Delete { - - @Test - void shouldCallDeleteUseCaseAndReturn204() { - - given().when().header("Authorization", adminToken).delete("/task/list/1").then().statusCode(204); - then(TaskServiceTest.this.deleteTaskList).should().delete(1L); - } - - @Test - void shouldFailWith403() { - given().when().header("Authorization", userToken).delete("/task/list/1").then().statusCode(403); - } - } - - @Nested - @DisplayName("/random-activity") - class RandomActivity { - - @Nested - @DisplayName("POST") - class Post { - @Test - void shouldCallRandomActivityUseCaseAndReturn201() { - - given().when().header("Authorization", adminToken).post("/task/list/1/random-activity").then().statusCode(201); - then(TaskServiceTest.this.addRandomActivityTaskItem).should().addRandom(1L); - } - } - } - } - - @Nested - @DisplayName("/list-with-items/{taskListId}") - class TaskListWithItems { - - @Nested - @DisplayName("GET") - class Get { - - @Test - void shouldReturnListWithItemsWhenListExists() { - - TaskListCto taskList = new TaskListCto(); - taskList.setList(TaskListMother.complete()); - taskList.setItems(List.of(TaskItemMother.complete())); - - given(TaskServiceTest.this.findTaskList.findWithItems(123L)).willReturn(taskList); - - given().when().header("Authorization", adminToken).get("/task/list-with-items/123").then().statusCode(200).body(jsonEquals( - "{\"items\":[{\"id\":42,\"version\":1,\"completed\":false,\"starred\":false,\"taskListId\":123,\"title\":\"Buy Eggs\"}],\"list\":{\"id\":123,\"version\":1,\"title\":\"Shopping List\"}}")); - } - - @Test - void shouldReturn404WhenListDoesntExist() { - - given(TaskServiceTest.this.findTaskList.findWithItems(anyLong())).willReturn(null); - - given().when().header("Authorization", adminToken).get("/task/list-with-items/99").then().statusCode(404); - } - } - } - - @Nested - @DisplayName("/multiple-random-activities") - class MultipleRandomActivities { - - @Nested - @DisplayName("POST") - class Post { - @Test - void shouldCallRandomActivitiesUseCaseAndReturn201() { - - given().when().header("Authorization", adminToken).body("Shopping list").contentType(ContentType.TEXT).post("/task/list/multiple-random-activities").then().statusCode(201); - then(TaskServiceTest.this.addRandomActivityTaskItem).should().addMultipleRandom(anyLong(), anyString()); - } - - @Test - void shouldFailWith400AndValidationErrorWhenTitleIsEmpty() { - - given().when().header("Authorization", adminToken).contentType(ContentType.TEXT).post("/task/list/multiple-random-activities").then().statusCode(400); - then(TaskServiceTest.this.addRandomActivityTaskItem).shouldHaveNoInteractions(); - } - } - } - - @Nested - @DisplayName("/ingredient-list") - class IngredientList { - - @Nested - @DisplayName("POST") - class Post { - @Test - void shouldCallRandomActivitiesUseCaseAndReturn201() { - - given().when().header("Authorization", adminToken).body("{\"listTitle\": \"Shopping list\", \"recipe\": \"Take flour, sugar and chocolate and mix everything.\"}") - .contentType(ContentType.JSON).post("/task/list/ingredient-list").then().statusCode(201); - then(TaskServiceTest.this.addRandomActivityTaskItem).should().addExtractedIngredients(anyLong(), anyString()); - } - - @Test - void shouldFailWith400AndValidationErrorWhenTitleIsEmpty() { - - given().when().header("Authorization", adminToken).body("{\"recipe\": \"Take flour, sugar and chocolate and mix everything.\"}").contentType(ContentType.JSON).post("/task/list/ingredient-list").then().statusCode(400); - then(TaskServiceTest.this.addRandomActivityTaskItem).shouldHaveNoInteractions(); - } - - @Test - void shouldFailWith400AndValidationErrorWhenRecipeIsEmpty() { - - given().when().header("Authorization", adminToken).body("{\"listTitle\": \"Shopping list\"}").contentType(ContentType.JSON).post("/task/list/ingredient-list").then().statusCode(400); - then(TaskServiceTest.this.addRandomActivityTaskItem).shouldHaveNoInteractions(); - } - } - } - } - - @Nested - @DisplayName("/item") - class TaskItemCollection { - - @Nested - @DisplayName("POST") - class Post { - - @Test - void shouldCallSaveUseCaseAndReturn201WhenCreatingTaskItem() { - - given(TaskServiceTest.this.saveTaskItem.save(Mockito.any())).willReturn(42L); - - given().when().header("Authorization", adminToken).body("{ \"title\": \"Buy Milk\", \"taskListId\": 123 }").contentType(ContentType.JSON) - .post("/task/item").then().statusCode(201).body(is("42")); - - ArgumentCaptor taskItemCaptor = ArgumentCaptor.forClass(TaskItemEto.class); - then(TaskServiceTest.this.saveTaskItem).should().save(taskItemCaptor.capture()); - BDDAssertions.then(taskItemCaptor.getValue()).usingRecursiveComparison() - .isEqualTo(TaskItemMother.notYetSaved()); - } - - @Test - void shouldFailWith400AndValidationErrorWhenTitleIsEmpty() { - - given().when().header("Authorization", adminToken).body("{ \"title\": \"\", \"taskListId\": 123 }").contentType(ContentType.JSON) - .post("/task/item").then().statusCode(400); - then(TaskServiceTest.this.saveTaskItem).shouldHaveNoInteractions(); - } - - @Test - void shouldFailWith400AndValidationErrorWhenTaskListIdNotGiven() { - - given().when().header("Authorization", adminToken).body("{ \"title\": \"Buy Milk\" }").contentType(ContentType.JSON).post("/task/item").then() - .statusCode(400); - then(TaskServiceTest.this.saveTaskItem).shouldHaveNoInteractions(); - } - } - - @Nested - @DisplayName("/{itemId}/") - class TaskItem { - - @Nested - @DisplayName("GET") - class Get { - - @Test - void shouldReturnJsonWhenItemExists() { - - given(TaskServiceTest.this.findTaskItem.findById(anyLong())).willReturn(TaskItemMother.complete()); - - given().when().header("Authorization", adminToken).get("/task/item/42").then().statusCode(200).body(jsonEquals( - "{\"id\":42,\"version\":1,\"completed\":false,\"starred\":false,\"taskListId\":123,\"title\":\"Buy Eggs\"}")); - } - - @Test - void shouldReturn404WhenUnknownTaskItem() { - - given(TaskServiceTest.this.findTaskItem.findById(anyLong())).willReturn(null); - - given().when().header("Authorization", adminToken).get("/task/item/99").then().statusCode(404); - } - - } - } - - @Nested - @DisplayName("DELETE") - class Delete { - - @Test - void shouldCallDeleteUseCaseAndReturn204() { - - given().when().header("Authorization", adminToken).delete("/task/item/42").then().statusCode(204); - then(TaskServiceTest.this.deleteTaskItem).should().delete(42L); - } - - @Test - void shouldFailWith403() { - given().when().header("Authorization", userToken).delete("/task/item/42").then().statusCode(403); - } - } - } -} \ No newline at end of file From 9a02ca61a314136f92bbdccbe3a5d88c8990adf5 Mon Sep 17 00:00:00 2001 From: sshakiri Date: Thu, 10 Apr 2025 08:30:36 +0200 Subject: [PATCH 5/5] Add diagramm --- .../diagrams/keycloak-with-session.drawio | 211 ++++++++++++++++++ .../diagrams/keycloak-with-session.png | Bin 0 -> 67254 bytes 2 files changed, 211 insertions(+) create mode 100644 documentation/diagrams/keycloak-with-session.drawio create mode 100644 documentation/diagrams/keycloak-with-session.png diff --git a/documentation/diagrams/keycloak-with-session.drawio b/documentation/diagrams/keycloak-with-session.drawio new file mode 100644 index 0000000..8b4dbcc --- /dev/null +++ b/documentation/diagrams/keycloak-with-session.drawio @@ -0,0 +1,211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/documentation/diagrams/keycloak-with-session.png b/documentation/diagrams/keycloak-with-session.png new file mode 100644 index 0000000000000000000000000000000000000000..9bdbea2f3bd4602e2e1a7bba534cbbf54b1a4f19 GIT binary patch literal 67254 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�nh#Jah<=!UICK({;!D$D1cwFcqv3xgTFDCP~qg4 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