Co-authored-by: TakahashiNg <83152264+TakahashiNguyen@users.noreply.github.com> Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+3
-16
@@ -17,6 +17,9 @@
|
||||
<packaging>quarkus</packaging>
|
||||
|
||||
<properties>
|
||||
<quarkus.package.jar.enabled>false</quarkus.package.jar.enabled>
|
||||
<skipITs>false</skipITs>
|
||||
<quarkus.native.enabled>true</quarkus.native.enabled>
|
||||
<compiler-plugin.version>3.15.0</compiler-plugin.version>
|
||||
<maven.compiler.release>25</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
@@ -158,20 +161,4 @@
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>native</id>
|
||||
<activation>
|
||||
<property>
|
||||
<name>native</name>
|
||||
</property>
|
||||
</activation>
|
||||
<properties>
|
||||
<quarkus.package.jar.enabled>false</quarkus.package.jar.enabled>
|
||||
<skipITs>false</skipITs>
|
||||
<quarkus.native.enabled>true</quarkus.native.enabled>
|
||||
</properties>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
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
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
|
||||
+14
-13
@@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project
|
||||
xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"
|
||||
xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"
|
||||
>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
@@ -23,11 +23,9 @@
|
||||
<compiler-plugin.version>3.15.0</compiler-plugin.version>
|
||||
<maven.compiler.release>25</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.reporting.outputEncoding
|
||||
>UTF-8</project.reporting.outputEncoding>
|
||||
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
|
||||
<quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
|
||||
<quarkus.platform.group-id
|
||||
>io.quarkus.platform</quarkus.platform.group-id>
|
||||
<quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
|
||||
<quarkus.platform.version>3.32.4</quarkus.platform.version>
|
||||
<skipITs>true</skipITs>
|
||||
<surefire-plugin.version>3.5.4</surefire-plugin.version>
|
||||
@@ -46,6 +44,11 @@
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.bitbucket.b_c</groupId>
|
||||
<artifactId>jose4j</artifactId>
|
||||
<version>0.9.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.drinkool</groupId>
|
||||
<artifactId>lib</artifactId>
|
||||
@@ -104,8 +107,7 @@
|
||||
<configuration>
|
||||
<argLine>@{argLine}</argLine>
|
||||
<systemPropertyVariables>
|
||||
<java.util.logging.manager
|
||||
>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||
<maven.home>${maven.home}</maven.home>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
@@ -126,12 +128,11 @@
|
||||
<systemPropertyVariables>
|
||||
<native.image.path>
|
||||
${project.build.directory}/${project.build.finalName}-runner</native.image.path>
|
||||
<java.util.logging.manager
|
||||
>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
|
||||
<maven.home>${maven.home}</maven.home>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
</project>
|
||||
@@ -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<Response> handleRequestHeaders(
|
||||
ContainerRequestContext requestContext
|
||||
) {
|
||||
requestContext
|
||||
.getHeaders()
|
||||
.keySet()
|
||||
.removeIf(
|
||||
key ->
|
||||
key.equalsIgnoreCase(InternalValue.headerId) ||
|
||||
key.toLowerCase().startsWith(InternalValue.headerId.toLowerCase())
|
||||
);
|
||||
|
||||
Map<String, Cookie> 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<String, Object> 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<String, Object> headers = responseContext.getHeaders();
|
||||
|
||||
List<String> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+97
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Quarkus
|
||||
quarkus.http.host=0.0.0.0
|
||||
|
||||
# JWT
|
||||
gateway.jwt.secret=1uZk07Hqu1316z9YqrcSGPTTJDhMWU1ZhFSLBrDQvmU=
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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";
|
||||
Reference in New Issue
Block a user