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:
2026-09-04 12:15:40 +08:00
parent 43ab6c5827
commit 4dc3dcdfa3
51 changed files with 2768 additions and 151 deletions
@@ -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));
}
}
@@ -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));
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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