From 00cc6a0f1f44eab9e081fb54fff36743b5790313 Mon Sep 17 00:00:00 2001 From: TakahashiNguyen Date: Thu, 9 Apr 2026 03:05:40 +0000 Subject: [PATCH] feat: add jwt authorization (#19) Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com> Reviewed-on: https://git.demonkernel.io.vn/FoodSurf/backend/pulls/19 --- .devcontainer/Dockerfile | 1 + account-service/pom.xml | 19 +-- .../services/AuthenticationService.java | 18 +-- .../com/drinkool/services/UserService.java | 4 +- .../src/main/resources/application.properties | 6 +- .../services/AuthenticationServiceTest.java | 6 +- .../drinkool/services/UserServiceTest.java | 6 +- gateway-service/pom.xml | 27 ++-- .../filters/InternalHeaderGatewayFilter.java | 131 ++++++++++++++++++ .../src/main/resources/application.properties | 2 +- .../InternalHeaderGatewayFilterTest.java | 97 +++++++++++++ .../java/com/drinkool/utils/TestResource.java | 28 ++++ .../src/test/resources/application.properties | 5 + k8s.yaml | 21 +-- .../drinkool/{Jwt.java => InternalValue.java} | 3 +- 15 files changed, 309 insertions(+), 65 deletions(-) create mode 100644 gateway-service/src/main/java/com/drinkool/filters/InternalHeaderGatewayFilter.java create mode 100644 gateway-service/src/test/java/com/drinkool/filters/InternalHeaderGatewayFilterTest.java create mode 100644 gateway-service/src/test/java/com/drinkool/utils/TestResource.java create mode 100644 gateway-service/src/test/resources/application.properties rename lib/src/main/java/com/drinkool/{Jwt.java => InternalValue.java} (71%) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 2cc40b7..cdc7730 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -16,6 +16,7 @@ RUN apt-get update --fix-missing && apt-get install --no-install-recommends --no docker-buildx \ git \ docker.io \ + nodejs \ maven \ libz-dev diff --git a/account-service/pom.xml b/account-service/pom.xml index 8c3738b..0081ef6 100644 --- a/account-service/pom.xml +++ b/account-service/pom.xml @@ -17,6 +17,9 @@ quarkus + false + false + true 3.15.0 25 UTF-8 @@ -158,20 +161,4 @@ - - - - native - - - native - - - - false - false - true - - - diff --git a/account-service/src/main/java/com/drinkool/services/AuthenticationService.java b/account-service/src/main/java/com/drinkool/services/AuthenticationService.java index 1dde771..490bcb4 100644 --- a/account-service/src/main/java/com/drinkool/services/AuthenticationService.java +++ b/account-service/src/main/java/com/drinkool/services/AuthenticationService.java @@ -1,6 +1,6 @@ package com.drinkool.services; -import com.drinkool.Jwt; +import com.drinkool.InternalValue; import com.drinkool.Role; import com.drinkool.Url; import com.drinkool.dtos.*; @@ -25,8 +25,8 @@ public class AuthenticationService { newCustomer.signup(input.name, input.phone, input.password); return Response.status(Response.Status.CREATED) - .header(Jwt.userId, newCustomer.id.toString()) - .header(Jwt.role, Role.Customer) + .header(InternalValue.userId, newCustomer.id.toString()) + .header(InternalValue.role, Role.Customer) .build(); } @@ -39,8 +39,8 @@ public class AuthenticationService { newCustomer.signup(input.phone); return Response.status(Response.Status.CREATED) - .header(Jwt.userId, newCustomer.id.toString()) - .header(Jwt.role, Role.Customer) + .header(InternalValue.userId, newCustomer.id.toString()) + .header(InternalValue.role, Role.Customer) .build(); } @@ -63,8 +63,8 @@ public class AuthenticationService { .build(); return Response.accepted() - .header(Jwt.userId, customer.id.toString()) - .header(Jwt.role, Role.Customer) + .header(InternalValue.userId, customer.id.toString()) + .header(InternalValue.role, Role.Customer) .build(); } @@ -83,8 +83,8 @@ public class AuthenticationService { ).firstResult(); return Response.accepted() - .header(Jwt.userId, customer.id.toString()) - .header(Jwt.role, Role.Customer) + .header(InternalValue.userId, customer.id.toString()) + .header(InternalValue.role, Role.Customer) .build(); } diff --git a/account-service/src/main/java/com/drinkool/services/UserService.java b/account-service/src/main/java/com/drinkool/services/UserService.java index bcb8cb0..2cd2280 100644 --- a/account-service/src/main/java/com/drinkool/services/UserService.java +++ b/account-service/src/main/java/com/drinkool/services/UserService.java @@ -1,6 +1,6 @@ package com.drinkool.services; -import com.drinkool.Jwt; +import com.drinkool.InternalValue; import com.drinkool.Url; import com.drinkool.models.UserModel; import jakarta.transaction.Transactional; @@ -14,7 +14,7 @@ public class UserService { @POST @Path(Url.Me) @Transactional - public Response me(@HeaderParam(Jwt.userId) UUID id) { + public Response me(@HeaderParam(InternalValue.userId) UUID id) { if (id == null) return Response.status(Response.Status.BAD_REQUEST) .entity("InvalidUserId") .build(); diff --git a/account-service/src/main/resources/application.properties b/account-service/src/main/resources/application.properties index ade9388..18cb5a4 100644 --- a/account-service/src/main/resources/application.properties +++ b/account-service/src/main/resources/application.properties @@ -21,4 +21,8 @@ quarkus.redis.hosts=redis://localhost:6379 # Dependency quarkus.index-dependency.lib.group-id=com.drinkool -quarkus.index-dependency.lib.artifact-id=lib \ No newline at end of file +quarkus.index-dependency.lib.artifact-id=lib + +# Build +quarkus.native.container-build=false +quarkus.native.additional-build-args=--initialize-at-run-time=com.password4j.AlgorithmFinder \ No newline at end of file diff --git a/account-service/src/test/java/com/drinkool/services/AuthenticationServiceTest.java b/account-service/src/test/java/com/drinkool/services/AuthenticationServiceTest.java index 61d5e50..e89655e 100644 --- a/account-service/src/test/java/com/drinkool/services/AuthenticationServiceTest.java +++ b/account-service/src/test/java/com/drinkool/services/AuthenticationServiceTest.java @@ -4,7 +4,7 @@ import static io.restassured.RestAssured.*; import static org.hamcrest.CoreMatchers.*; import static org.junit.jupiter.api.Assertions.*; -import com.drinkool.Jwt; +import com.drinkool.InternalValue; import com.drinkool.Role; import com.drinkool.Url; import com.drinkool.dtos.*; @@ -55,8 +55,8 @@ public class AuthenticationServiceTest { .when() .post(Url.QuickSignup) .then() - .header(Jwt.userId, notNullValue()) - .header(Jwt.role, is(Role.Customer)) + .header(InternalValue.userId, notNullValue()) + .header(InternalValue.role, is(Role.Customer)) .statusCode(201); assertEquals(1, CustomerEntity.find("phone", phone).count()); diff --git a/account-service/src/test/java/com/drinkool/services/UserServiceTest.java b/account-service/src/test/java/com/drinkool/services/UserServiceTest.java index cfc6f19..eb8c5ea 100644 --- a/account-service/src/test/java/com/drinkool/services/UserServiceTest.java +++ b/account-service/src/test/java/com/drinkool/services/UserServiceTest.java @@ -3,7 +3,7 @@ package com.drinkool.services; import static io.restassured.RestAssured.*; import static org.hamcrest.CoreMatchers.*; -import com.drinkool.Jwt; +import com.drinkool.InternalValue; import com.drinkool.Url; import com.drinkool.dtos.QuickSignup; import io.quarkus.test.junit.QuarkusTest; @@ -26,10 +26,10 @@ public class UserServiceTest { .post(Url.QuickSignup) .then() .extract() - .header(Jwt.userId); + .header(InternalValue.userId); given() - .header(Jwt.userId, testUserId) + .header(InternalValue.userId, testUserId) .contentType(ContentType.JSON) .when() .post(Url.Me) diff --git a/gateway-service/pom.xml b/gateway-service/pom.xml index 9cb7cb3..6493ca5 100644 --- a/gateway-service/pom.xml +++ b/gateway-service/pom.xml @@ -1,8 +1,8 @@ - + 4.0.0 @@ -23,11 +23,9 @@ 3.15.0 25 UTF-8 - UTF-8 + UTF-8 quarkus-bom - io.quarkus.platform + io.quarkus.platform 3.32.4 true 3.5.4 @@ -46,6 +44,11 @@ + + org.bitbucket.b_c + jose4j + 0.9.6 + com.drinkool lib @@ -104,8 +107,7 @@ @{argLine} - org.jboss.logmanager.LogManager + org.jboss.logmanager.LogManager ${maven.home} @@ -126,12 +128,11 @@ ${project.build.directory}/${project.build.finalName}-runner - org.jboss.logmanager.LogManager + org.jboss.logmanager.LogManager ${maven.home} - + \ No newline at end of file diff --git a/gateway-service/src/main/java/com/drinkool/filters/InternalHeaderGatewayFilter.java b/gateway-service/src/main/java/com/drinkool/filters/InternalHeaderGatewayFilter.java new file mode 100644 index 0000000..99afa06 --- /dev/null +++ b/gateway-service/src/main/java/com/drinkool/filters/InternalHeaderGatewayFilter.java @@ -0,0 +1,131 @@ +package com.drinkool.filters; + +import com.drinkool.InternalValue; +import io.smallrye.mutiny.Uni; +import jakarta.ws.rs.Priorities; +import jakarta.ws.rs.container.*; +import jakarta.ws.rs.core.*; +import java.util.*; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.resteasy.reactive.server.ServerRequestFilter; +import org.jboss.resteasy.reactive.server.ServerResponseFilter; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.consumer.JwtConsumer; +import org.jose4j.jwt.consumer.JwtConsumerBuilder; +import org.jose4j.keys.HmacKey; + +public class InternalHeaderGatewayFilter { + + @ConfigProperty(name = "gateway.jwt.secret") + String SECRET_KEY; + + private boolean isSystemClaim(String key) { + return List.of("iss", "sub", "aud", "exp", "nbf", "iat", "jti").contains( + key + ); + } + + @ServerRequestFilter(preMatching = true) + public Uni handleRequestHeaders( + ContainerRequestContext requestContext + ) { + requestContext + .getHeaders() + .keySet() + .removeIf( + key -> + key.equalsIgnoreCase(InternalValue.headerId) || + key.toLowerCase().startsWith(InternalValue.headerId.toLowerCase()) + ); + + Map cookies = requestContext.getCookies(); + Cookie authCookie = cookies.get(InternalValue.cookieName); + + if (authCookie == null) return Uni.createFrom().nullItem(); + + return Uni.createFrom().item(() -> { + try { + String token = authCookie.getValue(); + + JwtConsumer jwtConsumer = new JwtConsumerBuilder() + .setRequireExpirationTime() + .setVerificationKey(new HmacKey(SECRET_KEY.getBytes())) + .build(); + + JwtClaims claims = jwtConsumer.processToClaims(token); + + Map allClaims = claims.getClaimsMap(); + + allClaims.forEach((key, value) -> { + if (!isSystemClaim(key) && value != null) { + String headerName = InternalValue.headerId + (key); + + requestContext.getHeaders().add(headerName, value.toString()); + } + }); + } catch (Exception e) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity("InvalidToken") + .build(); + } + + return null; + }); + } + + @ServerResponseFilter(priority = Priorities.USER + 100) + public void handleResponseHeaders(ContainerResponseContext responseContext) { + JwtClaims claims = new JwtClaims(); + boolean hasInternalData = false; + + MultivaluedMap headers = responseContext.getHeaders(); + + List keysToRemove = new ArrayList<>(); + + for (String key : headers.keySet()) { + if (key.toLowerCase().startsWith(InternalValue.headerId.toLowerCase())) { + Object value = headers.getFirst(key); + if (value != null) { + String claimKey = key.substring(InternalValue.headerId.length()); + claims.setClaim(claimKey, value.toString()); + hasInternalData = true; + } + keysToRemove.add(key); + } + } + + for (String key : keysToRemove) { + headers.remove(key); + } + + if (hasInternalData) { + try { + claims.setExpirationTimeMinutesInTheFuture(60); + claims.setGeneratedJwtId(); + claims.setIssuedAtToNow(); + + JsonWebSignature jws = new JsonWebSignature(); + jws.setPayload(claims.toJson()); + jws.setKey(new HmacKey(SECRET_KEY.getBytes())); + jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.HMAC_SHA256); + + String jwt = jws.getCompactSerialization(); + + NewCookie jwtCookie = new NewCookie.Builder(InternalValue.cookieName) + .value(jwt) + .path("/") + .httpOnly(true) + .secure(true) + .sameSite(NewCookie.SameSite.STRICT) + .maxAge(3600) + .build(); + + headers.add(HttpHeaders.SET_COOKIE, jwtCookie); + } catch (Exception e) { + e.printStackTrace(); + } + } + } +} diff --git a/gateway-service/src/main/resources/application.properties b/gateway-service/src/main/resources/application.properties index 43aa152..dffba85 100644 --- a/gateway-service/src/main/resources/application.properties +++ b/gateway-service/src/main/resources/application.properties @@ -8,4 +8,4 @@ quarkus.container-image.builder=docker quarkus.container-image.additional-tags=latest # Build -quarkus.native.container-build=false +quarkus.native.container-build=false \ No newline at end of file diff --git a/gateway-service/src/test/java/com/drinkool/filters/InternalHeaderGatewayFilterTest.java b/gateway-service/src/test/java/com/drinkool/filters/InternalHeaderGatewayFilterTest.java new file mode 100644 index 0000000..205c5b8 --- /dev/null +++ b/gateway-service/src/test/java/com/drinkool/filters/InternalHeaderGatewayFilterTest.java @@ -0,0 +1,97 @@ +package com.drinkool.filters; + +import static io.restassured.RestAssured.*; +import static org.hamcrest.CoreMatchers.*; +import static org.junit.jupiter.api.Assertions.*; + +import com.drinkool.InternalValue; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.response.Response; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jose4j.jws.AlgorithmIdentifiers; +import org.jose4j.jws.JsonWebSignature; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.keys.HmacKey; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@QuarkusTest +public class InternalHeaderGatewayFilterTest { + + @ConfigProperty(name = "gateway.jwt.secret") + String TEST_SECRET; + + private String createTestToken(String userId) throws Exception { + JwtClaims claims = new JwtClaims(); + claims.setExpirationTimeMinutesInTheFuture(10); + claims.setClaim("UserId", userId); + claims.setClaim("Role", "user"); + + JsonWebSignature jws = new JsonWebSignature(); + jws.setPayload(claims.toJson()); + jws.setKey(new HmacKey(TEST_SECRET.getBytes())); + jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.HMAC_SHA256); + return jws.getCompactSerialization(); + } + + @Test + @DisplayName("Nên xóa header X-Internal nếu client tự ý gửi lên") + public void testShouldRemoveClentInternalHeaders() { + given() + .header("X-Internal-userId", "hacker-id") + .when() + .get("/test-gateway") + .then() + .statusCode(200) + .body(is("not-found")); // Filter phải xóa sạch trước khi vào Resource + } + + @Test + @DisplayName("Nên trích xuất JWT từ Cookie và gắn vào Header nội bộ") + public void testValidJwtInCookie() throws Exception { + String token = createTestToken("user123"); + + given() + .cookie(InternalValue.cookieName, token) + .when() + .get("/test-gateway") + .then() + .statusCode(200) + .body(is("user123")); // Resource nhận được userId từ Header do Filter gắn vào + } + + @Test + @DisplayName("Nên trả về 401 nếu JWT sai chữ ký") + public void testInvalidSignature() { + given() + .cookie(InternalValue.cookieName, "invalid.token.string") + .when() + .get("/test-gateway") + .then() + .statusCode(401) + .body(containsString("InvalidToken")); + } + + @Test + @DisplayName( + "Nên đóng gói X-Internal headers từ service thành JWT gửi về client" + ) + public void testResponseFilterConvertsHeadersToJwt() throws Exception { + String token = createTestToken("user123"); + + Response response = given() + .cookie(InternalValue.cookieName, token) + .when() + .post("/test-gateway/update") + .then() + .statusCode(200) + .cookie(InternalValue.cookieName) // Kiểm tra có Set-Cookie trả về không + .header("X-Internal-newStatus", nullValue()) // Header nội bộ phải bị xóa khỏi response + .extract() + .response(); + + String setCookie = response.getHeader("Set-Cookie"); + assertTrue(setCookie.contains("HttpOnly")); + assertTrue(setCookie.contains("SameSite=Strict")); + } +} diff --git a/gateway-service/src/test/java/com/drinkool/utils/TestResource.java b/gateway-service/src/test/java/com/drinkool/utils/TestResource.java new file mode 100644 index 0000000..2e98cc9 --- /dev/null +++ b/gateway-service/src/test/java/com/drinkool/utils/TestResource.java @@ -0,0 +1,28 @@ +package com.drinkool.utils; + +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; + +@Path("/test-gateway") +public class TestResource { + + @GET + public Response getHeaders(@Context HttpHeaders headers) { + // Trả về header nội bộ để chúng ta kiểm tra xem Filter có gắn đúng không + String userId = headers.getHeaderString("X-Internal-userId"); + return Response.ok().entity(userId != null ? userId : "not-found").build(); + } + + @POST + @Path("/update") + public Response updateInternal( + @HeaderParam("X-Internal-userId") String userId + ) { + // Giả lập service trả về header nội bộ để Gateway đóng gói vào JWT + return Response.ok("updated") + .header("X-Internal-newStatus", "active") + .build(); + } +} diff --git a/gateway-service/src/test/resources/application.properties b/gateway-service/src/test/resources/application.properties new file mode 100644 index 0000000..bf4225d --- /dev/null +++ b/gateway-service/src/test/resources/application.properties @@ -0,0 +1,5 @@ +# Quarkus +quarkus.http.host=0.0.0.0 + +# JWT +gateway.jwt.secret=1uZk07Hqu1316z9YqrcSGPTTJDhMWU1ZhFSLBrDQvmU= \ No newline at end of file diff --git a/k8s.yaml b/k8s.yaml index c275470..4b2fde8 100644 --- a/k8s.yaml +++ b/k8s.yaml @@ -44,14 +44,6 @@ spec: requests: cpu: "100m" memory: "256Mi" - volumeMounts: - - name: keys-volume - mountPath: "/etc/keys" - readOnly: true - volumes: - - name: keys-volume - secret: - secretName: jwt-keys --- apiVersion: v1 kind: Service @@ -88,6 +80,11 @@ spec: env: - name: QUARKUS_REST_CLIENT_ACCOUNT_URL value: "http://account-service" + - name: GATEWAY_JWT_SECRET + valueFrom: + secretKeyRef: + name: jwt-secret + key: secret resources: limits: cpu: "500m" @@ -95,14 +92,6 @@ spec: requests: cpu: "100m" memory: "256Mi" - volumeMounts: - - name: keys-volume - mountPath: "/etc/keys" - readOnly: true - volumes: - - name: keys-volume - secret: - secretName: jwt-keys --- apiVersion: v1 kind: Service diff --git a/lib/src/main/java/com/drinkool/Jwt.java b/lib/src/main/java/com/drinkool/InternalValue.java similarity index 71% rename from lib/src/main/java/com/drinkool/Jwt.java rename to lib/src/main/java/com/drinkool/InternalValue.java index 3847fee..f0865a4 100644 --- a/lib/src/main/java/com/drinkool/Jwt.java +++ b/lib/src/main/java/com/drinkool/InternalValue.java @@ -1,7 +1,8 @@ package com.drinkool; -public class Jwt { +public class InternalValue { + public static final String cookieName = "auth"; public static final String headerId = "X-Internal-"; public static final String userId = headerId + "UserId"; public static final String role = headerId + "Role";