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..265b12d --- /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); + requestContext.abortWith( + Response.status(Response.Status.UNAUTHORIZED) + .entity("{\"redirectUrl\": \"" + authRedirectUrl + "\"}") + .type(MediaType.APPLICATION_JSON) + .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..58ba0f7 --- /dev/null +++ b/backend/src/main/java/org/example/app/general/common/security/PermissionService.java @@ -0,0 +1,75 @@ +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; + +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 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) { + 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/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 new file mode 100644 index 0000000..672950b --- /dev/null +++ b/backend/src/test/java/org/example/app/task/service/TaskServiceIT.java @@ -0,0 +1,39 @@ + +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(); + } + +} \ No newline at end of file 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/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 0000000..9bdbea2 Binary files /dev/null and b/documentation/diagrams/keycloak-with-session.png differ 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