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:
@@ -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);
|
||||
}
|
||||
|
||||
+69
@@ -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-003:access 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();
|
||||
}
|
||||
}
|
||||
+169
-47
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user