feat: JWT RS256 + refresh 会话轮换与 /api/v1 契约落地(ADR-003)

- 会话逻辑下沉 patbond-user(新 /internal/sessions;auth_sessions 只存 SHA-256 摘要,刷新即轮换并链 token_family,重用撤销整个 family,退出仅撤当前会话,多设备并行)
- patbond-auth 作薄入口签发 RS256 JWT(access 15m / refresh 30d 均为配置项;密钥经环境变量注入,仓库零密钥材料,测试密钥运行时生成);公开端点迁至 /api/v1,冻结契约字段零偏差,expiresAt 无时区遗留修复
- /internal/** 加 X-Internal-Token 服务间鉴权(无凭证 401);/api/v1/me 由 user 以公钥本地验签(40101/40102 新错误码)
- 登录失败限制:按用户名 15 分钟窗口 5 次锁 15 分钟(423/42300,DB 原子计数,可配置)
- 修复两处存量缺陷:ErrorDecoder 未注册进 Feign 子上下文、JDK HttpURLConnection 对流式 POST 的 401 读不到错误体(引入 feign-hc5)——真实调用中下游错误码此前一律折叠为 503
- 门禁:JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test → BUILD SUCCESS,73 测试 0 失败(37→73),含同 JVM 双服务真实 HTTP E2E:注册→me→刷新→旧 refresh 重用被拒且 family 撤销→退出后 refresh 失效;Testcontainers postgres:18,无遗留容器

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 12:15:40 +08:00
parent 43ab6c5827
commit 4dc3dcdfa3
51 changed files with 2768 additions and 151 deletions
+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