Refresh tokens are 256-bit random values; only their SHA-256 digest is
+ * persisted. Neither the plaintext token nor its digest is ever logged.
+ */
+@Service
+public class SessionService {
+
+ private static final Logger log = LoggerFactory.getLogger(SessionService.class);
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ private final SessionRepository sessionRepository;
+ private final UserSecurityProperties properties;
+ private final TransactionTemplate transactionTemplate;
+
+ public SessionService(SessionRepository sessionRepository, UserSecurityProperties properties,
+ TransactionTemplate transactionTemplate) {
+ this.sessionRepository = sessionRepository;
+ this.properties = properties;
+ this.transactionTemplate = transactionTemplate;
+ }
+
+ /** Opens a new session (= new token family) for a freshly authenticated user. */
+ public SessionTokens create(CreateSessionRequest request) {
+ return insertSession(request.getUserId(), UuidV7.generate(),
+ request.getUserAgent(), request.getIpAddress());
+ }
+
+ /**
+ * Rotates a refresh token. The old session is closed and chained to its
+ * replacement; presenting a token that was already rotated or revoked is
+ * treated as reuse and kills every live session of the family (40102).
+ */
+ public SessionTokens refresh(String refreshToken) {
+ byte[] hash = sha256(refreshToken);
+ SessionRepository.SessionRow session = sessionRepository.findByTokenHash(hash)
+ .orElseThrow(() -> new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID));
+
+ if (session.revokedAt() != null) {
+ // A rotated (or logged-out) token came back: someone other than the
+ // rightful holder may have it. Revoke the whole family (ADR-003).
+ int revoked = sessionRepository.revokeFamily(session.tokenFamilyId(), "reuse_detected");
+ log.warn("Refresh token reuse detected: family={} of user={} revoked ({} live sessions)",
+ session.tokenFamilyId(), session.userId(), revoked);
+ throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
+ }
+ if (!session.expiresAt().isAfter(OffsetDateTime.now(ZoneOffset.UTC))) {
+ throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
+ }
+
+ SessionTokens rotated = transactionTemplate.execute(status -> {
+ SessionTokens tokens = insertSession(session.userId(), session.tokenFamilyId(), null, null);
+ if (sessionRepository.markRotated(session.id(), tokens.getSessionId()) != 1) {
+ status.setRollbackOnly();
+ return null;
+ }
+ return tokens;
+ });
+ if (rotated == null) {
+ // Lost a race against a concurrent rotation of the same token —
+ // by definition the token was presented twice: treat as reuse.
+ int revoked = sessionRepository.revokeFamily(session.tokenFamilyId(), "reuse_detected");
+ log.warn("Concurrent refresh detected: family={} of user={} revoked ({} live sessions)",
+ session.tokenFamilyId(), session.userId(), revoked);
+ throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
+ }
+ return rotated;
+ }
+
+ /** Logout: revokes the session holding this token; idempotent by design. */
+ public void revoke(UUID userId, String refreshToken) {
+ sessionRepository.revokeByTokenHashAndUser(sha256(refreshToken), userId, "logout");
+ }
+
+ private SessionTokens insertSession(UUID userId, UUID familyId, String userAgent, String ipAddress) {
+ UUID sessionId = UuidV7.generate();
+ String jti = UuidV7.generate().toString();
+ byte[] tokenBytes = new byte[32];
+ RANDOM.nextBytes(tokenBytes);
+ String refreshToken = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
+ OffsetDateTime expiresAt = OffsetDateTime.now(ZoneOffset.UTC)
+ .plus(properties.getSession().getRefreshTtl());
+
+ sessionRepository.insert(sessionId, userId, familyId, sha256(refreshToken), jti,
+ expiresAt, userAgent, ipAddress);
+ return new SessionTokens(sessionId, userId, jti, refreshToken, expiresAt);
+ }
+
+ private static byte[] sha256(String token) {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(token.getBytes(StandardCharsets.UTF_8));
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 unavailable", e);
+ }
+ }
+}
diff --git a/patbond-user/src/main/resources/application.yml.sample b/patbond-user/src/main/resources/application.yml.sample
index 19d0f7f..fb2fe9c 100644
--- a/patbond-user/src/main/resources/application.yml.sample
+++ b/patbond-user/src/main/resources/application.yml.sample
@@ -11,6 +11,27 @@ spring:
flyway:
locations: classpath:db/migration
+patbond:
+ # /internal/** 服务间共享密钥,需与 patbond-auth 配置同一值;生产环境必须
+ # 通过 PATBOND_INTERNAL_TOKEN 注入强随机值(如 `openssl rand -hex 32`)。
+ internal-token: ${PATBOND_INTERNAL_TOKEN:dev-only-internal-token}
+ jwt:
+ # RS256 公钥,用于本地校验 patbond-auth 签发的 access token。
+ # 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
+ # 密钥对生成(私钥只给 patbond-auth,绝不入库):
+ # openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt-private.pem
+ # openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
+ # 然后:export PATBOND_JWT_PUBLIC_KEY=/path/to/jwt-public.pem
+ public-key: ${PATBOND_JWT_PUBLIC_KEY:}
+ session:
+ # ADR-003:refresh token 30 天,刷新即轮换;值可配置。
+ refresh-ttl: ${PATBOND_REFRESH_TTL:30d}
+ login-lock:
+ # 登录失败限制:窗口内连续失败达到阈值后锁定账号(返回 423/42300)。
+ max-failures: ${PATBOND_LOGIN_LOCK_MAX_FAILURES:5}
+ failure-window: ${PATBOND_LOGIN_LOCK_WINDOW:15m}
+ lock-duration: ${PATBOND_LOGIN_LOCK_DURATION:15m}
+
# Development seed data (regions reference rows) is opt-in. To load it,
# activate a dev profile that widens the Flyway locations:
#
diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeEndpointTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeEndpointTest.java
new file mode 100644
index 0000000..0213bde
--- /dev/null
+++ b/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeEndpointTest.java
@@ -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));
+ }
+}
diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/controller/UserControllerTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/controller/UserControllerTest.java
index 045aab9..f639797 100644
--- a/patbond-user/src/test/java/com/patbond/patbond/user/controller/UserControllerTest.java
+++ b/patbond-user/src/test/java/com/patbond/patbond/user/controller/UserControllerTest.java
@@ -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));
}
diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/security/InternalAuthFilterTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/security/InternalAuthFilterTest.java
new file mode 100644
index 0000000..020e4d5
--- /dev/null
+++ b/patbond-user/src/test/java/com/patbond/patbond/user/security/InternalAuthFilterTest.java
@@ -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));
+ }
+}
diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/service/LoginLockoutIntegrationTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/service/LoginLockoutIntegrationTest.java
new file mode 100644
index 0000000..504eadd
--- /dev/null
+++ b/patbond-user/src/test/java/com/patbond/patbond/user/service/LoginLockoutIntegrationTest.java
@@ -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));
+ }
+}
diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/session/SessionLifecycleIntegrationTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/session/SessionLifecycleIntegrationTest.java
new file mode 100644
index 0000000..e4583f5
--- /dev/null
+++ b/patbond-user/src/test/java/com/patbond/patbond/user/session/SessionLifecycleIntegrationTest.java
@@ -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