feat: JWT RS256 + refresh 会话轮换与 /api/v1 契约落地(ADR-003)
- 会话逻辑下沉 patbond-user(新 /internal/sessions;auth_sessions 只存 SHA-256 摘要,刷新即轮换并链 token_family,重用撤销整个 family,退出仅撤当前会话,多设备并行) - patbond-auth 作薄入口签发 RS256 JWT(access 15m / refresh 30d 均为配置项;密钥经环境变量注入,仓库零密钥材料,测试密钥运行时生成);公开端点迁至 /api/v1,冻结契约字段零偏差,expiresAt 无时区遗留修复 - /internal/** 加 X-Internal-Token 服务间鉴权(无凭证 401);/api/v1/me 由 user 以公钥本地验签(40101/40102 新错误码) - 登录失败限制:按用户名 15 分钟窗口 5 次锁 15 分钟(423/42300,DB 原子计数,可配置) - 修复两处存量缺陷:ErrorDecoder 未注册进 Feign 子上下文、JDK HttpURLConnection 对流式 POST 的 401 读不到错误体(引入 feign-hc5)——真实调用中下游错误码此前一律折叠为 503 - 门禁:JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test → BUILD SUCCESS,73 测试 0 失败(37→73),含同 JVM 双服务真实 HTTP E2E:注册→me→刷新→旧 refresh 重用被拒且 family 撤销→退出后 refresh 失效;Testcontainers postgres:18,无遗留容器 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package com.patbond.patbond.user.controller;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.user.TestcontainersConfiguration;
|
||||
import com.patbond.patbond.user.security.InternalAuthFilter;
|
||||
import com.patbond.patbond.user.support.TestJwtKeys;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* GET /api/v1/me behind BearerAuthFilter: RS256 tokens are verified locally
|
||||
* against the configured public key (generated per test run — no committed
|
||||
* key material). Response shape is the frozen contract:
|
||||
* {userId, username, phone, createdAt} and nothing else.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class MeEndpointTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void jwtPublicKey(DynamicPropertyRegistry registry) {
|
||||
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
|
||||
}
|
||||
|
||||
private String registerUser(String username, String phone) throws Exception {
|
||||
String body = mockMvc.perform(post("/internal/users")
|
||||
.header(InternalAuthFilter.HEADER, "test-internal-token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"%s\",\"password\":\"secret123\",\"phone\":\"%s\"}"
|
||||
.formatted(username, phone)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
return JsonPath.read(body, "$.data.id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void meReturnsExactlyTheFrozenContractFields() throws Exception {
|
||||
String userId = registerUser("me_happy", "+8613800000401");
|
||||
String token = TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(),
|
||||
UUID.fromString(userId), Duration.ofMinutes(15));
|
||||
|
||||
mockMvc.perform(get("/api/v1/me").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.userId").value(userId))
|
||||
.andExpect(jsonPath("$.data.username").value("me_happy"))
|
||||
.andExpect(jsonPath("$.data.phone").value("+8613800000401"))
|
||||
.andExpect(jsonPath("$.data.createdAt").isNotEmpty())
|
||||
// Frozen contract: no other identity fields leak out.
|
||||
.andExpect(jsonPath("$.data.id").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.nickname").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void meWithoutTokenReturns40101() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/me"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void meWithExpiredTokenReturns40101() throws Exception {
|
||||
String userId = registerUser("me_expired", "+8613800000402");
|
||||
String token = TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(),
|
||||
UUID.fromString(userId), Duration.ofMinutes(-1));
|
||||
|
||||
mockMvc.perform(get("/api/v1/me").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void meWithForgedTokenReturns40101() throws Exception {
|
||||
String userId = registerUser("me_forged", "+8613800000403");
|
||||
// Signed with a key the service does not trust.
|
||||
String token = TestJwtKeys.accessToken(TestJwtKeys.WRONG_KEY_PAIR.getPrivate(),
|
||||
UUID.fromString(userId), Duration.ofMinutes(15));
|
||||
|
||||
mockMvc.perform(get("/api/v1/me").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void meWithGarbageTokenReturns40101() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/me").header("Authorization", "Bearer not.a.jwt"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
}
|
||||
+36
-22
@@ -2,6 +2,7 @@ package com.patbond.patbond.user.controller;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.user.TestcontainersConfiguration;
|
||||
import com.patbond.patbond.user.security.InternalAuthFilter;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
@@ -9,6 +10,7 @@ import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -21,19 +23,31 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
/**
|
||||
* MockMvc tests against the PostgreSQL-backed UserService (Testcontainers).
|
||||
* The database lives for the whole test context, so each test uses its own
|
||||
* username/phone to stay independent.
|
||||
* username/phone to stay independent. Every /internal/** call carries the
|
||||
* shared service token configured in the test application.yml; requests
|
||||
* without it are covered by InternalAuthFilterTest.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class UserControllerTest {
|
||||
|
||||
private static final String INTERNAL_TOKEN = "test-internal-token";
|
||||
|
||||
private static final String UUID_PATTERN =
|
||||
"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
private static MockHttpServletRequestBuilder internalPost(String path) {
|
||||
return post(path).header(InternalAuthFilter.HEADER, INTERNAL_TOKEN);
|
||||
}
|
||||
|
||||
private static MockHttpServletRequestBuilder internalGet(String path, Object... uriVariables) {
|
||||
return get(path, uriVariables).header(InternalAuthFilter.HEADER, INTERNAL_TOKEN);
|
||||
}
|
||||
|
||||
private static String createUserBody(String username) {
|
||||
return """
|
||||
{"username":"%s","password":"secret123","nickname":"Nick"}
|
||||
@@ -48,7 +62,7 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void createUserReturnsUuidProfileWithoutPassword() throws Exception {
|
||||
mockMvc.perform(post("/internal/users")
|
||||
mockMvc.perform(internalPost("/internal/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createUserBody("alice_create", "+8613800000101")))
|
||||
.andExpect(status().isOk())
|
||||
@@ -63,12 +77,12 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void createUserWithDuplicateUsernameReturnsConflict() throws Exception {
|
||||
mockMvc.perform(post("/internal/users")
|
||||
mockMvc.perform(internalPost("/internal/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createUserBody("bob_dup")))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(post("/internal/users")
|
||||
mockMvc.perform(internalPost("/internal/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createUserBody("bob_dup")))
|
||||
.andExpect(status().isConflict())
|
||||
@@ -77,13 +91,13 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void duplicateUsernameCheckIsCaseInsensitive() throws Exception {
|
||||
mockMvc.perform(post("/internal/users")
|
||||
mockMvc.perform(internalPost("/internal/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createUserBody("casey_case")))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
// identity.users.username is citext: uniqueness ignores case.
|
||||
mockMvc.perform(post("/internal/users")
|
||||
mockMvc.perform(internalPost("/internal/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createUserBody("CASEY_CASE")))
|
||||
.andExpect(status().isConflict())
|
||||
@@ -92,12 +106,12 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void createUserWithDuplicatePhoneReturnsConflict() throws Exception {
|
||||
mockMvc.perform(post("/internal/users")
|
||||
mockMvc.perform(internalPost("/internal/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createUserBody("pia_phone1", "+8613800000202")))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(post("/internal/users")
|
||||
mockMvc.perform(internalPost("/internal/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createUserBody("pia_phone2", "+8613800000202")))
|
||||
.andExpect(status().isConflict())
|
||||
@@ -106,7 +120,7 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void createUserWithInvalidPayloadReturnsBadRequest() throws Exception {
|
||||
mockMvc.perform(post("/internal/users")
|
||||
mockMvc.perform(internalPost("/internal/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"ab\",\"password\":\"123\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -117,7 +131,7 @@ class UserControllerTest {
|
||||
void createUserWithNonE164PhoneReturnsBadRequest() throws Exception {
|
||||
// 13800000000 passed the old length-only rule but violates the DB
|
||||
// ck_users_phone CHECK; the DTO now rejects it up front (issue B4).
|
||||
mockMvc.perform(post("/internal/users")
|
||||
mockMvc.perform(internalPost("/internal/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createUserBody("nina_badphone", "13800000000")))
|
||||
.andExpect(status().isBadRequest())
|
||||
@@ -126,12 +140,12 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void verifyPasswordSucceedsWithCorrectCredentials() throws Exception {
|
||||
mockMvc.perform(post("/internal/users")
|
||||
mockMvc.perform(internalPost("/internal/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createUserBody("carol_verify")))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(post("/internal/users/verify-password")
|
||||
mockMvc.perform(internalPost("/internal/users/verify-password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"carol_verify\",\"password\":\"secret123\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -142,12 +156,12 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void verifyPasswordWithWrongPasswordReturnsUnauthorized() throws Exception {
|
||||
mockMvc.perform(post("/internal/users")
|
||||
mockMvc.perform(internalPost("/internal/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createUserBody("dave_wrongpw")))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(post("/internal/users/verify-password")
|
||||
mockMvc.perform(internalPost("/internal/users/verify-password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"dave_wrongpw\",\"password\":\"wrong-password\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
@@ -156,7 +170,7 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void verifyPasswordForUnknownUserReturnsUnauthorized() throws Exception {
|
||||
mockMvc.perform(post("/internal/users/verify-password")
|
||||
mockMvc.perform(internalPost("/internal/users/verify-password")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"no_such_user\",\"password\":\"whatever1\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
@@ -165,14 +179,14 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void getByIdReturnsProfileForExistingUser() throws Exception {
|
||||
String body = mockMvc.perform(post("/internal/users")
|
||||
String body = mockMvc.perform(internalPost("/internal/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createUserBody("erin_getbyid")))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
String id = JsonPath.read(body, "$.data.id");
|
||||
|
||||
mockMvc.perform(get("/internal/users/{id}", id))
|
||||
mockMvc.perform(internalGet("/internal/users/{id}", id))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.id").value(id))
|
||||
.andExpect(jsonPath("$.data.username").value("erin_getbyid"));
|
||||
@@ -180,30 +194,30 @@ class UserControllerTest {
|
||||
|
||||
@Test
|
||||
void getByIdForUnknownUserReturnsNotFound() throws Exception {
|
||||
mockMvc.perform(get("/internal/users/{id}", UUID.randomUUID()))
|
||||
mockMvc.perform(internalGet("/internal/users/{id}", UUID.randomUUID()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40400));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByIdWithMalformedUuidReturnsBadRequest() throws Exception {
|
||||
mockMvc.perform(get("/internal/users/{id}", "not-a-uuid"))
|
||||
mockMvc.perform(internalGet("/internal/users/{id}", "not-a-uuid"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getByUsernameReturnsProfileAndNotFoundForUnknown() throws Exception {
|
||||
mockMvc.perform(post("/internal/users")
|
||||
mockMvc.perform(internalPost("/internal/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createUserBody("frank_byname")))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(get("/internal/users/by-username/{username}", "frank_byname"))
|
||||
mockMvc.perform(internalGet("/internal/users/by-username/{username}", "frank_byname"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.username").value("frank_byname"));
|
||||
|
||||
mockMvc.perform(get("/internal/users/by-username/{username}", "ghost_user"))
|
||||
mockMvc.perform(internalGet("/internal/users/by-username/{username}", "ghost_user"))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code").value(40400));
|
||||
}
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.patbond.patbond.user.security;
|
||||
|
||||
import com.patbond.patbond.user.TestcontainersConfiguration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* /internal/** requires the shared service secret (development-plan 6):
|
||||
* requests without a credential — or with a wrong one — answer 401 before
|
||||
* any controller code runs.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class InternalAuthFilterTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
private static final String VALID_BODY = """
|
||||
{"username":"filter_probe","password":"secret123"}
|
||||
""";
|
||||
|
||||
@Test
|
||||
void internalCallWithoutTokenIsRejectedWith401() throws Exception {
|
||||
mockMvc.perform(post("/internal/users")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(VALID_BODY))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void internalCallWithWrongTokenIsRejectedWith401() throws Exception {
|
||||
mockMvc.perform(post("/internal/users")
|
||||
.header(InternalAuthFilter.HEADER, "not-the-configured-secret")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(VALID_BODY))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sessionEndpointsAreGuardedToo() throws Exception {
|
||||
mockMvc.perform(post("/internal/sessions/refresh")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"refreshToken\":\"whatever\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.patbond.patbond.user.service;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.user.TestcontainersConfiguration;
|
||||
import com.patbond.patbond.user.security.InternalAuthFilter;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Database-backed login-failure lockout (policy documented in openapi.yaml):
|
||||
* maxFailures wrong passwords inside the failure window lock the account for
|
||||
* lockDuration; while locked even the correct password answers 423/42300; a
|
||||
* successful login resets the window. Threshold lowered to 3 here to keep
|
||||
* the tests fast.
|
||||
*/
|
||||
@SpringBootTest(properties = "patbond.login-lock.max-failures=3")
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class LoginLockoutIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcClient jdbcClient;
|
||||
|
||||
private String register(String username) throws Exception {
|
||||
String body = mockMvc.perform(internalPost("/internal/users")
|
||||
.content("{\"username\":\"%s\",\"password\":\"secret123\"}".formatted(username)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
return JsonPath.read(body, "$.data.id");
|
||||
}
|
||||
|
||||
private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder internalPost(String path) {
|
||||
return post(path)
|
||||
.header(InternalAuthFilter.HEADER, "test-internal-token")
|
||||
.contentType(MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
private org.springframework.test.web.servlet.ResultActions verify(String username, String password)
|
||||
throws Exception {
|
||||
return mockMvc.perform(internalPost("/internal/users/verify-password")
|
||||
.content("{\"username\":\"%s\",\"password\":\"%s\"}".formatted(username, password)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void accountLocksAfterMaxFailuresEvenForTheCorrectPassword() throws Exception {
|
||||
register("lock_basic");
|
||||
for (int i = 0; i < 3; i++) {
|
||||
verify("lock_basic", "wrong-password")
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40100));
|
||||
}
|
||||
verify("lock_basic", "secret123")
|
||||
.andExpect(status().is(423))
|
||||
.andExpect(jsonPath("$.code").value(42300));
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulLoginResetsTheFailureWindow() throws Exception {
|
||||
register("lock_reset");
|
||||
verify("lock_reset", "wrong-password").andExpect(status().isUnauthorized());
|
||||
verify("lock_reset", "wrong-password").andExpect(status().isUnauthorized());
|
||||
verify("lock_reset", "secret123").andExpect(status().isOk());
|
||||
|
||||
// Without the reset, these two would be failures 3 and 4 → locked.
|
||||
verify("lock_reset", "wrong-password").andExpect(status().isUnauthorized());
|
||||
verify("lock_reset", "wrong-password").andExpect(status().isUnauthorized());
|
||||
verify("lock_reset", "secret123").andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockExpiryAllowsLoggingInAgain() throws Exception {
|
||||
String userId = register("lock_expiry");
|
||||
for (int i = 0; i < 3; i++) {
|
||||
verify("lock_expiry", "wrong-password").andExpect(status().isUnauthorized());
|
||||
}
|
||||
verify("lock_expiry", "secret123").andExpect(status().is(423));
|
||||
|
||||
// Simulate the lock lapsing instead of sleeping 15 minutes.
|
||||
jdbcClient.sql("""
|
||||
UPDATE identity.user_credentials
|
||||
SET locked_until = now() - interval '1 second',
|
||||
failure_window_started_at = now() - interval '1 hour'
|
||||
WHERE user_id = :userId
|
||||
""")
|
||||
.param("userId", UUID.fromString(userId))
|
||||
.update();
|
||||
|
||||
verify("lock_expiry", "secret123")
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package com.patbond.patbond.user.session;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.user.TestcontainersConfiguration;
|
||||
import com.patbond.patbond.user.security.InternalAuthFilter;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Integration tests of the refresh-session lifecycle (ADR-003) against a
|
||||
* real postgres:18: hashed storage, rotation chaining, family revocation on
|
||||
* reuse, logout scope, expiry.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class SessionLifecycleIntegrationTest {
|
||||
|
||||
private static final String INTERNAL_TOKEN = "test-internal-token";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcClient jdbcClient;
|
||||
|
||||
private MockHttpServletRequestBuilder internalPost(String path) {
|
||||
return post(path)
|
||||
.header(InternalAuthFilter.HEADER, INTERNAL_TOKEN)
|
||||
.contentType(MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
private String registerUser(String username) throws Exception {
|
||||
String body = mockMvc.perform(internalPost("/internal/users")
|
||||
.content("{\"username\":\"%s\",\"password\":\"secret123\"}".formatted(username)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
return JsonPath.read(body, "$.data.id");
|
||||
}
|
||||
|
||||
private Map<String, Object> createSession(String userId) throws Exception {
|
||||
String body = mockMvc.perform(internalPost("/internal/sessions")
|
||||
.content("{\"userId\":\"%s\",\"userAgent\":\"junit\",\"ipAddress\":\"127.0.0.1\"}"
|
||||
.formatted(userId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
return JsonPath.read(body, "$.data");
|
||||
}
|
||||
|
||||
private String refresh(String refreshToken) throws Exception {
|
||||
return mockMvc.perform(internalPost("/internal/sessions/refresh")
|
||||
.content("{\"refreshToken\":\"%s\"}".formatted(refreshToken)))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createSessionStoresSha256DigestNotPlaintext() throws Exception {
|
||||
String userId = registerUser("sess_digest");
|
||||
Map<String, Object> session = createSession(userId);
|
||||
String refreshToken = (String) session.get("refreshToken");
|
||||
assertThat(refreshToken).isNotBlank();
|
||||
assertThat((String) session.get("jti")).isNotBlank();
|
||||
assertThat((String) session.get("refreshTokenExpiresAt")).contains("T");
|
||||
|
||||
byte[] storedHash = jdbcClient.sql("""
|
||||
SELECT refresh_token_hash FROM identity.auth_sessions WHERE id = :id
|
||||
""")
|
||||
.param("id", UUID.fromString((String) session.get("sessionId")))
|
||||
.query(byte[].class)
|
||||
.single();
|
||||
byte[] expected = MessageDigest.getInstance("SHA-256")
|
||||
.digest(refreshToken.getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(storedHash).isEqualTo(expected).hasSize(32);
|
||||
// The plaintext token appears nowhere in the row.
|
||||
assertThat(new String(storedHash, StandardCharsets.ISO_8859_1)).isNotEqualTo(refreshToken);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshRotatesTokenAndChainsSessions() throws Exception {
|
||||
String userId = registerUser("sess_rotate");
|
||||
Map<String, Object> first = createSession(userId);
|
||||
|
||||
String body = refresh((String) first.get("refreshToken"));
|
||||
assertThat((int) JsonPath.read(body, "$.code")).isZero();
|
||||
String newToken = JsonPath.read(body, "$.data.refreshToken");
|
||||
String newSessionId = JsonPath.read(body, "$.data.sessionId");
|
||||
assertThat(newToken).isNotEqualTo(first.get("refreshToken"));
|
||||
assertThat((String) JsonPath.read(body, "$.data.userId")).isEqualTo(userId);
|
||||
|
||||
Map<String, Object> oldRow = jdbcClient.sql("""
|
||||
SELECT revoked_at, rotated_at, revoke_reason,
|
||||
replaced_by_session_id::text AS replaced_by,
|
||||
token_family_id::text AS family
|
||||
FROM identity.auth_sessions WHERE id = :id
|
||||
""")
|
||||
.param("id", UUID.fromString((String) first.get("sessionId")))
|
||||
.query()
|
||||
.singleRow();
|
||||
assertThat(oldRow.get("revoked_at")).isNotNull();
|
||||
assertThat(oldRow.get("rotated_at")).isNotNull();
|
||||
assertThat(oldRow.get("revoke_reason")).isEqualTo("rotated");
|
||||
assertThat(oldRow.get("replaced_by")).isEqualTo(newSessionId);
|
||||
|
||||
String newFamily = jdbcClient.sql(
|
||||
"SELECT token_family_id::text FROM identity.auth_sessions WHERE id = :id")
|
||||
.param("id", UUID.fromString(newSessionId))
|
||||
.query(String.class)
|
||||
.single();
|
||||
assertThat(newFamily).isEqualTo(oldRow.get("family"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reuseOfRotatedTokenRevokesWholeFamily() throws Exception {
|
||||
String userId = registerUser("sess_reuse");
|
||||
Map<String, Object> first = createSession(userId);
|
||||
String rotatedAway = (String) first.get("refreshToken");
|
||||
|
||||
String current = JsonPath.read(refresh(rotatedAway), "$.data.refreshToken");
|
||||
|
||||
// Replay of the rotated token: rejected and the family is killed.
|
||||
String reuse = refresh(rotatedAway);
|
||||
assertThat((int) JsonPath.read(reuse, "$.code")).isEqualTo(40102);
|
||||
|
||||
// The (previously valid) current token died with the family.
|
||||
String afterKill = refresh(current);
|
||||
assertThat((int) JsonPath.read(afterKill, "$.code")).isEqualTo(40102);
|
||||
|
||||
Integer live = jdbcClient.sql("""
|
||||
SELECT count(*) FROM identity.auth_sessions
|
||||
WHERE user_id = :userId AND revoked_at IS NULL
|
||||
""")
|
||||
.param("userId", UUID.fromString(userId))
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
assertThat(live).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutRevokesOnlyTheCurrentSession() throws Exception {
|
||||
String userId = registerUser("sess_logout");
|
||||
Map<String, Object> phone = createSession(userId);
|
||||
Map<String, Object> tablet = createSession(userId);
|
||||
|
||||
mockMvc.perform(internalPost("/internal/sessions/revoke")
|
||||
.content("{\"userId\":\"%s\",\"refreshToken\":\"%s\"}"
|
||||
.formatted(userId, phone.get("refreshToken"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
// The logged-out session's token is dead …
|
||||
assertThat((int) JsonPath.read(refresh((String) phone.get("refreshToken")), "$.code"))
|
||||
.isEqualTo(40102);
|
||||
// … while the other device keeps working (ADR-003 multi-device).
|
||||
assertThat((int) JsonPath.read(refresh((String) tablet.get("refreshToken")), "$.code"))
|
||||
.isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutWithForeignUserIdDoesNotRevokeTheSession() throws Exception {
|
||||
String owner = registerUser("sess_owner");
|
||||
String attacker = registerUser("sess_attacker");
|
||||
Map<String, Object> session = createSession(owner);
|
||||
|
||||
mockMvc.perform(internalPost("/internal/sessions/revoke")
|
||||
.content("{\"userId\":\"%s\",\"refreshToken\":\"%s\"}"
|
||||
.formatted(attacker, session.get("refreshToken"))))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertThat((int) JsonPath.read(refresh((String) session.get("refreshToken")), "$.code"))
|
||||
.isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredRefreshTokenIsRejected() throws Exception {
|
||||
String userId = registerUser("sess_expired");
|
||||
Map<String, Object> session = createSession(userId);
|
||||
jdbcClient.sql("""
|
||||
UPDATE identity.auth_sessions
|
||||
SET expires_at = created_at + interval '1 millisecond' WHERE id = :id
|
||||
""")
|
||||
.param("id", UUID.fromString((String) session.get("sessionId")))
|
||||
.update();
|
||||
|
||||
assertThat((int) JsonPath.read(refresh((String) session.get("refreshToken")), "$.code"))
|
||||
.isEqualTo(40102);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownRefreshTokenIsRejected() throws Exception {
|
||||
String body = refresh("bm90LWEtcmVhbC10b2tlbi1hdC1hbGwtanVzdC1iYXNlNjQ");
|
||||
assertThat((int) JsonPath.read(body, "$.code")).isEqualTo(40102);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.patbond.patbond.user.support;
|
||||
|
||||
import io.jsonwebtoken.Jwts;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Runtime-generated RSA material for JWT tests. Nothing here is committed
|
||||
* key material (git-workflow: no credentials in the repository) — every test
|
||||
* run mints a fresh pair and injects the public key via
|
||||
* {@code @DynamicPropertySource}.
|
||||
*/
|
||||
public final class TestJwtKeys {
|
||||
|
||||
public static final KeyPair KEY_PAIR = generate();
|
||||
/** A second pair, for signing tokens the service must reject. */
|
||||
public static final KeyPair WRONG_KEY_PAIR = generate();
|
||||
|
||||
private TestJwtKeys() {
|
||||
}
|
||||
|
||||
public static String publicPem() {
|
||||
return "-----BEGIN PUBLIC KEY-----\n"
|
||||
+ Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded())
|
||||
+ "\n-----END PUBLIC KEY-----";
|
||||
}
|
||||
|
||||
/** Signs an access token the way patbond-auth does (sub/jti/sid/iat/exp). */
|
||||
public static String accessToken(PrivateKey key, UUID userId, Duration ttl) {
|
||||
Instant now = Instant.now();
|
||||
return Jwts.builder()
|
||||
.id(UUID.randomUUID().toString())
|
||||
.subject(userId.toString())
|
||||
.issuer("patbond-auth")
|
||||
.claim("sid", UUID.randomUUID().toString())
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plus(ttl)))
|
||||
.signWith(key, Jwts.SIG.RS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
private static KeyPair generate() {
|
||||
try {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
return generator.generateKeyPair();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Test-only configuration: keeps @SpringBootTest deterministic on a clean
|
||||
# checkout, where the git-ignored main application.yml does not exist. The
|
||||
# datasource comes from Testcontainers (@ServiceConnection); the JWT public
|
||||
# key, when a test needs one, is generated at runtime and injected through
|
||||
# @DynamicPropertySource — no key material is committed.
|
||||
spring:
|
||||
application:
|
||||
name: patbond-user
|
||||
|
||||
patbond:
|
||||
internal-token: test-internal-token
|
||||
Reference in New Issue
Block a user