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,45 @@
package com.patbond.patbond.user.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.patbond.patbond.user.security.BearerAuthFilter;
import com.patbond.patbond.user.security.InternalAuthFilter;
import com.patbond.patbond.user.security.JwtVerifier;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Wires the two servlet filters guarding this service without pulling in
* spring-security: /internal/** requires the shared service secret,
* /api/v1/** requires a valid RS256 access token.
*/
@Configuration
@EnableConfigurationProperties(UserSecurityProperties.class)
public class SecurityConfig {
@Bean
public JwtVerifier jwtVerifier(UserSecurityProperties properties) {
return new JwtVerifier(properties.getJwt().getPublicKey());
}
@Bean
public FilterRegistrationBean<InternalAuthFilter> internalAuthFilter(
UserSecurityProperties properties, ObjectMapper objectMapper) {
FilterRegistrationBean<InternalAuthFilter> registration = new FilterRegistrationBean<>(
new InternalAuthFilter(properties.getInternalToken(), objectMapper));
registration.addUrlPatterns("/internal/*");
registration.setOrder(10);
return registration;
}
@Bean
public FilterRegistrationBean<BearerAuthFilter> bearerAuthFilter(
JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
FilterRegistrationBean<BearerAuthFilter> registration = new FilterRegistrationBean<>(
new BearerAuthFilter(jwtVerifier, objectMapper));
registration.addUrlPatterns("/api/v1/*");
registration.setOrder(20);
return registration;
}
}
@@ -0,0 +1,111 @@
package com.patbond.patbond.user.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Security-related knobs of the user service. ADR-003 mandates that the
* refresh TTL and the login-failure limits are configuration, not constants;
* the committed defaults are the ADR values (refresh 30 days) and the login
* lockout policy documented in openapi.yaml (5 failures / 15 min window /
* 15 min lock).
*/
@ConfigurationProperties(prefix = "patbond")
public class UserSecurityProperties {
/** Shared secret expected in X-Internal-Token on every /internal/** call. */
private String internalToken;
private final Jwt jwt = new Jwt();
private final Session session = new Session();
private final LoginLock loginLock = new LoginLock();
public String getInternalToken() {
return internalToken;
}
public void setInternalToken(String internalToken) {
this.internalToken = internalToken;
}
public Jwt getJwt() {
return jwt;
}
public Session getSession() {
return session;
}
public LoginLock getLoginLock() {
return loginLock;
}
public static class Jwt {
/**
* RS256 public key for verifying access tokens signed by
* patbond-auth: either inline PEM (starts with -----BEGIN) or a
* filesystem path. The private key never reaches this service.
*/
private String publicKey;
public String getPublicKey() {
return publicKey;
}
public void setPublicKey(String publicKey) {
this.publicKey = publicKey;
}
}
public static class Session {
/** Refresh token lifetime (ADR-003: 30 days). */
private Duration refreshTtl = Duration.ofDays(30);
public Duration getRefreshTtl() {
return refreshTtl;
}
public void setRefreshTtl(Duration refreshTtl) {
this.refreshTtl = refreshTtl;
}
}
public static class LoginLock {
/** Failures within the window that trigger a lock. */
private int maxFailures = 5;
/** Sliding window in which failures accumulate. */
private Duration failureWindow = Duration.ofMinutes(15);
/** How long the account stays locked once triggered. */
private Duration lockDuration = Duration.ofMinutes(15);
public int getMaxFailures() {
return maxFailures;
}
public void setMaxFailures(int maxFailures) {
this.maxFailures = maxFailures;
}
public Duration getFailureWindow() {
return failureWindow;
}
public void setFailureWindow(Duration failureWindow) {
this.failureWindow = failureWindow;
}
public Duration getLockDuration() {
return lockDuration;
}
public void setLockDuration(Duration lockDuration) {
this.lockDuration = lockDuration;
}
}
}
@@ -0,0 +1,34 @@
package com.patbond.patbond.user.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.common.user.UserProfile;
import com.patbond.patbond.user.dto.MeResponse;
import com.patbond.patbond.user.security.BearerAuthFilter;
import com.patbond.patbond.user.service.UserService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* Public profile endpoint. Authentication happens in BearerAuthFilter (RS256
* verification against the auth service's public key); by the time this
* controller runs, the user id attribute is guaranteed to be present.
*/
@RestController
public class MeController {
private final UserService userService;
public MeController(UserService userService) {
this.userService = userService;
}
@GetMapping("/api/v1/me")
public ApiResponse<MeResponse> me(@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
UserProfile profile = userService.getById(userId);
return ApiResponse.success(new MeResponse(
profile.getId(), profile.getUsername(), profile.getPhone(), profile.getCreatedAt()));
}
}
@@ -0,0 +1,11 @@
package com.patbond.patbond.user.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* Public /api/v1/me payload — exactly the frozen contract fields
* {userId, username, phone, createdAt}; nothing else leaks out.
*/
public record MeResponse(UUID userId, String username, String phone, OffsetDateTime createdAt) {
}
@@ -32,7 +32,8 @@ public class UserRepository {
public record UserRow(UUID id, String username, String nickname, String phone, OffsetDateTime createdAt) {
}
public record AuthRow(UUID id, String username, String nickname, String passwordHash) {
public record AuthRow(UUID id, String username, String nickname, String passwordHash,
OffsetDateTime lockedUntil) {
}
/** Inserts the user row; created_at/updated_at come from the DB defaults. */
@@ -87,7 +88,7 @@ public class UserRepository {
public Optional<AuthRow> findAuthByUsername(String username) {
return jdbcClient.sql("""
SELECT u.id, u.username::text AS username, u.nickname, c.password_hash
SELECT u.id, u.username::text AS username, u.nickname, c.password_hash, c.locked_until
FROM identity.users u
JOIN identity.user_credentials c ON c.user_id = u.id
WHERE u.deleted_at IS NULL AND u.username = :username
@@ -97,7 +98,58 @@ public class UserRepository {
rs.getObject("id", UUID.class),
rs.getString("username"),
rs.getString("nickname"),
rs.getString("password_hash")))
rs.getString("password_hash"),
rs.getObject("locked_until", OffsetDateTime.class)))
.optional();
}
/**
* Registers one failed login attempt in a single atomic UPDATE: the
* counter restarts when the failure window has lapsed, and locked_until
* is set the moment the counter reaches the configured maximum. All CASE
* expressions read the pre-update column values, so concurrent failures
* cannot double-count or skip the lock.
*/
public void recordLoginFailure(UUID userId, long windowSeconds, int maxFailures, long lockSeconds) {
jdbcClient.sql("""
UPDATE identity.user_credentials SET
failed_login_count = CASE
WHEN failure_window_started_at IS NULL
OR failure_window_started_at < now() - make_interval(secs => :windowSeconds)
THEN 1 ELSE failed_login_count + 1 END,
failure_window_started_at = CASE
WHEN failure_window_started_at IS NULL
OR failure_window_started_at < now() - make_interval(secs => :windowSeconds)
THEN now() ELSE failure_window_started_at END,
last_failed_at = now(),
locked_until = CASE
WHEN (CASE
WHEN failure_window_started_at IS NULL
OR failure_window_started_at < now() - make_interval(secs => :windowSeconds)
THEN 1 ELSE failed_login_count + 1 END) >= :maxFailures
THEN now() + make_interval(secs => :lockSeconds)
ELSE locked_until END
WHERE user_id = :userId
""")
.param("userId", userId)
.param("windowSeconds", windowSeconds)
.param("maxFailures", maxFailures)
.param("lockSeconds", lockSeconds)
.update();
}
/** Successful login: clears the failure window and stamps last_login_at. */
public void recordLoginSuccess(UUID userId) {
jdbcClient.sql("""
UPDATE identity.user_credentials
SET failed_login_count = 0, failure_window_started_at = NULL, locked_until = NULL
WHERE user_id = :userId
AND (failed_login_count > 0 OR locked_until IS NOT NULL)
""")
.param("userId", userId)
.update();
jdbcClient.sql("UPDATE identity.users SET last_login_at = now() WHERE id = :userId")
.param("userId", userId)
.update();
}
}
@@ -0,0 +1,77 @@
package com.patbond.patbond.user.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.response.ApiResponse;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
/**
* Bearer authentication for the public /api/v1/** routes of this service
* (currently GET /api/v1/me). Verifies the RS256 signature locally with the
* auth service's public key — no network hop per request — and exposes the
* authenticated user id as a request attribute. Missing, forged or expired
* tokens all answer 401/40101 without detail (log-redaction rule: the token
* itself is never logged).
*/
public class BearerAuthFilter extends OncePerRequestFilter {
/** Request attribute holding the authenticated user's UUID. */
public static final String USER_ID_ATTRIBUTE = "patbond.authenticatedUserId";
private static final Logger log = LoggerFactory.getLogger(BearerAuthFilter.class);
private final JwtVerifier jwtVerifier;
private final ObjectMapper objectMapper;
public BearerAuthFilter(JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
this.jwtVerifier = jwtVerifier;
this.objectMapper = objectMapper;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/api/v1/");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
reject(response, ErrorCode.TOKEN_INVALID);
return;
}
try {
Claims claims = jwtVerifier.verify(header.substring("Bearer ".length()).trim());
request.setAttribute(USER_ID_ATTRIBUTE, UUID.fromString(claims.getSubject()));
} catch (BusinessException e) {
reject(response, ErrorCode.TOKEN_INVALID);
return;
} catch (IllegalStateException | IllegalArgumentException e) {
log.error("Access token verification unavailable: {}", e.getMessage());
reject(response, ErrorCode.INTERNAL_ERROR);
return;
}
filterChain.doFilter(request, response);
}
private void reject(HttpServletResponse response, ErrorCode errorCode) throws IOException {
response.setStatus(errorCode.getHttpStatus());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(),
ApiResponse.failure(errorCode.getCode(), errorCode.getDefaultMessage()));
}
}
@@ -0,0 +1,70 @@
package com.patbond.patbond.user.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.response.ApiResponse;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/**
* Service-to-service authentication for /internal/** (development-plan 6:
* internal routes must carry inter-service auth). The caller presents the
* shared secret in X-Internal-Token; it is injected via environment variable
* on both sides and never logged. Fails closed: with no secret configured
* every internal call is rejected.
*/
public class InternalAuthFilter extends OncePerRequestFilter {
public static final String HEADER = "X-Internal-Token";
private static final Logger log = LoggerFactory.getLogger(InternalAuthFilter.class);
private final String expectedToken;
private final ObjectMapper objectMapper;
public InternalAuthFilter(String expectedToken, ObjectMapper objectMapper) {
this.expectedToken = expectedToken;
this.objectMapper = objectMapper;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/internal/");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (expectedToken == null || expectedToken.isBlank()) {
log.error("patbond.internal-token 未配置,/internal/** 请求全部拒绝");
reject(response);
return;
}
String presented = request.getHeader(HEADER);
if (presented == null || !MessageDigest.isEqual(
presented.getBytes(StandardCharsets.UTF_8),
expectedToken.getBytes(StandardCharsets.UTF_8))) {
reject(response);
return;
}
filterChain.doFilter(request, response);
}
private void reject(HttpServletResponse response) throws IOException {
response.setStatus(401);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(),
ApiResponse.failure(ErrorCode.TOKEN_INVALID.getCode(), "服务间凭证缺失或无效"));
}
}
@@ -0,0 +1,41 @@
package com.patbond.patbond.user.security;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
/**
* Verifies RS256 access tokens issued by patbond-auth against the configured
* public key. The key is optional at startup so contexts that never serve
* protected routes (most tests) can boot without one; any verification
* attempt without a key fails loudly as a server misconfiguration instead of
* being reported to the client as an authentication problem.
*/
public class JwtVerifier {
private final JwtParser parser;
public JwtVerifier(String publicKeyLocation) {
this.parser = publicKeyLocation == null || publicKeyLocation.isBlank()
? null
: Jwts.parser().verifyWith(RsaPublicKeyLoader.load(publicKeyLocation)).build();
}
/**
* @return the verified claims
* @throws BusinessException 40101 when the token is forged, malformed or expired
*/
public Claims verify(String token) {
if (parser == null) {
throw new IllegalStateException("patbond.jwt.public-key 未配置,无法校验 access token");
}
try {
return parser.parseSignedClaims(token).getPayload();
} catch (JwtException | IllegalArgumentException e) {
throw new BusinessException(ErrorCode.TOKEN_INVALID);
}
}
}
@@ -0,0 +1,46 @@
package com.patbond.patbond.user.security;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
/**
* Loads an RSA public key from either inline PEM content (value starts with
* -----BEGIN, e.g. injected through an environment variable) or a filesystem
* path to a PEM file. Only the X.509 SubjectPublicKeyInfo form produced by
* `openssl pkey -pubout` is supported.
*/
public final class RsaPublicKeyLoader {
private RsaPublicKeyLoader() {
}
public static RSAPublicKey load(String pemOrPath) {
String pem = pemOrPath.trim();
if (!pem.startsWith("-----BEGIN")) {
try {
pem = Files.readString(Path.of(pem));
} catch (IOException e) {
throw new UncheckedIOException("无法读取 JWT 公钥文件: " + pemOrPath, e);
}
}
String base64 = pem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
try {
byte[] der = Base64.getDecoder().decode(base64);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return (RSAPublicKey) keyFactory.generatePublic(new X509EncodedKeySpec(der));
} catch (IllegalArgumentException | NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new IllegalStateException("JWT 公钥不是有效的 PEM(X.509/SubjectPublicKeyInfo) RSA 公钥", e);
}
}
}
@@ -6,6 +6,7 @@ import com.patbond.patbond.common.user.CreateUserRequest;
import com.patbond.patbond.common.user.UserProfile;
import com.patbond.patbond.common.user.VerifyPasswordRequest;
import com.patbond.patbond.common.user.VerifyPasswordResponse;
import com.patbond.patbond.user.config.UserSecurityProperties;
import com.patbond.patbond.user.repository.UserRepository;
import com.patbond.patbond.user.support.UuidV7;
import org.springframework.dao.DuplicateKeyException;
@@ -15,12 +16,14 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.UUID;
@Service
public class UserService {
private final UserRepository userRepository;
private final UserSecurityProperties properties;
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
/**
@@ -29,8 +32,9 @@ public class UserService {
*/
private final String unknownUserHash = passwordEncoder.encode(UUID.randomUUID().toString());
public UserService(UserRepository userRepository) {
public UserService(UserRepository userRepository, UserSecurityProperties properties) {
this.userRepository = userRepository;
this.properties = properties;
}
@Transactional
@@ -50,13 +54,31 @@ public class UserService {
return new UserProfile(id, username, nickname, phone, createdAt);
}
/**
* Password check with a database-backed lockout window (frozen policy,
* see openapi.yaml): repeated failures within the failure window lock the
* account for the configured duration and answer 423/42300, even for the
* correct password, until the lock lapses. Counters live on
* identity.user_credentials, so they survive restarts and are shared by
* every instance. A successful login resets the window.
*/
public VerifyPasswordResponse verifyPassword(VerifyPasswordRequest request) {
UserRepository.AuthRow auth = userRepository.findAuthByUsername(request.getUsername().trim())
.orElse(null);
if (auth != null && auth.lockedUntil() != null
&& auth.lockedUntil().isAfter(OffsetDateTime.now(ZoneOffset.UTC))) {
throw new BusinessException(ErrorCode.LOGIN_LOCKED);
}
String storedHash = auth == null ? unknownUserHash : auth.passwordHash();
if (!passwordEncoder.matches(request.getPassword(), storedHash) || auth == null) {
if (auth != null) {
UserSecurityProperties.LoginLock lock = properties.getLoginLock();
userRepository.recordLoginFailure(auth.id(), lock.getFailureWindow().toSeconds(),
lock.getMaxFailures(), lock.getLockDuration().toSeconds());
}
throw new BusinessException(ErrorCode.INVALID_CREDENTIALS);
}
userRepository.recordLoginSuccess(auth.id());
return new VerifyPasswordResponse(auth.id(), auth.username(), auth.nickname());
}
@@ -0,0 +1,43 @@
package com.patbond.patbond.user.session;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.common.session.CreateSessionRequest;
import com.patbond.patbond.common.session.RefreshSessionRequest;
import com.patbond.patbond.common.session.RevokeSessionRequest;
import com.patbond.patbond.common.session.SessionTokens;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Internal session API consumed by patbond-auth. Guarded by
* InternalAuthFilter like every /internal/** route.
*/
@RestController
@RequestMapping("/internal/sessions")
public class SessionController {
private final SessionService sessionService;
public SessionController(SessionService sessionService) {
this.sessionService = sessionService;
}
@PostMapping
public ApiResponse<SessionTokens> create(@Valid @RequestBody CreateSessionRequest request) {
return ApiResponse.success(sessionService.create(request));
}
@PostMapping("/refresh")
public ApiResponse<SessionTokens> refresh(@Valid @RequestBody RefreshSessionRequest request) {
return ApiResponse.success(sessionService.refresh(request.getRefreshToken()));
}
@PostMapping("/revoke")
public ApiResponse<Void> revoke(@Valid @RequestBody RevokeSessionRequest request) {
sessionService.revoke(request.getUserId(), request.getRefreshToken());
return ApiResponse.success(null);
}
}
@@ -0,0 +1,108 @@
package com.patbond.patbond.user.session;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.time.OffsetDateTime;
import java.util.Optional;
import java.util.UUID;
/**
* JDBC access to identity.auth_sessions. The table only ever sees the
* SHA-256 digest of a refresh token (ck_sessions_refresh_hash pins 32 bytes);
* plaintext tokens exist solely in transit back to the client.
*/
@Repository
public class SessionRepository {
private final JdbcClient jdbcClient;
public SessionRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
public record SessionRow(UUID id, UUID userId, UUID tokenFamilyId, OffsetDateTime expiresAt,
OffsetDateTime revokedAt, OffsetDateTime rotatedAt) {
}
public void insert(UUID id, UUID userId, UUID tokenFamilyId, byte[] refreshTokenHash,
String accessTokenJti, OffsetDateTime expiresAt,
String userAgent, String ipAddress) {
jdbcClient.sql("""
INSERT INTO identity.auth_sessions
(id, user_id, token_family_id, refresh_token_hash, access_token_jti,
expires_at, user_agent, ip_address)
VALUES (:id, :userId, :familyId, :hash, :jti, :expiresAt,
:userAgent, CAST(:ipAddress AS inet))
""")
.param("id", id)
.param("userId", userId)
.param("familyId", tokenFamilyId)
.param("hash", refreshTokenHash)
.param("jti", accessTokenJti)
.param("expiresAt", expiresAt)
.param("userAgent", userAgent)
.param("ipAddress", ipAddress)
.update();
}
public Optional<SessionRow> findByTokenHash(byte[] refreshTokenHash) {
return jdbcClient.sql("""
SELECT id, user_id, token_family_id, expires_at, revoked_at, rotated_at
FROM identity.auth_sessions
WHERE refresh_token_hash = :hash
""")
.param("hash", refreshTokenHash)
.query((rs, rowNum) -> new SessionRow(
rs.getObject("id", UUID.class),
rs.getObject("user_id", UUID.class),
rs.getObject("token_family_id", UUID.class),
rs.getObject("expires_at", OffsetDateTime.class),
rs.getObject("revoked_at", OffsetDateTime.class),
rs.getObject("rotated_at", OffsetDateTime.class)))
.optional();
}
/**
* Closes the old session as part of a rotation. The revoked_at IS NULL
* guard makes concurrent rotations of the same token detectable: exactly
* one caller sees 1 row updated, every other sees 0 (= reuse).
*/
public int markRotated(UUID oldSessionId, UUID newSessionId) {
return jdbcClient.sql("""
UPDATE identity.auth_sessions
SET revoked_at = now(), rotated_at = now(),
revoke_reason = 'rotated', replaced_by_session_id = :newId,
last_seen_at = now()
WHERE id = :oldId AND revoked_at IS NULL
""")
.param("oldId", oldSessionId)
.param("newId", newSessionId)
.update();
}
/** Revokes every live session of the family (refresh-token reuse response). */
public int revokeFamily(UUID tokenFamilyId, String reason) {
return jdbcClient.sql("""
UPDATE identity.auth_sessions
SET revoked_at = now(), revoke_reason = :reason
WHERE token_family_id = :familyId AND revoked_at IS NULL
""")
.param("familyId", tokenFamilyId)
.param("reason", reason)
.update();
}
/** Logout: revokes the one live session holding this token for this user. */
public int revokeByTokenHashAndUser(byte[] refreshTokenHash, UUID userId, String reason) {
return jdbcClient.sql("""
UPDATE identity.auth_sessions
SET revoked_at = now(), revoke_reason = :reason
WHERE refresh_token_hash = :hash AND user_id = :userId AND revoked_at IS NULL
""")
.param("hash", refreshTokenHash)
.param("userId", userId)
.param("reason", reason)
.update();
}
}
@@ -0,0 +1,122 @@
package com.patbond.patbond.user.session;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.session.CreateSessionRequest;
import com.patbond.patbond.common.session.SessionTokens;
import com.patbond.patbond.user.config.UserSecurityProperties;
import com.patbond.patbond.user.support.UuidV7;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Base64;
import java.util.UUID;
/**
* Refresh-session lifecycle on identity.auth_sessions, implementing ADR-003:
* 30-day (configurable) refresh tokens, rotation on every refresh, reuse of a
* rotated token revokes the whole token family, logout revokes only the
* current session so parallel device sessions stay alive.
*
* <p>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);
}
}
}
@@ -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-003refresh 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:
#
@@ -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