Compare commits

...

2 Commits

Author SHA1 Message Date
lixi 4dc3dcdfa3 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>
2026-09-04 12:15:40 +08:00
lixi 43ab6c5827 test: Testcontainers 数据库镜像升级为 postgres:18(ADR-008)
- 验收:JAVA_HOME=jdk17 ./mvnw clean test 全量 37 测试 0 失败,Flyway V1 于 PostgreSQL 18.6 执行无兼容问题
2026-09-04 11:34:08 +08:00
53 changed files with 2771 additions and 154 deletions
+39 -14
View File
@@ -13,7 +13,7 @@ Patbond API is a Spring Boot multi-module backend.
- Java 17 (build baseline; use JDK 17 for release builds)
- Spring Boot 3.5.16
- Spring Cloud 2025.0.3 (OpenFeign only)
- PostgreSQL 16 + Flyway (patbond-user owns the `identity`/`media` schemas)
- PostgreSQL 18 + Flyway (patbond-user owns the `identity`/`media` schemas)
- Maven (use the committed Maven Wrapper `./mvnw`)
## Build and Test
@@ -22,7 +22,7 @@ Patbond API is a Spring Boot multi-module backend.
./mvnw clean test
```
Integration tests start a disposable `postgres:16` via Testcontainers, so a
Integration tests start a disposable `postgres:18` via Testcontainers, so a
running Docker daemon is required (no local PostgreSQL installation or
credentials are needed).
@@ -34,7 +34,7 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test
## Run
Running `patbond-user` requires a reachable PostgreSQL 16 database; Flyway
Running `patbond-user` requires a reachable PostgreSQL 18 database; Flyway
applies the versioned migrations in
`patbond-user/src/main/resources/db/migration` automatically on startup.
Development seed data (`db/dev`, regions reference rows) is opt-in via a dev
@@ -59,13 +59,24 @@ Install the shared module once, then run each service in its own terminal:
./mvnw -pl patbond-auth spring-boot:run
```
Running the services also needs an RS256 key pair for access tokens (the
private key signs in `patbond-auth`, the public key verifies in
`patbond-user`; neither is committed):
```bash
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_PRIVATE_KEY=$PWD/jwt-private.pem # patbond-auth
export PATBOND_JWT_PUBLIC_KEY=$PWD/jwt-public.pem # patbond-user
```
Tests do not require this copy step — `patbond-auth/src/test/resources/application.yml`
keeps `./mvnw clean test` self-contained on a clean checkout.
Smoke check:
```bash
curl -X POST http://127.0.0.1:8081/auth/register \
curl -X POST http://127.0.0.1:8081/api/v1/auth/register \
-H 'Content-Type: application/json' \
-d '{"username":"demo_user","password":"secret123"}'
```
@@ -80,6 +91,14 @@ curl -X POST http://127.0.0.1:8081/auth/register \
| `PATBOND_DB_URL` | `jdbc:postgresql://127.0.0.1:5432/patbond` | patbond-user |
| `PATBOND_DB_USER` | `patbond` | patbond-user |
| `PATBOND_DB_PASSWORD` | `patbond` | patbond-user |
| `PATBOND_INTERNAL_TOKEN` | `dev-only-internal-token` | both (shared secret for `/internal/**`; inject a strong random value outside local dev) |
| `PATBOND_JWT_PRIVATE_KEY` | _(none, required)_ | patbond-auth (RS256 private key: PEM path or inline PEM) |
| `PATBOND_JWT_PUBLIC_KEY` | _(none)_ | patbond-user (RS256 public key: PEM path or inline PEM) |
| `PATBOND_ACCESS_TTL` | `15m` | patbond-auth (access token lifetime, ADR-003) |
| `PATBOND_REFRESH_TTL` | `30d` | patbond-user (refresh token lifetime, ADR-003) |
| `PATBOND_LOGIN_LOCK_MAX_FAILURES` | `5` | patbond-user (login failures before lockout) |
| `PATBOND_LOGIN_LOCK_WINDOW` | `15m` | patbond-user (failure counting window) |
| `PATBOND_LOGIN_LOCK_DURATION` | `15m` | patbond-user (lock duration) |
Machine-specific values live in the git-ignored `application.yml` (copied from the
committed `.sample`); never commit secrets to the samples.
@@ -90,21 +109,27 @@ committed `.sample`); never commit secrets to the samples.
- Application name: `patbond-auth`
- Default port: `8081`
- Register: `POST /auth/register`
- Login: `POST /auth/login`
- Register: `POST /api/v1/auth/register`
- Login: `POST /api/v1/auth/login`
- Refresh (rotates the refresh token): `POST /api/v1/auth/refresh`
- Logout (revokes the current session): `POST /api/v1/auth/logout`
### User Service
- Application name: `patbond-user`
- Default port: `8082`
- Create user: `POST /internal/users`
- Verify password: `POST /internal/users/verify-password`
- Get user by id: `GET /internal/users/{id}`
- Get user by username: `GET /internal/users/by-username/{username}`
- Current user profile: `GET /api/v1/me` (Bearer access token, verified locally with the RS256 public key)
- Internal (require `X-Internal-Token`):
- Create user: `POST /internal/users`
- Verify password: `POST /internal/users/verify-password`
- Get user by id: `GET /internal/users/{id}`
- Get user by username: `GET /internal/users/by-username/{username}`
- Sessions: `POST /internal/sessions`, `POST /internal/sessions/refresh`, `POST /internal/sessions/revoke`
> Note: user data is persisted in PostgreSQL (`identity.users` /
> `identity.user_credentials`, bcrypt password hashes, UUIDv7 ids generated in
> the application). Errors follow the `{code, message, data}` envelope with
> stable business codes and matching HTTP statuses. Verifiable JWT tokens,
> refresh sessions, and `/internal` access control are planned in iteration 1
> follow-up tasks.
> the application). Refresh sessions live in `identity.auth_sessions` (SHA-256
> digests only, rotation on every refresh, token-family revocation on reuse,
> ADR-003). Errors follow the `{code, message, data}` envelope with stable
> business codes and matching HTTP statuses; the public contract is documented
> in `patbond-doc/docs/api/openapi.yaml`.
+41
View File
@@ -35,11 +35,52 @@
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- Real HTTP client for Feign: the JDK HttpURLConnection default
returns a null error stream on 401 replies to streamed POSTs, so
downstream error envelopes (40100/40102…) were unreadable and
collapsed to 503 (found by AuthE2eIntegrationTest). -->
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-hc5</artifactId>
</dependency>
<!-- Access token issuing (RS256): jjwt is not in the Boot BOM, version
pinned here and in patbond-user in step. -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- End-to-end vertical test: boots the real user service (against a
Testcontainers postgres:18) in the same JVM and drives the full
register → login → me → refresh → logout flow over HTTP. -->
<dependency>
<groupId>com.patbond.patbond</groupId>
<artifactId>patbond-user</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -1,10 +1,11 @@
package com.patbond.patbond.auth;
import com.patbond.patbond.auth.config.FeignInternalConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
@EnableFeignClients
@EnableFeignClients(defaultConfiguration = FeignInternalConfig.class)
@SpringBootApplication
public class AuthApplication {
@@ -0,0 +1,27 @@
package com.patbond.patbond.auth.client;
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 org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* Session lifecycle API of patbond-user, the identity schema owner. The
* X-Internal-Token header is attached by the interceptor in FeignConfig.
*/
@FeignClient(name = "patbond-user-sessions", url = "${patbond.user-service.url}")
public interface SessionClient {
@PostMapping("/internal/sessions")
ApiResponse<SessionTokens> create(@RequestBody CreateSessionRequest request);
@PostMapping("/internal/sessions/refresh")
ApiResponse<SessionTokens> refresh(@RequestBody RefreshSessionRequest request);
@PostMapping("/internal/sessions/revoke")
ApiResponse<Void> revoke(@RequestBody RevokeSessionRequest request);
}
@@ -7,6 +7,8 @@ import com.patbond.patbond.common.response.ApiResponse;
import feign.Response;
import feign.Util;
import feign.codec.ErrorDecoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
@@ -19,6 +21,8 @@ import java.io.IOException;
*/
public class ApiErrorDecoder implements ErrorDecoder {
private static final Logger log = LoggerFactory.getLogger(ApiErrorDecoder.class);
private final ObjectMapper objectMapper;
public ApiErrorDecoder(ObjectMapper objectMapper) {
@@ -35,8 +39,10 @@ public class ApiErrorDecoder implements ErrorDecoder {
return new BusinessException(envelope.getCode(), response.status(), envelope.getMessage());
}
}
} catch (IOException | RuntimeException ignored) {
// Not a Patbond envelope; fall through to the generic error below.
} catch (IOException | RuntimeException e) {
// Not a Patbond envelope; report as DOWNSTREAM_UNAVAILABLE below.
// The body itself is not logged (it may echo request data).
log.warn("Undecodable {} reply from {}: {}", response.status(), methodKey, e.toString());
}
return new BusinessException(ErrorCode.DOWNSTREAM_UNAVAILABLE);
}
@@ -0,0 +1,69 @@
package com.patbond.patbond.auth.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Security knobs of the auth service. ADR-003 mandates configurable token
* lifetimes; the committed default is the ADR value (access 15 minutes).
*/
@ConfigurationProperties(prefix = "patbond")
public class AuthSecurityProperties {
/** Shared secret sent as X-Internal-Token on every call to patbond-user. */
private String internalToken;
private final Jwt jwt = new Jwt();
public String getInternalToken() {
return internalToken;
}
public void setInternalToken(String internalToken) {
this.internalToken = internalToken;
}
public Jwt getJwt() {
return jwt;
}
public static class Jwt {
/**
* RS256 private key (PKCS#8): either inline PEM (starts with
* -----BEGIN) or a filesystem path. Injected via environment
* variable; the key never enters the repository.
*/
private String privateKey;
/** Access token lifetime (ADR-003: 15 minutes). */
private Duration accessTtl = Duration.ofMinutes(15);
private String issuer = "patbond-auth";
public String getPrivateKey() {
return privateKey;
}
public void setPrivateKey(String privateKey) {
this.privateKey = privateKey;
}
public Duration getAccessTtl() {
return accessTtl;
}
public void setAccessTtl(Duration accessTtl) {
this.accessTtl = accessTtl;
}
public String getIssuer() {
return issuer;
}
public void setIssuer(String issuer) {
this.issuer = issuer;
}
}
}
@@ -1,15 +0,0 @@
package com.patbond.patbond.auth.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.codec.ErrorDecoder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class FeignConfig {
@Bean
public ErrorDecoder apiErrorDecoder(ObjectMapper objectMapper) {
return new ApiErrorDecoder(objectMapper);
}
}
@@ -0,0 +1,33 @@
package com.patbond.patbond.auth.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.RequestInterceptor;
import feign.codec.ErrorDecoder;
import org.springframework.context.annotation.Bean;
/**
* Registered via {@code @EnableFeignClients(defaultConfiguration = …)} so
* these beans land INSIDE each Feign child context. Deliberately not
* annotated with @Configuration: a component-scanned ErrorDecoder only
* reaches the parent context, where the child's own @ConditionalOnMissingBean
* default shadows it — downstream business errors would silently collapse to
* 503 on real HTTP calls (caught by AuthE2eIntegrationTest).
*/
public class FeignInternalConfig {
/**
* Re-raises downstream {code, message} envelopes as BusinessException so
* the user service's business code and HTTP status reach the client
* unchanged (audit issue M1).
*/
@Bean
public ErrorDecoder apiErrorDecoder(ObjectMapper objectMapper) {
return new ApiErrorDecoder(objectMapper);
}
/** Presents the shared service secret on every call to patbond-user. */
@Bean
public RequestInterceptor internalTokenInterceptor(AuthSecurityProperties properties) {
return template -> template.header("X-Internal-Token", properties.getInternalToken());
}
}
@@ -0,0 +1,23 @@
package com.patbond.patbond.auth.config;
import com.patbond.patbond.auth.security.JwtSigner;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Application-context security wiring; the Feign-specific beans live in
* {@link FeignInternalConfig} (see the note there).
*/
@Configuration
@EnableConfigurationProperties(AuthSecurityProperties.class)
public class SecurityConfig {
@Bean
public JwtSigner jwtSigner(AuthSecurityProperties properties) {
return new JwtSigner(
properties.getJwt().getPrivateKey(),
properties.getJwt().getAccessTtl(),
properties.getJwt().getIssuer());
}
}
@@ -2,17 +2,23 @@ package com.patbond.patbond.auth.controller;
import com.patbond.patbond.auth.dto.AuthTokenResponse;
import com.patbond.patbond.auth.dto.LoginRequest;
import com.patbond.patbond.auth.dto.LogoutRequest;
import com.patbond.patbond.auth.dto.RefreshRequest;
import com.patbond.patbond.auth.dto.RegisterRequest;
import com.patbond.patbond.auth.service.AuthService;
import com.patbond.patbond.common.response.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** Public auth endpoints under the /api/v1 prefix (development-plan 6.2). */
@RestController
@RequestMapping("/auth")
@RequestMapping("/api/v1/auth")
public class AuthController {
private final AuthService authService;
@@ -22,12 +28,39 @@ public class AuthController {
}
@PostMapping("/register")
public ApiResponse<AuthTokenResponse> register(@Valid @RequestBody RegisterRequest request) {
return ApiResponse.success(authService.register(request));
public ApiResponse<AuthTokenResponse> register(@Valid @RequestBody RegisterRequest request,
HttpServletRequest httpRequest) {
return ApiResponse.success(authService.register(request, clientInfo(httpRequest)));
}
@PostMapping("/login")
public ApiResponse<AuthTokenResponse> login(@Valid @RequestBody LoginRequest request) {
return ApiResponse.success(authService.login(request));
public ApiResponse<AuthTokenResponse> login(@Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
return ApiResponse.success(authService.login(request, clientInfo(httpRequest)));
}
@PostMapping("/refresh")
public ApiResponse<AuthTokenResponse> refresh(@Valid @RequestBody RefreshRequest request) {
return ApiResponse.success(authService.refresh(request));
}
@PostMapping("/logout")
public ApiResponse<Void> logout(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@Valid @RequestBody LogoutRequest request) {
authService.logout(authorization, request);
return ApiResponse.success(null);
}
private AuthService.ClientInfo clientInfo(HttpServletRequest request) {
String userAgent = request.getHeader(HttpHeaders.USER_AGENT);
if (userAgent != null && userAgent.length() > 512) {
userAgent = userAgent.substring(0, 512);
}
String forwarded = request.getHeader("X-Forwarded-For");
String ip = forwarded != null && !forwarded.isBlank()
? forwarded.split(",")[0].trim()
: request.getRemoteAddr();
return new AuthService.ClientInfo(userAgent, ip);
}
}
@@ -1,25 +1,35 @@
package com.patbond.patbond.auth.dto;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* Frozen public contract for register/login/refresh responses — exactly
* {userId, tokenType, accessToken, accessTokenExpiresAt, refreshToken,
* refreshTokenExpiresAt}; timestamps serialize as ISO 8601 with offset.
*/
public class AuthTokenResponse {
private UUID userId;
private String tokenType;
private String accessToken;
private LocalDateTime expiresAt;
private UUID userId;
private String username;
private String nickname;
private OffsetDateTime accessTokenExpiresAt;
private String refreshToken;
private OffsetDateTime refreshTokenExpiresAt;
public AuthTokenResponse(String tokenType, String accessToken, LocalDateTime expiresAt,
UUID userId, String username, String nickname) {
public AuthTokenResponse(UUID userId, String tokenType,
String accessToken, OffsetDateTime accessTokenExpiresAt,
String refreshToken, OffsetDateTime refreshTokenExpiresAt) {
this.userId = userId;
this.tokenType = tokenType;
this.accessToken = accessToken;
this.expiresAt = expiresAt;
this.userId = userId;
this.username = username;
this.nickname = nickname;
this.accessTokenExpiresAt = accessTokenExpiresAt;
this.refreshToken = refreshToken;
this.refreshTokenExpiresAt = refreshTokenExpiresAt;
}
public UUID getUserId() {
return userId;
}
public String getTokenType() {
@@ -30,19 +40,15 @@ public class AuthTokenResponse {
return accessToken;
}
public LocalDateTime getExpiresAt() {
return expiresAt;
public OffsetDateTime getAccessTokenExpiresAt() {
return accessTokenExpiresAt;
}
public UUID getUserId() {
return userId;
public String getRefreshToken() {
return refreshToken;
}
public String getUsername() {
return username;
}
public String getNickname() {
return nickname;
public OffsetDateTime getRefreshTokenExpiresAt() {
return refreshTokenExpiresAt;
}
}
@@ -0,0 +1,17 @@
package com.patbond.patbond.auth.dto;
import jakarta.validation.constraints.NotBlank;
public class LogoutRequest {
@NotBlank(message = "refreshToken 不能为空")
private String refreshToken;
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
}
@@ -0,0 +1,17 @@
package com.patbond.patbond.auth.dto;
import jakarta.validation.constraints.NotBlank;
public class RefreshRequest {
@NotBlank(message = "refreshToken 不能为空")
private String refreshToken;
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
}
@@ -0,0 +1,76 @@
package com.patbond.patbond.auth.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;
import java.security.interfaces.RSAPrivateCrtKey;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Date;
import java.util.UUID;
/**
* Issues (and, for logout, verifies) RS256 access tokens. Claims: sub = user
* UUID, jti = the id minted by patbond-user and stored on the session row
* (auth_sessions.access_token_jti), sid = session UUID, plus iss/iat/exp.
* Resource services verify these tokens locally with the public key only —
* the private key never leaves this service.
*/
public class JwtSigner {
public static final String SESSION_ID_CLAIM = "sid";
private final RSAPrivateCrtKey privateKey;
private final Duration accessTtl;
private final String issuer;
private final JwtParser parser;
public record AccessToken(String token, OffsetDateTime expiresAt) {
}
public JwtSigner(String privateKeyLocation, Duration accessTtl, String issuer) {
if (privateKeyLocation == null || privateKeyLocation.isBlank()) {
throw new IllegalStateException(
"patbond.jwt.private-key 未配置:请生成 RS256 密钥对并通过环境变量注入"
+ "(见 application.yml.sample");
}
this.privateKey = RsaPrivateKeyLoader.load(privateKeyLocation);
this.accessTtl = accessTtl;
this.issuer = issuer;
this.parser = Jwts.parser()
.verifyWith(RsaPrivateKeyLoader.derivePublicKey(privateKey))
.build();
}
public AccessToken sign(UUID userId, UUID sessionId, String jti) {
Instant now = Instant.now();
Instant expiresAt = now.plus(accessTtl);
String token = Jwts.builder()
.id(jti)
.subject(userId.toString())
.issuer(issuer)
.claim(SESSION_ID_CLAIM, sessionId.toString())
.issuedAt(Date.from(now))
.expiration(Date.from(expiresAt))
.signWith(privateKey, Jwts.SIG.RS256)
.compact();
return new AccessToken(token, OffsetDateTime.ofInstant(expiresAt, ZoneOffset.UTC));
}
/**
* @throws BusinessException 40101 when the token is forged, malformed or expired
*/
public Claims verify(String token) {
try {
return parser.parseSignedClaims(token).getPayload();
} catch (JwtException | IllegalArgumentException e) {
throw new BusinessException(ErrorCode.TOKEN_INVALID);
}
}
}
@@ -0,0 +1,59 @@
package com.patbond.patbond.auth.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.RSAPrivateCrtKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.RSAPublicKeySpec;
import java.util.Base64;
/**
* Loads the RS256 signing 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 PKCS#8 form produced by
* `openssl genpkey` is supported. The matching public key is derived from
* the CRT parameters, so this service needs no second configuration value.
*/
public final class RsaPrivateKeyLoader {
private RsaPrivateKeyLoader() {
}
public static RSAPrivateCrtKey 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 PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
try {
byte[] der = Base64.getDecoder().decode(base64);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return (RSAPrivateCrtKey) keyFactory.generatePrivate(new PKCS8EncodedKeySpec(der));
} catch (IllegalArgumentException | ClassCastException
| NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new IllegalStateException("JWT 私钥不是有效的 PEM(PKCS#8) RSA 私钥", e);
}
}
public static RSAPublicKey derivePublicKey(RSAPrivateCrtKey privateKey) {
try {
return (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(
new RSAPublicKeySpec(privateKey.getModulus(), privateKey.getPublicExponent()));
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new IllegalStateException("无法从 RSA 私钥推导公钥", e);
}
}
}
@@ -1,31 +1,53 @@
package com.patbond.patbond.auth.service;
import com.patbond.patbond.auth.client.SessionClient;
import com.patbond.patbond.auth.client.UserClient;
import com.patbond.patbond.auth.dto.AuthTokenResponse;
import com.patbond.patbond.auth.dto.LoginRequest;
import com.patbond.patbond.auth.dto.LogoutRequest;
import com.patbond.patbond.auth.dto.RefreshRequest;
import com.patbond.patbond.auth.dto.RegisterRequest;
import com.patbond.patbond.auth.security.JwtSigner;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
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 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 io.jsonwebtoken.Claims;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Thin public entry for authentication (per the adopted architecture from
* the iteration-1 technical assessment): credentials and sessions live in
* patbond-user, the identity schema owner; this service validates input,
* orchestrates the internal calls, and signs RS256 access tokens.
*/
@Service
public class AuthService {
private final UserClient userClient;
public AuthService(UserClient userClient) {
this.userClient = userClient;
/** Device metadata forwarded to the session record (observability only). */
public record ClientInfo(String userAgent, String ipAddress) {
}
public AuthTokenResponse register(RegisterRequest request) {
private final UserClient userClient;
private final SessionClient sessionClient;
private final JwtSigner jwtSigner;
public AuthService(UserClient userClient, SessionClient sessionClient, JwtSigner jwtSigner) {
this.userClient = userClient;
this.sessionClient = sessionClient;
this.jwtSigner = jwtSigner;
}
public AuthTokenResponse register(RegisterRequest request, ClientInfo clientInfo) {
ApiResponse<UserProfile> response = userClient.createUser(new CreateUserRequest(
request.getUsername(),
request.getPassword(),
@@ -33,28 +55,63 @@ public class AuthService {
request.getPhone()
));
UserProfile user = requireData(response, "注册失败");
return buildToken(user.getId(), user.getUsername(), user.getNickname());
return openSession(user.getId(), clientInfo);
}
public AuthTokenResponse login(LoginRequest request) {
public AuthTokenResponse login(LoginRequest request, ClientInfo clientInfo) {
ApiResponse<VerifyPasswordResponse> response = userClient.verifyPassword(
new VerifyPasswordRequest(request.getUsername(), request.getPassword())
);
VerifyPasswordResponse user = requireData(response, "登录失败");
return buildToken(user.getUserId(), user.getUsername(), user.getNickname());
return openSession(user.getUserId(), clientInfo);
}
private AuthTokenResponse buildToken(UUID userId, String username, String nickname) {
/** ADR-003: every refresh rotates the refresh token (handled downstream). */
public AuthTokenResponse refresh(RefreshRequest request) {
SessionTokens tokens = requireData(
sessionClient.refresh(new RefreshSessionRequest(request.getRefreshToken())),
"刷新失败");
return assemble(tokens);
}
/**
* ADR-003: logout revokes only the current session. The user is taken
* from the verified access token, so a caller can only revoke their own
* session; an invalid or expired access token answers 40101.
*/
public void logout(String authorizationHeader, LogoutRequest request) {
Claims claims = requireBearer(authorizationHeader);
UUID userId = UUID.fromString(claims.getSubject());
requireSuccess(sessionClient.revoke(
new RevokeSessionRequest(userId, request.getRefreshToken())), "退出失败");
}
private AuthTokenResponse openSession(UUID userId, ClientInfo clientInfo) {
SessionTokens tokens = requireData(sessionClient.create(new CreateSessionRequest(
userId, clientInfo.userAgent(), clientInfo.ipAddress())), "创建会话失败");
return assemble(tokens);
}
private AuthTokenResponse assemble(SessionTokens tokens) {
JwtSigner.AccessToken accessToken =
jwtSigner.sign(tokens.getUserId(), tokens.getSessionId(), tokens.getJti());
return new AuthTokenResponse(
tokens.getUserId(),
"Bearer",
UUID.randomUUID().toString().replace("-", ""),
LocalDateTime.now().plusHours(2),
userId,
username,
nickname
accessToken.token(),
accessToken.expiresAt(),
tokens.getRefreshToken(),
tokens.getRefreshTokenExpiresAt()
);
}
private Claims requireBearer(String authorizationHeader) {
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
throw new BusinessException(ErrorCode.TOKEN_INVALID);
}
return jwtSigner.verify(authorizationHeader.substring("Bearer ".length()).trim());
}
/**
* Downstream business failures arrive as BusinessException via the Feign
* ErrorDecoder and never reach this method; this only guards against a
@@ -67,4 +124,12 @@ public class AuthService {
}
return response.getData();
}
/** Same guard for envelopes that legitimately carry no data (revoke). */
private void requireSuccess(ApiResponse<Void> response, String defaultMessage) {
if (response == null || !response.isSuccess()) {
String message = response == null || response.getMessage() == null ? defaultMessage : response.getMessage();
throw new BusinessException(ErrorCode.INTERNAL_ERROR, message);
}
}
}
@@ -8,3 +8,18 @@ spring:
patbond:
user-service:
url: ${PATBOND_USER_SERVICE_URL:http://127.0.0.1:8082}
# /internal/** 服务间共享密钥,需与 patbond-user 配置同一值;生产环境必须
# 通过 PATBOND_INTERNAL_TOKEN 注入强随机值(如 `openssl rand -hex 32`)。
internal-token: ${PATBOND_INTERNAL_TOKEN:dev-only-internal-token}
jwt:
# RS256 私钥(PKCS#8),用于签发 access token;对应公钥配置给 patbond-user。
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
# 生成密钥对(私钥绝不提交进仓库):
# 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_PRIVATE_KEY=/path/to/jwt-private.pem
# 未配置时服务启动失败(fail-fast)。
private-key: ${PATBOND_JWT_PRIVATE_KEY:}
# ADR-003access token 15 分钟,可配置。
access-ttl: ${PATBOND_ACCESS_TTL:15m}
issuer: patbond-auth
@@ -1,15 +1,25 @@
package com.patbond.patbond.auth;
import com.patbond.patbond.auth.support.SpringTestSupport;
import com.patbond.patbond.auth.support.TestJwtKeys;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
@SpringBootTest
@SpringBootTest(properties = SpringTestSupport.EXCLUDE_JDBC_AUTOCONFIG)
class AuthApplicationTests {
@DynamicPropertySource
static void jwtKey(DynamicPropertyRegistry registry) {
registry.add("patbond.jwt.private-key", TestJwtKeys::privatePem);
}
@Test
void contextLoads() {
// Verifies the auth service starts with the committed application.yml:
// the Feign client resolves patbond.user-service.url from the config
// default without Nacos or a running user service (ADR-002).
// Verifies the auth service starts with the committed configuration:
// the Feign clients resolve patbond.user-service.url without Nacos or
// a running user service (ADR-002), and the JwtSigner comes up from
// an injected private key (here: generated per test run).
}
}
@@ -0,0 +1,247 @@
package com.patbond.patbond.auth;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.auth.support.SpringTestSupport;
import com.patbond.patbond.auth.support.TestJwtKeys;
import com.patbond.patbond.user.UserApplication;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import java.time.Duration;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Date;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* End-to-end vertical over real HTTP: this class boots the actual user
* service (Flyway V1 on a clean Testcontainers postgres:18) in the same JVM
* and drives the full M1 acceptance flow through the auth service —
* register → me → refresh (rotation) → reuse rejected + family revoked →
* login → logout → refresh dead. Also pins 40101 for expired/forged access
* tokens, 401 for /internal without the service credential, and the login
* failure lockout.
*/
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = SpringTestSupport.EXCLUDE_JDBC_AUTOCONFIG)
class AuthE2eIntegrationTest {
private static final String INTERNAL_TOKEN = "e2e-internal-token";
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:18");
private static ConfigurableApplicationContext userApp;
private static String userBaseUrl;
@Autowired
private TestRestTemplate restTemplate;
@DynamicPropertySource
static void bootUserServiceAndWireAuth(DynamicPropertyRegistry registry) {
POSTGRES.start();
userApp = new SpringApplicationBuilder(UserApplication.class).run(
"--server.port=0",
"--spring.application.name=patbond-user",
"--spring.datasource.url=" + POSTGRES.getJdbcUrl(),
"--spring.datasource.username=" + POSTGRES.getUsername(),
"--spring.datasource.password=" + POSTGRES.getPassword(),
"--patbond.internal-token=" + INTERNAL_TOKEN,
"--patbond.jwt.public-key=" + TestJwtKeys.publicPem(),
"--patbond.login-lock.max-failures=3");
userBaseUrl = "http://127.0.0.1:" + userApp.getEnvironment().getProperty("local.server.port");
registry.add("patbond.user-service.url", () -> userBaseUrl);
registry.add("patbond.internal-token", () -> INTERNAL_TOKEN);
registry.add("patbond.jwt.private-key", TestJwtKeys::privatePem);
}
@AfterAll
static void shutdown() {
if (userApp != null) {
userApp.close();
}
POSTGRES.stop();
}
private ResponseEntity<String> postJson(String url, String body, String bearerToken) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
if (bearerToken != null) {
headers.setBearerAuth(bearerToken);
}
return restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(body, headers), String.class);
}
private ResponseEntity<String> getWithBearer(String url, String bearerToken) {
HttpHeaders headers = new HttpHeaders();
if (bearerToken != null) {
headers.setBearerAuth(bearerToken);
}
return restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), String.class);
}
private String register(String username, String phone) {
ResponseEntity<String> response = postJson("/api/v1/auth/register",
"{\"username\":\"%s\",\"phone\":\"%s\",\"password\":\"secret123\"}"
.formatted(username, phone), null);
assertThat(response.getStatusCode().value()).isEqualTo(200);
return response.getBody();
}
private ResponseEntity<String> login(String username, String password) {
return postJson("/api/v1/auth/login",
"{\"username\":\"%s\",\"password\":\"%s\"}".formatted(username, password), null);
}
private ResponseEntity<String> refresh(String refreshToken) {
return postJson("/api/v1/auth/refresh",
"{\"refreshToken\":\"%s\"}".formatted(refreshToken), null);
}
@Test
void fullAuthVerticalFlow() {
// Register: frozen contract shape, ISO 8601 timestamps with offset.
String registered = register("e2e_alice", "+8613800000501");
String userId = JsonPath.read(registered, "$.data.userId");
String accessToken = JsonPath.read(registered, "$.data.accessToken");
String refreshToken = JsonPath.read(registered, "$.data.refreshToken");
assertThat((String) JsonPath.read(registered, "$.data.tokenType")).isEqualTo("Bearer");
OffsetDateTime accessExpiry =
OffsetDateTime.parse(JsonPath.read(registered, "$.data.accessTokenExpiresAt"));
OffsetDateTime refreshExpiry =
OffsetDateTime.parse(JsonPath.read(registered, "$.data.refreshTokenExpiresAt"));
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
assertThat(accessExpiry).isAfter(now.plusMinutes(13)).isBefore(now.plusMinutes(17));
assertThat(refreshExpiry).isAfter(now.plusDays(29));
// Me on the user service, authenticated purely by local RS256 verification.
ResponseEntity<String> me = getWithBearer(userBaseUrl + "/api/v1/me", accessToken);
assertThat(me.getStatusCode().value()).isEqualTo(200);
assertThat((String) JsonPath.read(me.getBody(), "$.data.userId")).isEqualTo(userId);
assertThat((String) JsonPath.read(me.getBody(), "$.data.username")).isEqualTo("e2e_alice");
assertThat((String) JsonPath.read(me.getBody(), "$.data.phone")).isEqualTo("+8613800000501");
assertThat((String) JsonPath.read(me.getBody(), "$.data.createdAt")).contains("T");
// Refresh rotates the pair.
ResponseEntity<String> rotated = refresh(refreshToken);
assertThat(rotated.getStatusCode().value()).isEqualTo(200);
String rotatedRefresh = JsonPath.read(rotated.getBody(), "$.data.refreshToken");
assertThat(rotatedRefresh).isNotEqualTo(refreshToken);
assertThat((String) JsonPath.read(rotated.getBody(), "$.data.userId")).isEqualTo(userId);
// Replaying the rotated-away token is rejected and kills the family …
ResponseEntity<String> reuse = refresh(refreshToken);
assertThat(reuse.getStatusCode().value()).isEqualTo(401);
assertThat((int) JsonPath.read(reuse.getBody(), "$.code")).isEqualTo(40102);
// … including the freshly rotated token.
ResponseEntity<String> familyDead = refresh(rotatedRefresh);
assertThat(familyDead.getStatusCode().value()).isEqualTo(401);
assertThat((int) JsonPath.read(familyDead.getBody(), "$.code")).isEqualTo(40102);
// Login again (new family), then logout revokes that session.
ResponseEntity<String> reLogin = login("e2e_alice", "secret123");
assertThat(reLogin.getStatusCode().value()).isEqualTo(200);
String access2 = JsonPath.read(reLogin.getBody(), "$.data.accessToken");
String refresh2 = JsonPath.read(reLogin.getBody(), "$.data.refreshToken");
ResponseEntity<String> logout = postJson("/api/v1/auth/logout",
"{\"refreshToken\":\"%s\"}".formatted(refresh2), access2);
assertThat(logout.getStatusCode().value()).isEqualTo(200);
assertThat((int) JsonPath.read(logout.getBody(), "$.code")).isZero();
ResponseEntity<String> afterLogout = refresh(refresh2);
assertThat(afterLogout.getStatusCode().value()).isEqualTo(401);
assertThat((int) JsonPath.read(afterLogout.getBody(), "$.code")).isEqualTo(40102);
}
@Test
void expiredAndForgedAccessTokensAnswer40101() {
String registered = register("e2e_bob", "+8613800000502");
String userId = JsonPath.read(registered, "$.data.userId");
String expired = signToken(TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(-1));
ResponseEntity<String> expiredMe = getWithBearer(userBaseUrl + "/api/v1/me", expired);
assertThat(expiredMe.getStatusCode().value()).isEqualTo(401);
assertThat((int) JsonPath.read(expiredMe.getBody(), "$.code")).isEqualTo(40101);
String forged = signToken(TestJwtKeys.WRONG_KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
ResponseEntity<String> forgedMe = getWithBearer(userBaseUrl + "/api/v1/me", forged);
assertThat(forgedMe.getStatusCode().value()).isEqualTo(401);
assertThat((int) JsonPath.read(forgedMe.getBody(), "$.code")).isEqualTo(40101);
}
@Test
void internalEndpointsRejectCallsWithoutTheServiceCredential() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<String> response = restTemplate.exchange(
userBaseUrl + "/internal/users", HttpMethod.POST,
new HttpEntity<>("{\"username\":\"e2e_intruder\",\"password\":\"secret123\"}", headers),
String.class);
assertThat(response.getStatusCode().value()).isEqualTo(401);
}
@Test
void repeatedLoginFailuresLockTheAccount() {
register("e2e_carol", "+8613800000503");
for (int i = 0; i < 3; i++) {
ResponseEntity<String> wrong = login("e2e_carol", "wrong-password");
assertThat(wrong.getStatusCode().value()).isEqualTo(401);
assertThat((int) JsonPath.read(wrong.getBody(), "$.code")).isEqualTo(40100);
}
ResponseEntity<String> locked = login("e2e_carol", "secret123");
assertThat(locked.getStatusCode().value()).isEqualTo(423);
assertThat((int) JsonPath.read(locked.getBody(), "$.code")).isEqualTo(42300);
}
@Test
void logoutOnOneDeviceKeepsOtherDevicesLoggedIn() {
String device1 = register("e2e_dave", "+8613800000504");
String refresh1 = JsonPath.read(device1, "$.data.refreshToken");
ResponseEntity<String> device2 = login("e2e_dave", "secret123");
String access2 = JsonPath.read(device2.getBody(), "$.data.accessToken");
String refresh2 = JsonPath.read(device2.getBody(), "$.data.refreshToken");
ResponseEntity<String> logout = postJson("/api/v1/auth/logout",
"{\"refreshToken\":\"%s\"}".formatted(refresh2), access2);
assertThat(logout.getStatusCode().value()).isEqualTo(200);
// Device 2's session is gone, device 1 refreshes on unaffected.
assertThat(refresh(refresh2).getStatusCode().value()).isEqualTo(401);
ResponseEntity<String> stillAlive = refresh(refresh1);
assertThat(stillAlive.getStatusCode().value()).isEqualTo(200);
assertThat((int) JsonPath.read(stillAlive.getBody(), "$.code")).isZero();
}
private static String signToken(java.security.PrivateKey key, String userId, Duration ttl) {
Instant now = Instant.now();
return Jwts.builder()
.id(UUID.randomUUID().toString())
.subject(userId)
.issuer("patbond-auth")
.claim("sid", UUID.randomUUID().toString())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(ttl)))
.signWith(key, Jwts.SIG.RS256)
.compact();
}
}
@@ -1,74 +1,115 @@
package com.patbond.patbond.auth.controller;
import com.patbond.patbond.auth.client.SessionClient;
import com.patbond.patbond.auth.client.UserClient;
import com.patbond.patbond.auth.security.JwtSigner;
import com.patbond.patbond.auth.support.SpringTestSupport;
import com.patbond.patbond.auth.support.TestJwtKeys;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.common.session.RevokeSessionRequest;
import com.patbond.patbond.common.session.SessionTokens;
import com.patbond.patbond.common.user.UserProfile;
import com.patbond.patbond.common.user.VerifyPasswordResponse;
import feign.FeignException;
import feign.Request;
import feign.Response;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
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.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.matchesPattern;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.http.MediaType.APPLICATION_JSON;
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;
/**
* MockMvc tests for the auth endpoints. The Feign UserClient is replaced with
* a Mockito mock so no user-service process is required. Downstream business
* failures are simulated as the BusinessException the ApiErrorDecoder raises,
* so these tests pin the FIXED error contract: 409/401/400 pass through to
* the client with stable business codes instead of collapsing to 400/500
* (audit issue M1); transport-level Feign failures answer 503.
* MockMvc tests for the public /api/v1/auth endpoints. The Feign clients are
* Mockito mocks, so no user-service process is required; downstream business
* failures are simulated as the BusinessException the ApiErrorDecoder raises.
* These tests pin the FROZEN response contract: data carries exactly
* {userId, tokenType, accessToken, accessTokenExpiresAt, refreshToken,
* refreshTokenExpiresAt}, timestamps are ISO 8601 with offset, and error
* codes pass through unchanged (40100/40102/40900/42300…).
*/
@SpringBootTest
@SpringBootTest(properties = SpringTestSupport.EXCLUDE_JDBC_AUTOCONFIG)
@AutoConfigureMockMvc
class AuthControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UserClient userClient;
private static final UUID USER_ID = UUID.fromString("019212aa-0000-7000-8000-000000000001");
private static final UUID SESSION_ID = UUID.fromString("019212aa-0000-7000-8000-000000000002");
/** ISO 8601 with a UTC offset, e.g. 2026-09-04T12:34:56.789Z or …+00:00. */
private static final String ISO_OFFSET_PATTERN =
"\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})";
private static final String JWT_PATTERN =
"[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+";
private static final String REGISTER_BODY = """
{"username":"alice","password":"secret123","nickname":"Alice","phone":"+8613800138000"}
{"username":"alice","password":"secret123","phone":"+8613800138000"}
""";
private static final String LOGIN_BODY = """
{"username":"alice","password":"secret123"}
""";
@Test
void registerReturnsTokenWhenUserServiceSucceeds() throws Exception {
UserProfile profile = new UserProfile(USER_ID, "alice", "Alice", null, OffsetDateTime.now());
when(userClient.createUser(any())).thenReturn(ApiResponse.success(profile));
@Autowired
private MockMvc mockMvc;
mockMvc.perform(post("/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(REGISTER_BODY))
@Autowired
private JwtSigner jwtSigner;
@MockitoBean
private UserClient userClient;
@MockitoBean
private SessionClient sessionClient;
@DynamicPropertySource
static void jwtKey(DynamicPropertyRegistry registry) {
registry.add("patbond.jwt.private-key", TestJwtKeys::privatePem);
}
private SessionTokens sessionTokens() {
return new SessionTokens(SESSION_ID, USER_ID, "jti-1", "refresh-token-1",
OffsetDateTime.now(ZoneOffset.UTC).plusDays(30));
}
@Test
void registerReturnsTheFrozenTokenContract() throws Exception {
when(userClient.createUser(any())).thenReturn(ApiResponse.success(
new UserProfile(USER_ID, "alice", null, "+8613800138000", OffsetDateTime.now())));
when(sessionClient.create(any())).thenReturn(ApiResponse.success(sessionTokens()));
mockMvc.perform(post("/api/v1/auth/register").contentType(APPLICATION_JSON).content(REGISTER_BODY))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()))
.andExpect(jsonPath("$.data.username").value("alice"));
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
.andExpect(jsonPath("$.data.accessToken", matchesPattern(JWT_PATTERN)))
.andExpect(jsonPath("$.data.accessTokenExpiresAt", matchesPattern(ISO_OFFSET_PATTERN)))
.andExpect(jsonPath("$.data.refreshToken").value("refresh-token-1"))
.andExpect(jsonPath("$.data.refreshTokenExpiresAt", matchesPattern(ISO_OFFSET_PATTERN)))
// Frozen contract: exactly these six fields, nothing else.
.andExpect(jsonPath("$.data.username").doesNotExist())
.andExpect(jsonPath("$.data.nickname").doesNotExist())
.andExpect(jsonPath("$.data.expiresAt").doesNotExist());
}
@Test
@@ -76,9 +117,7 @@ class AuthControllerTest {
when(userClient.createUser(any()))
.thenThrow(new BusinessException(ErrorCode.USERNAME_EXISTS));
mockMvc.perform(post("/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content(REGISTER_BODY))
mockMvc.perform(post("/api/v1/auth/register").contentType(APPLICATION_JSON).content(REGISTER_BODY))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40900))
.andExpect(jsonPath("$.message").value("用户名已存在"));
@@ -86,8 +125,7 @@ class AuthControllerTest {
@Test
void registerRejectsInvalidPayloadWithoutCallingUserService() throws Exception {
mockMvc.perform(post("/auth/register")
.contentType(MediaType.APPLICATION_JSON)
mockMvc.perform(post("/api/v1/auth/register").contentType(APPLICATION_JSON)
.content("{\"username\":\"ab\",\"password\":\"123\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
@@ -95,8 +133,7 @@ class AuthControllerTest {
@Test
void registerRejectsNonE164Phone() throws Exception {
mockMvc.perform(post("/auth/register")
.contentType(MediaType.APPLICATION_JSON)
mockMvc.perform(post("/api/v1/auth/register").contentType(APPLICATION_JSON)
.content("""
{"username":"alice","password":"secret123","phone":"13800138000"}
"""))
@@ -105,17 +142,17 @@ class AuthControllerTest {
}
@Test
void loginReturnsTokenWhenPasswordVerified() throws Exception {
void loginReturnsTokenPairWhenPasswordVerified() throws Exception {
when(userClient.verifyPassword(any()))
.thenReturn(ApiResponse.success(new VerifyPasswordResponse(USER_ID, "alice", "Alice")));
.thenReturn(ApiResponse.success(new VerifyPasswordResponse(USER_ID, "alice", null)));
when(sessionClient.create(any())).thenReturn(ApiResponse.success(sessionTokens()));
mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(LOGIN_BODY))
mockMvc.perform(post("/api/v1/auth/login").contentType(APPLICATION_JSON).content(LOGIN_BODY))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()));
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()))
.andExpect(jsonPath("$.data.accessToken", matchesPattern(JWT_PATTERN)))
.andExpect(jsonPath("$.data.refreshToken").value("refresh-token-1"));
}
@Test
@@ -123,18 +160,25 @@ class AuthControllerTest {
when(userClient.verifyPassword(any()))
.thenThrow(new BusinessException(ErrorCode.INVALID_CREDENTIALS));
mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(LOGIN_BODY))
mockMvc.perform(post("/api/v1/auth/login").contentType(APPLICATION_JSON).content(LOGIN_BODY))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40100))
.andExpect(jsonPath("$.message").value("用户名或密码错误"));
}
@Test
void loginPropagatesAccountLockAs423() throws Exception {
when(userClient.verifyPassword(any()))
.thenThrow(new BusinessException(ErrorCode.LOGIN_LOCKED));
mockMvc.perform(post("/api/v1/auth/login").contentType(APPLICATION_JSON).content(LOGIN_BODY))
.andExpect(status().is(423))
.andExpect(jsonPath("$.code").value(42300));
}
@Test
void loginRejectsBlankCredentials() throws Exception {
mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
mockMvc.perform(post("/api/v1/auth/login").contentType(APPLICATION_JSON)
.content("{\"username\":\"\",\"password\":\"\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
@@ -148,10 +192,88 @@ class AuthControllerTest {
Response.builder().status(502).request(request).build());
when(userClient.verifyPassword(any())).thenThrow(transportFailure);
mockMvc.perform(post("/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(LOGIN_BODY))
mockMvc.perform(post("/api/v1/auth/login").contentType(APPLICATION_JSON).content(LOGIN_BODY))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value(50300));
}
@Test
void refreshReturnsARotatedTokenPair() throws Exception {
when(sessionClient.refresh(any())).thenReturn(ApiResponse.success(sessionTokens()));
mockMvc.perform(post("/api/v1/auth/refresh").contentType(APPLICATION_JSON)
.content("{\"refreshToken\":\"old-refresh-token\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()))
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
.andExpect(jsonPath("$.data.accessToken", matchesPattern(JWT_PATTERN)))
.andExpect(jsonPath("$.data.refreshToken").value("refresh-token-1"));
}
@Test
void refreshPropagatesInvalidatedTokenAs40102() throws Exception {
when(sessionClient.refresh(any()))
.thenThrow(new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID));
mockMvc.perform(post("/api/v1/auth/refresh").contentType(APPLICATION_JSON)
.content("{\"refreshToken\":\"reused-refresh-token\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40102));
}
@Test
void refreshRejectsMissingToken() throws Exception {
mockMvc.perform(post("/api/v1/auth/refresh").contentType(APPLICATION_JSON).content("{}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@Test
void logoutRevokesTheCurrentSessionWithAValidAccessToken() throws Exception {
when(sessionClient.revoke(any())).thenReturn(ApiResponse.success(null));
String accessToken = jwtSigner.sign(USER_ID, SESSION_ID, "jti-logout").token();
mockMvc.perform(post("/api/v1/auth/logout")
.header("Authorization", "Bearer " + accessToken)
.contentType(APPLICATION_JSON)
.content("{\"refreshToken\":\"refresh-token-1\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
ArgumentCaptor<RevokeSessionRequest> captor = ArgumentCaptor.forClass(RevokeSessionRequest.class);
verify(sessionClient).revoke(captor.capture());
assertThat(captor.getValue().getUserId()).isEqualTo(USER_ID);
assertThat(captor.getValue().getRefreshToken()).isEqualTo("refresh-token-1");
}
@Test
void logoutWithoutAuthorizationHeaderAnswers40101() throws Exception {
mockMvc.perform(post("/api/v1/auth/logout").contentType(APPLICATION_JSON)
.content("{\"refreshToken\":\"refresh-token-1\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
@Test
void logoutWithGarbageAccessTokenAnswers40101() throws Exception {
mockMvc.perform(post("/api/v1/auth/logout")
.header("Authorization", "Bearer not.a.jwt")
.contentType(APPLICATION_JSON)
.content("{\"refreshToken\":\"refresh-token-1\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
@Test
void logoutRejectsMissingRefreshToken() throws Exception {
String accessToken = jwtSigner.sign(USER_ID, SESSION_ID, "jti-logout2").token();
mockMvc.perform(post("/api/v1/auth/logout")
.header("Authorization", "Bearer " + accessToken)
.contentType(APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
}
@@ -0,0 +1,79 @@
package com.patbond.patbond.auth.security;
import com.patbond.patbond.auth.support.TestJwtKeys;
import com.patbond.patbond.common.error.BusinessException;
import io.jsonwebtoken.Claims;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class JwtSignerTest {
private static final UUID USER_ID = UUID.fromString("019212aa-0000-7000-8000-000000000001");
private static final UUID SESSION_ID = UUID.fromString("019212aa-0000-7000-8000-000000000002");
private JwtSigner signer(Duration ttl) {
return new JwtSigner(TestJwtKeys.privatePem(), ttl, "patbond-auth");
}
@Test
void signedTokenCarriesTheExpectedClaimsAndVerifies() {
JwtSigner signer = signer(Duration.ofMinutes(15));
JwtSigner.AccessToken accessToken = signer.sign(USER_ID, SESSION_ID, "jti-123");
Claims claims = signer.verify(accessToken.token());
assertThat(claims.getSubject()).isEqualTo(USER_ID.toString());
assertThat(claims.getId()).isEqualTo("jti-123");
assertThat(claims.get(JwtSigner.SESSION_ID_CLAIM, String.class))
.isEqualTo(SESSION_ID.toString());
assertThat(claims.getIssuer()).isEqualTo("patbond-auth");
assertThat(accessToken.expiresAt())
.isAfter(OffsetDateTime.now(ZoneOffset.UTC).plusMinutes(14))
.isBefore(OffsetDateTime.now(ZoneOffset.UTC).plusMinutes(16));
}
@Test
void expiredTokenIsRejectedWith40101() {
JwtSigner expiredSigner = signer(Duration.ofMinutes(-1));
String token = expiredSigner.sign(USER_ID, SESSION_ID, "jti-exp").token();
assertThatThrownBy(() -> expiredSigner.verify(token))
.isInstanceOf(BusinessException.class)
.satisfies(e -> assertThat(((BusinessException) e).getCode()).isEqualTo(40101));
}
@Test
void tokenSignedWithAForeignKeyIsRejected() {
String forged = new JwtSigner(TestJwtKeys.privatePem(TestJwtKeys.WRONG_KEY_PAIR),
Duration.ofMinutes(15), "patbond-auth")
.sign(USER_ID, SESSION_ID, "jti-forged").token();
assertThatThrownBy(() -> signer(Duration.ofMinutes(15)).verify(forged))
.isInstanceOf(BusinessException.class)
.satisfies(e -> assertThat(((BusinessException) e).getCode()).isEqualTo(40101));
}
@Test
void tamperedTokenIsRejected() {
JwtSigner signer = signer(Duration.ofMinutes(15));
String token = signer.sign(USER_ID, SESSION_ID, "jti-tamper").token();
String[] parts = token.split("\\.");
String tampered = parts[0] + "." + parts[1].substring(0, parts[1].length() - 2) + "aa." + parts[2];
assertThatThrownBy(() -> signer.verify(tampered))
.isInstanceOf(BusinessException.class);
}
@Test
void missingPrivateKeyFailsFastAtConstruction() {
assertThatThrownBy(() -> new JwtSigner("", Duration.ofMinutes(15), "patbond-auth"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("patbond.jwt.private-key");
}
}
@@ -0,0 +1,17 @@
package com.patbond.patbond.auth.support;
/**
* The auth service has no database; JDBC and Flyway are only on the test
* classpath because the end-to-end test boots the real user service in the
* same JVM. Auth-only Spring contexts must exclude their auto-configuration
* or they fail for lack of a DataSource.
*/
public final class SpringTestSupport {
public static final String EXCLUDE_JDBC_AUTOCONFIG = "spring.autoconfigure.exclude="
+ "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,"
+ "org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration";
private SpringTestSupport() {
}
}
@@ -0,0 +1,47 @@
package com.patbond.patbond.auth.support;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
/**
* 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 PEM via {@code @DynamicPropertySource}.
*/
public final class TestJwtKeys {
public static final KeyPair KEY_PAIR = generate();
/** A second pair, for tokens the services must reject. */
public static final KeyPair WRONG_KEY_PAIR = generate();
private TestJwtKeys() {
}
public static String privatePem() {
return privatePem(KEY_PAIR);
}
public static String privatePem(KeyPair keyPair) {
return "-----BEGIN PRIVATE KEY-----\n"
+ Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded())
+ "\n-----END PRIVATE KEY-----";
}
public static String publicPem() {
return "-----BEGIN PUBLIC KEY-----\n"
+ Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded())
+ "\n-----END PUBLIC KEY-----";
}
private static KeyPair generate() {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -1,5 +1,7 @@
# Test-only configuration: keeps @SpringBootTest self-contained on a clean
# checkout, where the git-ignored application.yml does not exist yet.
# checkout, where the git-ignored application.yml does not exist yet. The JWT
# private key is generated at runtime per test class and injected through
# @DynamicPropertySource — no key material is committed.
spring:
application:
name: patbond-auth
@@ -7,3 +9,4 @@ spring:
patbond:
user-service:
url: http://127.0.0.1:8082
internal-token: test-internal-token
@@ -11,9 +11,12 @@ public enum ErrorCode {
VALIDATION_ERROR(40000, 400, "参数校验失败"),
INVALID_CREDENTIALS(40100, 401, "用户名或密码错误"),
TOKEN_INVALID(40101, 401, "token 无效或过期"),
REFRESH_TOKEN_INVALID(40102, 401, "refresh token 已失效或被重用"),
USER_NOT_FOUND(40400, 404, "用户不存在"),
USERNAME_EXISTS(40900, 409, "用户名已存在"),
PHONE_EXISTS(40901, 409, "手机号已被使用"),
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
@@ -0,0 +1,56 @@
package com.patbond.patbond.common.session;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.UUID;
/**
* Internal contract: patbond-auth asks patbond-user (the identity schema
* owner) to open a refresh session for a just-authenticated user. Device
* metadata is optional observability data for the multi-device session list.
*/
public class CreateSessionRequest {
@NotNull(message = "userId 不能为空")
private UUID userId;
@Size(max = 512, message = "userAgent 长度不能超过512位")
private String userAgent;
@Size(max = 45, message = "ipAddress 长度不能超过45位")
private String ipAddress;
public CreateSessionRequest() {
}
public CreateSessionRequest(UUID userId, String userAgent, String ipAddress) {
this.userId = userId;
this.userAgent = userAgent;
this.ipAddress = ipAddress;
}
public UUID getUserId() {
return userId;
}
public void setUserId(UUID userId) {
this.userId = userId;
}
public String getUserAgent() {
return userAgent;
}
public void setUserAgent(String userAgent) {
this.userAgent = userAgent;
}
public String getIpAddress() {
return ipAddress;
}
public void setIpAddress(String ipAddress) {
this.ipAddress = ipAddress;
}
}
@@ -0,0 +1,25 @@
package com.patbond.patbond.common.session;
import jakarta.validation.constraints.NotBlank;
/** Internal contract: rotate a refresh session (ADR-003). */
public class RefreshSessionRequest {
@NotBlank(message = "refreshToken 不能为空")
private String refreshToken;
public RefreshSessionRequest() {
}
public RefreshSessionRequest(String refreshToken) {
this.refreshToken = refreshToken;
}
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
}
@@ -0,0 +1,45 @@
package com.patbond.patbond.common.session;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
/**
* Internal contract: logout — revoke the one session holding this refresh
* token, scoped to the user taken from the verified access token so a token
* from another account cannot be revoked (ADR-003: logout revokes only the
* current session; other devices stay logged in).
*/
public class RevokeSessionRequest {
@NotNull(message = "userId 不能为空")
private UUID userId;
@NotBlank(message = "refreshToken 不能为空")
private String refreshToken;
public RevokeSessionRequest() {
}
public RevokeSessionRequest(UUID userId, String refreshToken) {
this.userId = userId;
this.refreshToken = refreshToken;
}
public UUID getUserId() {
return userId;
}
public void setUserId(UUID userId) {
this.userId = userId;
}
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
}
@@ -0,0 +1,72 @@
package com.patbond.patbond.common.session;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* Internal contract: the session material patbond-user hands back to
* patbond-auth after creating or rotating a session. The refresh token is the
* only plaintext copy that ever exists (the database stores its SHA-256
* digest); the jti is minted here so auth can embed it in the access token
* it signs, matching auth_sessions.access_token_jti without a second call.
*/
public class SessionTokens {
private UUID sessionId;
private UUID userId;
private String jti;
private String refreshToken;
private OffsetDateTime refreshTokenExpiresAt;
public SessionTokens() {
}
public SessionTokens(UUID sessionId, UUID userId, String jti,
String refreshToken, OffsetDateTime refreshTokenExpiresAt) {
this.sessionId = sessionId;
this.userId = userId;
this.jti = jti;
this.refreshToken = refreshToken;
this.refreshTokenExpiresAt = refreshTokenExpiresAt;
}
public UUID getSessionId() {
return sessionId;
}
public void setSessionId(UUID sessionId) {
this.sessionId = sessionId;
}
public UUID getUserId() {
return userId;
}
public void setUserId(UUID userId) {
this.userId = userId;
}
public String getJti() {
return jti;
}
public void setJti(String jti) {
this.jti = jti;
}
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
public OffsetDateTime getRefreshTokenExpiresAt() {
return refreshTokenExpiresAt;
}
public void setRefreshTokenExpiresAt(OffsetDateTime refreshTokenExpiresAt) {
this.refreshTokenExpiresAt = refreshTokenExpiresAt;
}
}
+19
View File
@@ -52,6 +52,25 @@
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Access token verification (RS256, public key only): jjwt is not in
the Boot BOM, version pinned here and in patbond-auth in step. -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -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:
#
@@ -7,7 +7,7 @@ import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
/**
* Shared Testcontainers setup: a disposable postgres:16 (the production
* Shared Testcontainers setup: a disposable postgres:18 (the production
* target version) wired into the Spring context via @ServiceConnection.
* Flyway runs the real migrations against it on context startup, so every
* @SpringBootTest in this module exercises the V1 baseline on a clean
@@ -19,6 +19,6 @@ public class TestcontainersConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16"));
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:18"));
}
}
@@ -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));
}
@@ -24,7 +24,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Verifies the Flyway V1 baseline and the real persistence semantics against
* a clean postgres:16 container: migrations apply, constraints hold, and a
* a clean postgres:18 container: migrations apply, constraints hold, and a
* registered user is durably stored in PostgreSQL (readable over a fresh raw
* JDBC connection, i.e. independent of any application-process memory the
* restart-survival semantics that killed the old ConcurrentHashMap storage).
@@ -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