From 4dc3dcdfa38ca061ab2199736e107c91087db9f2 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 4 Sep 2026 12:15:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20JWT=20RS256=20+=20refresh=20=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E8=BD=AE=E6=8D=A2=E4=B8=8E=20/api/v1=20=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E8=90=BD=E5=9C=B0=EF=BC=88ADR-003=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 会话逻辑下沉 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 --- Readme.md | 53 +++- patbond-auth/pom.xml | 41 +++ .../patbond/patbond/auth/AuthApplication.java | 3 +- .../patbond/auth/client/SessionClient.java | 27 ++ .../patbond/auth/config/ApiErrorDecoder.java | 10 +- .../auth/config/AuthSecurityProperties.java | 69 +++++ .../patbond/auth/config/FeignConfig.java | 15 -- .../auth/config/FeignInternalConfig.java | 33 +++ .../patbond/auth/config/SecurityConfig.java | 23 ++ .../auth/controller/AuthController.java | 43 ++- .../patbond/auth/dto/AuthTokenResponse.java | 48 ++-- .../patbond/auth/dto/LogoutRequest.java | 17 ++ .../patbond/auth/dto/RefreshRequest.java | 17 ++ .../patbond/auth/security/JwtSigner.java | 76 ++++++ .../auth/security/RsaPrivateKeyLoader.java | 59 +++++ .../patbond/auth/service/AuthService.java | 95 +++++-- .../src/main/resources/application.yml.sample | 15 ++ .../patbond/auth/AuthApplicationTests.java | 18 +- .../patbond/auth/AuthE2eIntegrationTest.java | 247 ++++++++++++++++++ .../auth/controller/AuthControllerTest.java | 216 +++++++++++---- .../patbond/auth/security/JwtSignerTest.java | 79 ++++++ .../auth/support/SpringTestSupport.java | 17 ++ .../patbond/auth/support/TestJwtKeys.java | 47 ++++ .../src/test/resources/application.yml | 5 +- .../patbond/common/error/ErrorCode.java | 3 + .../common/session/CreateSessionRequest.java | 56 ++++ .../common/session/RefreshSessionRequest.java | 25 ++ .../common/session/RevokeSessionRequest.java | 45 ++++ .../patbond/common/session/SessionTokens.java | 72 +++++ patbond-user/pom.xml | 19 ++ .../patbond/user/config/SecurityConfig.java | 45 ++++ .../user/config/UserSecurityProperties.java | 111 ++++++++ .../patbond/user/controller/MeController.java | 34 +++ .../patbond/patbond/user/dto/MeResponse.java | 11 + .../user/repository/UserRepository.java | 58 +++- .../user/security/BearerAuthFilter.java | 77 ++++++ .../user/security/InternalAuthFilter.java | 70 +++++ .../patbond/user/security/JwtVerifier.java | 41 +++ .../user/security/RsaPublicKeyLoader.java | 46 ++++ .../patbond/user/service/UserService.java | 24 +- .../user/session/SessionController.java | 43 +++ .../user/session/SessionRepository.java | 108 ++++++++ .../patbond/user/session/SessionService.java | 122 +++++++++ .../src/main/resources/application.yml.sample | 21 ++ .../user/controller/MeEndpointTest.java | 109 ++++++++ .../user/controller/UserControllerTest.java | 58 ++-- .../user/security/InternalAuthFilterTest.java | 60 +++++ .../service/LoginLockoutIntegrationTest.java | 107 ++++++++ .../SessionLifecycleIntegrationTest.java | 211 +++++++++++++++ .../patbond/user/support/TestJwtKeys.java | 59 +++++ .../src/test/resources/application.yml | 11 + 51 files changed, 2768 insertions(+), 151 deletions(-) create mode 100644 patbond-auth/src/main/java/com/patbond/patbond/auth/client/SessionClient.java create mode 100644 patbond-auth/src/main/java/com/patbond/patbond/auth/config/AuthSecurityProperties.java delete mode 100644 patbond-auth/src/main/java/com/patbond/patbond/auth/config/FeignConfig.java create mode 100644 patbond-auth/src/main/java/com/patbond/patbond/auth/config/FeignInternalConfig.java create mode 100644 patbond-auth/src/main/java/com/patbond/patbond/auth/config/SecurityConfig.java create mode 100644 patbond-auth/src/main/java/com/patbond/patbond/auth/dto/LogoutRequest.java create mode 100644 patbond-auth/src/main/java/com/patbond/patbond/auth/dto/RefreshRequest.java create mode 100644 patbond-auth/src/main/java/com/patbond/patbond/auth/security/JwtSigner.java create mode 100644 patbond-auth/src/main/java/com/patbond/patbond/auth/security/RsaPrivateKeyLoader.java create mode 100644 patbond-auth/src/test/java/com/patbond/patbond/auth/AuthE2eIntegrationTest.java create mode 100644 patbond-auth/src/test/java/com/patbond/patbond/auth/security/JwtSignerTest.java create mode 100644 patbond-auth/src/test/java/com/patbond/patbond/auth/support/SpringTestSupport.java create mode 100644 patbond-auth/src/test/java/com/patbond/patbond/auth/support/TestJwtKeys.java create mode 100644 patbond-common/src/main/java/com/patbond/patbond/common/session/CreateSessionRequest.java create mode 100644 patbond-common/src/main/java/com/patbond/patbond/common/session/RefreshSessionRequest.java create mode 100644 patbond-common/src/main/java/com/patbond/patbond/common/session/RevokeSessionRequest.java create mode 100644 patbond-common/src/main/java/com/patbond/patbond/common/session/SessionTokens.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/config/SecurityConfig.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/config/UserSecurityProperties.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/controller/MeController.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/dto/MeResponse.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/security/BearerAuthFilter.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/security/InternalAuthFilter.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/security/JwtVerifier.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/security/RsaPublicKeyLoader.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/session/SessionController.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/session/SessionRepository.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/session/SessionService.java create mode 100644 patbond-user/src/test/java/com/patbond/patbond/user/controller/MeEndpointTest.java create mode 100644 patbond-user/src/test/java/com/patbond/patbond/user/security/InternalAuthFilterTest.java create mode 100644 patbond-user/src/test/java/com/patbond/patbond/user/service/LoginLockoutIntegrationTest.java create mode 100644 patbond-user/src/test/java/com/patbond/patbond/user/session/SessionLifecycleIntegrationTest.java create mode 100644 patbond-user/src/test/java/com/patbond/patbond/user/support/TestJwtKeys.java create mode 100644 patbond-user/src/test/resources/application.yml diff --git a/Readme.md b/Readme.md index e1516d9..e886138 100644 --- a/Readme.md +++ b/Readme.md @@ -13,7 +13,7 @@ Patbond API is a Spring Boot multi-module backend. - Java 17 (build baseline; use JDK 17 for release builds) - Spring Boot 3.5.16 - Spring Cloud 2025.0.3 (OpenFeign only) -- PostgreSQL 16 + Flyway (patbond-user owns the `identity`/`media` schemas) +- PostgreSQL 18 + Flyway (patbond-user owns the `identity`/`media` schemas) - Maven (use the committed Maven Wrapper `./mvnw`) ## Build and Test @@ -22,7 +22,7 @@ Patbond API is a Spring Boot multi-module backend. ./mvnw clean test ``` -Integration tests start a disposable `postgres:16` via Testcontainers, so a +Integration tests start a disposable `postgres:18` via Testcontainers, so a running Docker daemon is required (no local PostgreSQL installation or credentials are needed). @@ -34,7 +34,7 @@ JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test ## Run -Running `patbond-user` requires a reachable PostgreSQL 16 database; Flyway +Running `patbond-user` requires a reachable PostgreSQL 18 database; Flyway applies the versioned migrations in `patbond-user/src/main/resources/db/migration` automatically on startup. Development seed data (`db/dev`, regions reference rows) is opt-in via a dev @@ -59,13 +59,24 @@ Install the shared module once, then run each service in its own terminal: ./mvnw -pl patbond-auth spring-boot:run ``` +Running the services also needs an RS256 key pair for access tokens (the +private key signs in `patbond-auth`, the public key verifies in +`patbond-user`; neither is committed): + +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt-private.pem +openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem +export PATBOND_JWT_PRIVATE_KEY=$PWD/jwt-private.pem # patbond-auth +export PATBOND_JWT_PUBLIC_KEY=$PWD/jwt-public.pem # patbond-user +``` + Tests do not require this copy step — `patbond-auth/src/test/resources/application.yml` keeps `./mvnw clean test` self-contained on a clean checkout. Smoke check: ```bash -curl -X POST http://127.0.0.1:8081/auth/register \ +curl -X POST http://127.0.0.1:8081/api/v1/auth/register \ -H 'Content-Type: application/json' \ -d '{"username":"demo_user","password":"secret123"}' ``` @@ -80,6 +91,14 @@ curl -X POST http://127.0.0.1:8081/auth/register \ | `PATBOND_DB_URL` | `jdbc:postgresql://127.0.0.1:5432/patbond` | patbond-user | | `PATBOND_DB_USER` | `patbond` | patbond-user | | `PATBOND_DB_PASSWORD` | `patbond` | patbond-user | +| `PATBOND_INTERNAL_TOKEN` | `dev-only-internal-token` | both (shared secret for `/internal/**`; inject a strong random value outside local dev) | +| `PATBOND_JWT_PRIVATE_KEY` | _(none, required)_ | patbond-auth (RS256 private key: PEM path or inline PEM) | +| `PATBOND_JWT_PUBLIC_KEY` | _(none)_ | patbond-user (RS256 public key: PEM path or inline PEM) | +| `PATBOND_ACCESS_TTL` | `15m` | patbond-auth (access token lifetime, ADR-003) | +| `PATBOND_REFRESH_TTL` | `30d` | patbond-user (refresh token lifetime, ADR-003) | +| `PATBOND_LOGIN_LOCK_MAX_FAILURES` | `5` | patbond-user (login failures before lockout) | +| `PATBOND_LOGIN_LOCK_WINDOW` | `15m` | patbond-user (failure counting window) | +| `PATBOND_LOGIN_LOCK_DURATION` | `15m` | patbond-user (lock duration) | Machine-specific values live in the git-ignored `application.yml` (copied from the committed `.sample`); never commit secrets to the samples. @@ -90,21 +109,27 @@ committed `.sample`); never commit secrets to the samples. - Application name: `patbond-auth` - Default port: `8081` -- Register: `POST /auth/register` -- Login: `POST /auth/login` +- Register: `POST /api/v1/auth/register` +- Login: `POST /api/v1/auth/login` +- Refresh (rotates the refresh token): `POST /api/v1/auth/refresh` +- Logout (revokes the current session): `POST /api/v1/auth/logout` ### User Service - Application name: `patbond-user` - Default port: `8082` -- Create user: `POST /internal/users` -- Verify password: `POST /internal/users/verify-password` -- Get user by id: `GET /internal/users/{id}` -- Get user by username: `GET /internal/users/by-username/{username}` +- Current user profile: `GET /api/v1/me` (Bearer access token, verified locally with the RS256 public key) +- Internal (require `X-Internal-Token`): + - Create user: `POST /internal/users` + - Verify password: `POST /internal/users/verify-password` + - Get user by id: `GET /internal/users/{id}` + - Get user by username: `GET /internal/users/by-username/{username}` + - Sessions: `POST /internal/sessions`, `POST /internal/sessions/refresh`, `POST /internal/sessions/revoke` > Note: user data is persisted in PostgreSQL (`identity.users` / > `identity.user_credentials`, bcrypt password hashes, UUIDv7 ids generated in -> the application). Errors follow the `{code, message, data}` envelope with -> stable business codes and matching HTTP statuses. Verifiable JWT tokens, -> refresh sessions, and `/internal` access control are planned in iteration 1 -> follow-up tasks. +> the application). Refresh sessions live in `identity.auth_sessions` (SHA-256 +> digests only, rotation on every refresh, token-family revocation on reuse, +> ADR-003). Errors follow the `{code, message, data}` envelope with stable +> business codes and matching HTTP statuses; the public contract is documented +> in `patbond-doc/docs/api/openapi.yaml`. diff --git a/patbond-auth/pom.xml b/patbond-auth/pom.xml index 4d469f4..370769a 100644 --- a/patbond-auth/pom.xml +++ b/patbond-auth/pom.xml @@ -35,11 +35,52 @@ org.springframework.cloud spring-cloud-starter-openfeign + + + io.github.openfeign + feign-hc5 + + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + org.springframework.boot spring-boot-starter-test test + + + com.patbond.patbond + patbond-user + ${project.version} + test + + + org.testcontainers + postgresql + test + diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/AuthApplication.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/AuthApplication.java index 7ac9bba..4515677 100644 --- a/patbond-auth/src/main/java/com/patbond/patbond/auth/AuthApplication.java +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/AuthApplication.java @@ -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 { diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/client/SessionClient.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/client/SessionClient.java new file mode 100644 index 0000000..649ced0 --- /dev/null +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/client/SessionClient.java @@ -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 create(@RequestBody CreateSessionRequest request); + + @PostMapping("/internal/sessions/refresh") + ApiResponse refresh(@RequestBody RefreshSessionRequest request); + + @PostMapping("/internal/sessions/revoke") + ApiResponse revoke(@RequestBody RevokeSessionRequest request); +} diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/config/ApiErrorDecoder.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/config/ApiErrorDecoder.java index 359f513..8fa3162 100644 --- a/patbond-auth/src/main/java/com/patbond/patbond/auth/config/ApiErrorDecoder.java +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/config/ApiErrorDecoder.java @@ -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); } diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/config/AuthSecurityProperties.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/config/AuthSecurityProperties.java new file mode 100644 index 0000000..d45701f --- /dev/null +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/config/AuthSecurityProperties.java @@ -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; + } + } +} diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/config/FeignConfig.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/config/FeignConfig.java deleted file mode 100644 index 535605a..0000000 --- a/patbond-auth/src/main/java/com/patbond/patbond/auth/config/FeignConfig.java +++ /dev/null @@ -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); - } -} diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/config/FeignInternalConfig.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/config/FeignInternalConfig.java new file mode 100644 index 0000000..d24e760 --- /dev/null +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/config/FeignInternalConfig.java @@ -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()); + } +} diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/config/SecurityConfig.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/config/SecurityConfig.java new file mode 100644 index 0000000..5f328b1 --- /dev/null +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/config/SecurityConfig.java @@ -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()); + } +} diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/controller/AuthController.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/controller/AuthController.java index 39fc136..ed97b6b 100644 --- a/patbond-auth/src/main/java/com/patbond/patbond/auth/controller/AuthController.java +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/controller/AuthController.java @@ -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 register(@Valid @RequestBody RegisterRequest request) { - return ApiResponse.success(authService.register(request)); + public ApiResponse register(@Valid @RequestBody RegisterRequest request, + HttpServletRequest httpRequest) { + return ApiResponse.success(authService.register(request, clientInfo(httpRequest))); } @PostMapping("/login") - public ApiResponse login(@Valid @RequestBody LoginRequest request) { - return ApiResponse.success(authService.login(request)); + public ApiResponse login(@Valid @RequestBody LoginRequest request, + HttpServletRequest httpRequest) { + return ApiResponse.success(authService.login(request, clientInfo(httpRequest))); + } + + @PostMapping("/refresh") + public ApiResponse refresh(@Valid @RequestBody RefreshRequest request) { + return ApiResponse.success(authService.refresh(request)); + } + + @PostMapping("/logout") + public ApiResponse 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); } } diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/AuthTokenResponse.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/AuthTokenResponse.java index 0800cf7..0dc43b7 100644 --- a/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/AuthTokenResponse.java +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/AuthTokenResponse.java @@ -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; } } diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/LogoutRequest.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/LogoutRequest.java new file mode 100644 index 0000000..b2e357e --- /dev/null +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/LogoutRequest.java @@ -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; + } +} diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/RefreshRequest.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/RefreshRequest.java new file mode 100644 index 0000000..17cdd96 --- /dev/null +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/dto/RefreshRequest.java @@ -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; + } +} diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/security/JwtSigner.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/security/JwtSigner.java new file mode 100644 index 0000000..6a17470 --- /dev/null +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/security/JwtSigner.java @@ -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); + } + } +} diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/security/RsaPrivateKeyLoader.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/security/RsaPrivateKeyLoader.java new file mode 100644 index 0000000..9c96614 --- /dev/null +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/security/RsaPrivateKeyLoader.java @@ -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); + } + } +} diff --git a/patbond-auth/src/main/java/com/patbond/patbond/auth/service/AuthService.java b/patbond-auth/src/main/java/com/patbond/patbond/auth/service/AuthService.java index 4aec25f..addc191 100644 --- a/patbond-auth/src/main/java/com/patbond/patbond/auth/service/AuthService.java +++ b/patbond-auth/src/main/java/com/patbond/patbond/auth/service/AuthService.java @@ -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 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 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 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); + } + } } diff --git a/patbond-auth/src/main/resources/application.yml.sample b/patbond-auth/src/main/resources/application.yml.sample index 98e9ea5..81002e9 100644 --- a/patbond-auth/src/main/resources/application.yml.sample +++ b/patbond-auth/src/main/resources/application.yml.sample @@ -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 diff --git a/patbond-auth/src/test/java/com/patbond/patbond/auth/AuthApplicationTests.java b/patbond-auth/src/test/java/com/patbond/patbond/auth/AuthApplicationTests.java index b0dd282..a1c6194 100644 --- a/patbond-auth/src/test/java/com/patbond/patbond/auth/AuthApplicationTests.java +++ b/patbond-auth/src/test/java/com/patbond/patbond/auth/AuthApplicationTests.java @@ -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). } } diff --git a/patbond-auth/src/test/java/com/patbond/patbond/auth/AuthE2eIntegrationTest.java b/patbond-auth/src/test/java/com/patbond/patbond/auth/AuthE2eIntegrationTest.java new file mode 100644 index 0000000..af8891c --- /dev/null +++ b/patbond-auth/src/test/java/com/patbond/patbond/auth/AuthE2eIntegrationTest.java @@ -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 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 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 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 login(String username, String password) { + return postJson("/api/v1/auth/login", + "{\"username\":\"%s\",\"password\":\"%s\"}".formatted(username, password), null); + } + + private ResponseEntity 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 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 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 reuse = refresh(refreshToken); + assertThat(reuse.getStatusCode().value()).isEqualTo(401); + assertThat((int) JsonPath.read(reuse.getBody(), "$.code")).isEqualTo(40102); + + // … including the freshly rotated token. + ResponseEntity 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 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 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 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 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 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 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 wrong = login("e2e_carol", "wrong-password"); + assertThat(wrong.getStatusCode().value()).isEqualTo(401); + assertThat((int) JsonPath.read(wrong.getBody(), "$.code")).isEqualTo(40100); + } + ResponseEntity 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 device2 = login("e2e_dave", "secret123"); + String access2 = JsonPath.read(device2.getBody(), "$.data.accessToken"); + String refresh2 = JsonPath.read(device2.getBody(), "$.data.refreshToken"); + + ResponseEntity 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 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(); + } +} diff --git a/patbond-auth/src/test/java/com/patbond/patbond/auth/controller/AuthControllerTest.java b/patbond-auth/src/test/java/com/patbond/patbond/auth/controller/AuthControllerTest.java index 752f234..8153852 100644 --- a/patbond-auth/src/test/java/com/patbond/patbond/auth/controller/AuthControllerTest.java +++ b/patbond-auth/src/test/java/com/patbond/patbond/auth/controller/AuthControllerTest.java @@ -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 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)); + } } diff --git a/patbond-auth/src/test/java/com/patbond/patbond/auth/security/JwtSignerTest.java b/patbond-auth/src/test/java/com/patbond/patbond/auth/security/JwtSignerTest.java new file mode 100644 index 0000000..47d7f5e --- /dev/null +++ b/patbond-auth/src/test/java/com/patbond/patbond/auth/security/JwtSignerTest.java @@ -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"); + } +} diff --git a/patbond-auth/src/test/java/com/patbond/patbond/auth/support/SpringTestSupport.java b/patbond-auth/src/test/java/com/patbond/patbond/auth/support/SpringTestSupport.java new file mode 100644 index 0000000..bbff435 --- /dev/null +++ b/patbond-auth/src/test/java/com/patbond/patbond/auth/support/SpringTestSupport.java @@ -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() { + } +} diff --git a/patbond-auth/src/test/java/com/patbond/patbond/auth/support/TestJwtKeys.java b/patbond-auth/src/test/java/com/patbond/patbond/auth/support/TestJwtKeys.java new file mode 100644 index 0000000..91a1a54 --- /dev/null +++ b/patbond-auth/src/test/java/com/patbond/patbond/auth/support/TestJwtKeys.java @@ -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); + } + } +} diff --git a/patbond-auth/src/test/resources/application.yml b/patbond-auth/src/test/resources/application.yml index f870bba..79e98c5 100644 --- a/patbond-auth/src/test/resources/application.yml +++ b/patbond-auth/src/test/resources/application.yml @@ -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 diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java index 8b7cecf..7a9a059 100644 --- a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java +++ b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java @@ -11,9 +11,12 @@ public enum ErrorCode { VALIDATION_ERROR(40000, 400, "参数校验失败"), INVALID_CREDENTIALS(40100, 401, "用户名或密码错误"), + TOKEN_INVALID(40101, 401, "token 无效或过期"), + REFRESH_TOKEN_INVALID(40102, 401, "refresh token 已失效或被重用"), USER_NOT_FOUND(40400, 404, "用户不存在"), USERNAME_EXISTS(40900, 409, "用户名已存在"), PHONE_EXISTS(40901, 409, "手机号已被使用"), + LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"), INTERNAL_ERROR(50000, 500, "服务器内部错误"), DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用"); diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/session/CreateSessionRequest.java b/patbond-common/src/main/java/com/patbond/patbond/common/session/CreateSessionRequest.java new file mode 100644 index 0000000..ac81775 --- /dev/null +++ b/patbond-common/src/main/java/com/patbond/patbond/common/session/CreateSessionRequest.java @@ -0,0 +1,56 @@ +package com.patbond.patbond.common.session; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.UUID; + +/** + * Internal contract: patbond-auth asks patbond-user (the identity schema + * owner) to open a refresh session for a just-authenticated user. Device + * metadata is optional observability data for the multi-device session list. + */ +public class CreateSessionRequest { + + @NotNull(message = "userId 不能为空") + private UUID userId; + + @Size(max = 512, message = "userAgent 长度不能超过512位") + private String userAgent; + + @Size(max = 45, message = "ipAddress 长度不能超过45位") + private String ipAddress; + + public CreateSessionRequest() { + } + + public CreateSessionRequest(UUID userId, String userAgent, String ipAddress) { + this.userId = userId; + this.userAgent = userAgent; + this.ipAddress = ipAddress; + } + + public UUID getUserId() { + return userId; + } + + public void setUserId(UUID userId) { + this.userId = userId; + } + + public String getUserAgent() { + return userAgent; + } + + public void setUserAgent(String userAgent) { + this.userAgent = userAgent; + } + + public String getIpAddress() { + return ipAddress; + } + + public void setIpAddress(String ipAddress) { + this.ipAddress = ipAddress; + } +} diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/session/RefreshSessionRequest.java b/patbond-common/src/main/java/com/patbond/patbond/common/session/RefreshSessionRequest.java new file mode 100644 index 0000000..12d5239 --- /dev/null +++ b/patbond-common/src/main/java/com/patbond/patbond/common/session/RefreshSessionRequest.java @@ -0,0 +1,25 @@ +package com.patbond.patbond.common.session; + +import jakarta.validation.constraints.NotBlank; + +/** Internal contract: rotate a refresh session (ADR-003). */ +public class RefreshSessionRequest { + + @NotBlank(message = "refreshToken 不能为空") + private String refreshToken; + + public RefreshSessionRequest() { + } + + public RefreshSessionRequest(String refreshToken) { + this.refreshToken = refreshToken; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } +} diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/session/RevokeSessionRequest.java b/patbond-common/src/main/java/com/patbond/patbond/common/session/RevokeSessionRequest.java new file mode 100644 index 0000000..41fa5d4 --- /dev/null +++ b/patbond-common/src/main/java/com/patbond/patbond/common/session/RevokeSessionRequest.java @@ -0,0 +1,45 @@ +package com.patbond.patbond.common.session; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.util.UUID; + +/** + * Internal contract: logout — revoke the one session holding this refresh + * token, scoped to the user taken from the verified access token so a token + * from another account cannot be revoked (ADR-003: logout revokes only the + * current session; other devices stay logged in). + */ +public class RevokeSessionRequest { + + @NotNull(message = "userId 不能为空") + private UUID userId; + + @NotBlank(message = "refreshToken 不能为空") + private String refreshToken; + + public RevokeSessionRequest() { + } + + public RevokeSessionRequest(UUID userId, String refreshToken) { + this.userId = userId; + this.refreshToken = refreshToken; + } + + public UUID getUserId() { + return userId; + } + + public void setUserId(UUID userId) { + this.userId = userId; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } +} diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/session/SessionTokens.java b/patbond-common/src/main/java/com/patbond/patbond/common/session/SessionTokens.java new file mode 100644 index 0000000..4c817fb --- /dev/null +++ b/patbond-common/src/main/java/com/patbond/patbond/common/session/SessionTokens.java @@ -0,0 +1,72 @@ +package com.patbond.patbond.common.session; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Internal contract: the session material patbond-user hands back to + * patbond-auth after creating or rotating a session. The refresh token is the + * only plaintext copy that ever exists (the database stores its SHA-256 + * digest); the jti is minted here so auth can embed it in the access token + * it signs, matching auth_sessions.access_token_jti without a second call. + */ +public class SessionTokens { + + private UUID sessionId; + private UUID userId; + private String jti; + private String refreshToken; + private OffsetDateTime refreshTokenExpiresAt; + + public SessionTokens() { + } + + public SessionTokens(UUID sessionId, UUID userId, String jti, + String refreshToken, OffsetDateTime refreshTokenExpiresAt) { + this.sessionId = sessionId; + this.userId = userId; + this.jti = jti; + this.refreshToken = refreshToken; + this.refreshTokenExpiresAt = refreshTokenExpiresAt; + } + + public UUID getSessionId() { + return sessionId; + } + + public void setSessionId(UUID sessionId) { + this.sessionId = sessionId; + } + + public UUID getUserId() { + return userId; + } + + public void setUserId(UUID userId) { + this.userId = userId; + } + + public String getJti() { + return jti; + } + + public void setJti(String jti) { + this.jti = jti; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public OffsetDateTime getRefreshTokenExpiresAt() { + return refreshTokenExpiresAt; + } + + public void setRefreshTokenExpiresAt(OffsetDateTime refreshTokenExpiresAt) { + this.refreshTokenExpiresAt = refreshTokenExpiresAt; + } +} diff --git a/patbond-user/pom.xml b/patbond-user/pom.xml index e62c96a..8fb5158 100644 --- a/patbond-user/pom.xml +++ b/patbond-user/pom.xml @@ -52,6 +52,25 @@ postgresql runtime + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + org.springframework.boot spring-boot-starter-test diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/config/SecurityConfig.java b/patbond-user/src/main/java/com/patbond/patbond/user/config/SecurityConfig.java new file mode 100644 index 0000000..359cf61 --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/config/SecurityConfig.java @@ -0,0 +1,45 @@ +package com.patbond.patbond.user.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.patbond.patbond.user.security.BearerAuthFilter; +import com.patbond.patbond.user.security.InternalAuthFilter; +import com.patbond.patbond.user.security.JwtVerifier; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Wires the two servlet filters guarding this service without pulling in + * spring-security: /internal/** requires the shared service secret, + * /api/v1/** requires a valid RS256 access token. + */ +@Configuration +@EnableConfigurationProperties(UserSecurityProperties.class) +public class SecurityConfig { + + @Bean + public JwtVerifier jwtVerifier(UserSecurityProperties properties) { + return new JwtVerifier(properties.getJwt().getPublicKey()); + } + + @Bean + public FilterRegistrationBean internalAuthFilter( + UserSecurityProperties properties, ObjectMapper objectMapper) { + FilterRegistrationBean registration = new FilterRegistrationBean<>( + new InternalAuthFilter(properties.getInternalToken(), objectMapper)); + registration.addUrlPatterns("/internal/*"); + registration.setOrder(10); + return registration; + } + + @Bean + public FilterRegistrationBean bearerAuthFilter( + JwtVerifier jwtVerifier, ObjectMapper objectMapper) { + FilterRegistrationBean registration = new FilterRegistrationBean<>( + new BearerAuthFilter(jwtVerifier, objectMapper)); + registration.addUrlPatterns("/api/v1/*"); + registration.setOrder(20); + return registration; + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/config/UserSecurityProperties.java b/patbond-user/src/main/java/com/patbond/patbond/user/config/UserSecurityProperties.java new file mode 100644 index 0000000..6a5e3c4 --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/config/UserSecurityProperties.java @@ -0,0 +1,111 @@ +package com.patbond.patbond.user.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +/** + * Security-related knobs of the user service. ADR-003 mandates that the + * refresh TTL and the login-failure limits are configuration, not constants; + * the committed defaults are the ADR values (refresh 30 days) and the login + * lockout policy documented in openapi.yaml (5 failures / 15 min window / + * 15 min lock). + */ +@ConfigurationProperties(prefix = "patbond") +public class UserSecurityProperties { + + /** Shared secret expected in X-Internal-Token on every /internal/** call. */ + private String internalToken; + + private final Jwt jwt = new Jwt(); + private final Session session = new Session(); + private final LoginLock loginLock = new LoginLock(); + + public String getInternalToken() { + return internalToken; + } + + public void setInternalToken(String internalToken) { + this.internalToken = internalToken; + } + + public Jwt getJwt() { + return jwt; + } + + public Session getSession() { + return session; + } + + public LoginLock getLoginLock() { + return loginLock; + } + + public static class Jwt { + + /** + * RS256 public key for verifying access tokens signed by + * patbond-auth: either inline PEM (starts with -----BEGIN) or a + * filesystem path. The private key never reaches this service. + */ + private String publicKey; + + public String getPublicKey() { + return publicKey; + } + + public void setPublicKey(String publicKey) { + this.publicKey = publicKey; + } + } + + public static class Session { + + /** Refresh token lifetime (ADR-003: 30 days). */ + private Duration refreshTtl = Duration.ofDays(30); + + public Duration getRefreshTtl() { + return refreshTtl; + } + + public void setRefreshTtl(Duration refreshTtl) { + this.refreshTtl = refreshTtl; + } + } + + public static class LoginLock { + + /** Failures within the window that trigger a lock. */ + private int maxFailures = 5; + + /** Sliding window in which failures accumulate. */ + private Duration failureWindow = Duration.ofMinutes(15); + + /** How long the account stays locked once triggered. */ + private Duration lockDuration = Duration.ofMinutes(15); + + public int getMaxFailures() { + return maxFailures; + } + + public void setMaxFailures(int maxFailures) { + this.maxFailures = maxFailures; + } + + public Duration getFailureWindow() { + return failureWindow; + } + + public void setFailureWindow(Duration failureWindow) { + this.failureWindow = failureWindow; + } + + public Duration getLockDuration() { + return lockDuration; + } + + public void setLockDuration(Duration lockDuration) { + this.lockDuration = lockDuration; + } + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/controller/MeController.java b/patbond-user/src/main/java/com/patbond/patbond/user/controller/MeController.java new file mode 100644 index 0000000..f6f0600 --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/controller/MeController.java @@ -0,0 +1,34 @@ +package com.patbond.patbond.user.controller; + +import com.patbond.patbond.common.response.ApiResponse; +import com.patbond.patbond.common.user.UserProfile; +import com.patbond.patbond.user.dto.MeResponse; +import com.patbond.patbond.user.security.BearerAuthFilter; +import com.patbond.patbond.user.service.UserService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** + * Public profile endpoint. Authentication happens in BearerAuthFilter (RS256 + * verification against the auth service's public key); by the time this + * controller runs, the user id attribute is guaranteed to be present. + */ +@RestController +public class MeController { + + private final UserService userService; + + public MeController(UserService userService) { + this.userService = userService; + } + + @GetMapping("/api/v1/me") + public ApiResponse me(@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) { + UserProfile profile = userService.getById(userId); + return ApiResponse.success(new MeResponse( + profile.getId(), profile.getUsername(), profile.getPhone(), profile.getCreatedAt())); + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/dto/MeResponse.java b/patbond-user/src/main/java/com/patbond/patbond/user/dto/MeResponse.java new file mode 100644 index 0000000..0532226 --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/dto/MeResponse.java @@ -0,0 +1,11 @@ +package com.patbond.patbond.user.dto; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Public /api/v1/me payload — exactly the frozen contract fields + * {userId, username, phone, createdAt}; nothing else leaks out. + */ +public record MeResponse(UUID userId, String username, String phone, OffsetDateTime createdAt) { +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/repository/UserRepository.java b/patbond-user/src/main/java/com/patbond/patbond/user/repository/UserRepository.java index fcc245e..12819f6 100644 --- a/patbond-user/src/main/java/com/patbond/patbond/user/repository/UserRepository.java +++ b/patbond-user/src/main/java/com/patbond/patbond/user/repository/UserRepository.java @@ -32,7 +32,8 @@ public class UserRepository { public record UserRow(UUID id, String username, String nickname, String phone, OffsetDateTime createdAt) { } - public record AuthRow(UUID id, String username, String nickname, String passwordHash) { + public record AuthRow(UUID id, String username, String nickname, String passwordHash, + OffsetDateTime lockedUntil) { } /** Inserts the user row; created_at/updated_at come from the DB defaults. */ @@ -87,7 +88,7 @@ public class UserRepository { public Optional findAuthByUsername(String username) { return jdbcClient.sql(""" - SELECT u.id, u.username::text AS username, u.nickname, c.password_hash + SELECT u.id, u.username::text AS username, u.nickname, c.password_hash, c.locked_until FROM identity.users u JOIN identity.user_credentials c ON c.user_id = u.id WHERE u.deleted_at IS NULL AND u.username = :username @@ -97,7 +98,58 @@ public class UserRepository { rs.getObject("id", UUID.class), rs.getString("username"), rs.getString("nickname"), - rs.getString("password_hash"))) + rs.getString("password_hash"), + rs.getObject("locked_until", OffsetDateTime.class))) .optional(); } + + /** + * Registers one failed login attempt in a single atomic UPDATE: the + * counter restarts when the failure window has lapsed, and locked_until + * is set the moment the counter reaches the configured maximum. All CASE + * expressions read the pre-update column values, so concurrent failures + * cannot double-count or skip the lock. + */ + public void recordLoginFailure(UUID userId, long windowSeconds, int maxFailures, long lockSeconds) { + jdbcClient.sql(""" + UPDATE identity.user_credentials SET + failed_login_count = CASE + WHEN failure_window_started_at IS NULL + OR failure_window_started_at < now() - make_interval(secs => :windowSeconds) + THEN 1 ELSE failed_login_count + 1 END, + failure_window_started_at = CASE + WHEN failure_window_started_at IS NULL + OR failure_window_started_at < now() - make_interval(secs => :windowSeconds) + THEN now() ELSE failure_window_started_at END, + last_failed_at = now(), + locked_until = CASE + WHEN (CASE + WHEN failure_window_started_at IS NULL + OR failure_window_started_at < now() - make_interval(secs => :windowSeconds) + THEN 1 ELSE failed_login_count + 1 END) >= :maxFailures + THEN now() + make_interval(secs => :lockSeconds) + ELSE locked_until END + WHERE user_id = :userId + """) + .param("userId", userId) + .param("windowSeconds", windowSeconds) + .param("maxFailures", maxFailures) + .param("lockSeconds", lockSeconds) + .update(); + } + + /** Successful login: clears the failure window and stamps last_login_at. */ + public void recordLoginSuccess(UUID userId) { + jdbcClient.sql(""" + UPDATE identity.user_credentials + SET failed_login_count = 0, failure_window_started_at = NULL, locked_until = NULL + WHERE user_id = :userId + AND (failed_login_count > 0 OR locked_until IS NOT NULL) + """) + .param("userId", userId) + .update(); + jdbcClient.sql("UPDATE identity.users SET last_login_at = now() WHERE id = :userId") + .param("userId", userId) + .update(); + } } diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/security/BearerAuthFilter.java b/patbond-user/src/main/java/com/patbond/patbond/user/security/BearerAuthFilter.java new file mode 100644 index 0000000..e6d046c --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/security/BearerAuthFilter.java @@ -0,0 +1,77 @@ +package com.patbond.patbond.user.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.patbond.patbond.common.error.BusinessException; +import com.patbond.patbond.common.error.ErrorCode; +import com.patbond.patbond.common.response.ApiResponse; +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +/** + * Bearer authentication for the public /api/v1/** routes of this service + * (currently GET /api/v1/me). Verifies the RS256 signature locally with the + * auth service's public key — no network hop per request — and exposes the + * authenticated user id as a request attribute. Missing, forged or expired + * tokens all answer 401/40101 without detail (log-redaction rule: the token + * itself is never logged). + */ +public class BearerAuthFilter extends OncePerRequestFilter { + + /** Request attribute holding the authenticated user's UUID. */ + public static final String USER_ID_ATTRIBUTE = "patbond.authenticatedUserId"; + + private static final Logger log = LoggerFactory.getLogger(BearerAuthFilter.class); + + private final JwtVerifier jwtVerifier; + private final ObjectMapper objectMapper; + + public BearerAuthFilter(JwtVerifier jwtVerifier, ObjectMapper objectMapper) { + this.jwtVerifier = jwtVerifier; + this.objectMapper = objectMapper; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().startsWith("/api/v1/"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String header = request.getHeader("Authorization"); + if (header == null || !header.startsWith("Bearer ")) { + reject(response, ErrorCode.TOKEN_INVALID); + return; + } + try { + Claims claims = jwtVerifier.verify(header.substring("Bearer ".length()).trim()); + request.setAttribute(USER_ID_ATTRIBUTE, UUID.fromString(claims.getSubject())); + } catch (BusinessException e) { + reject(response, ErrorCode.TOKEN_INVALID); + return; + } catch (IllegalStateException | IllegalArgumentException e) { + log.error("Access token verification unavailable: {}", e.getMessage()); + reject(response, ErrorCode.INTERNAL_ERROR); + return; + } + filterChain.doFilter(request, response); + } + + private void reject(HttpServletResponse response, ErrorCode errorCode) throws IOException { + response.setStatus(errorCode.getHttpStatus()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), + ApiResponse.failure(errorCode.getCode(), errorCode.getDefaultMessage())); + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/security/InternalAuthFilter.java b/patbond-user/src/main/java/com/patbond/patbond/user/security/InternalAuthFilter.java new file mode 100644 index 0000000..a3f59e1 --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/security/InternalAuthFilter.java @@ -0,0 +1,70 @@ +package com.patbond.patbond.user.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.patbond.patbond.common.error.ErrorCode; +import com.patbond.patbond.common.response.ApiResponse; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** + * Service-to-service authentication for /internal/** (development-plan 6: + * internal routes must carry inter-service auth). The caller presents the + * shared secret in X-Internal-Token; it is injected via environment variable + * on both sides and never logged. Fails closed: with no secret configured + * every internal call is rejected. + */ +public class InternalAuthFilter extends OncePerRequestFilter { + + public static final String HEADER = "X-Internal-Token"; + + private static final Logger log = LoggerFactory.getLogger(InternalAuthFilter.class); + + private final String expectedToken; + private final ObjectMapper objectMapper; + + public InternalAuthFilter(String expectedToken, ObjectMapper objectMapper) { + this.expectedToken = expectedToken; + this.objectMapper = objectMapper; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().startsWith("/internal/"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (expectedToken == null || expectedToken.isBlank()) { + log.error("patbond.internal-token 未配置,/internal/** 请求全部拒绝"); + reject(response); + return; + } + String presented = request.getHeader(HEADER); + if (presented == null || !MessageDigest.isEqual( + presented.getBytes(StandardCharsets.UTF_8), + expectedToken.getBytes(StandardCharsets.UTF_8))) { + reject(response); + return; + } + filterChain.doFilter(request, response); + } + + private void reject(HttpServletResponse response) throws IOException { + response.setStatus(401); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + objectMapper.writeValue(response.getWriter(), + ApiResponse.failure(ErrorCode.TOKEN_INVALID.getCode(), "服务间凭证缺失或无效")); + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/security/JwtVerifier.java b/patbond-user/src/main/java/com/patbond/patbond/user/security/JwtVerifier.java new file mode 100644 index 0000000..de10e7a --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/security/JwtVerifier.java @@ -0,0 +1,41 @@ +package com.patbond.patbond.user.security; + +import com.patbond.patbond.common.error.BusinessException; +import com.patbond.patbond.common.error.ErrorCode; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; + +/** + * Verifies RS256 access tokens issued by patbond-auth against the configured + * public key. The key is optional at startup so contexts that never serve + * protected routes (most tests) can boot without one; any verification + * attempt without a key fails loudly as a server misconfiguration instead of + * being reported to the client as an authentication problem. + */ +public class JwtVerifier { + + private final JwtParser parser; + + public JwtVerifier(String publicKeyLocation) { + this.parser = publicKeyLocation == null || publicKeyLocation.isBlank() + ? null + : Jwts.parser().verifyWith(RsaPublicKeyLoader.load(publicKeyLocation)).build(); + } + + /** + * @return the verified claims + * @throws BusinessException 40101 when the token is forged, malformed or expired + */ + public Claims verify(String token) { + if (parser == null) { + throw new IllegalStateException("patbond.jwt.public-key 未配置,无法校验 access token"); + } + try { + return parser.parseSignedClaims(token).getPayload(); + } catch (JwtException | IllegalArgumentException e) { + throw new BusinessException(ErrorCode.TOKEN_INVALID); + } + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/security/RsaPublicKeyLoader.java b/patbond-user/src/main/java/com/patbond/patbond/user/security/RsaPublicKeyLoader.java new file mode 100644 index 0000000..1883699 --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/security/RsaPublicKeyLoader.java @@ -0,0 +1,46 @@ +package com.patbond.patbond.user.security; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +/** + * Loads an RSA public key from either inline PEM content (value starts with + * -----BEGIN, e.g. injected through an environment variable) or a filesystem + * path to a PEM file. Only the X.509 SubjectPublicKeyInfo form produced by + * `openssl pkey -pubout` is supported. + */ +public final class RsaPublicKeyLoader { + + private RsaPublicKeyLoader() { + } + + public static RSAPublicKey load(String pemOrPath) { + String pem = pemOrPath.trim(); + if (!pem.startsWith("-----BEGIN")) { + try { + pem = Files.readString(Path.of(pem)); + } catch (IOException e) { + throw new UncheckedIOException("无法读取 JWT 公钥文件: " + pemOrPath, e); + } + } + String base64 = pem + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s", ""); + try { + byte[] der = Base64.getDecoder().decode(base64); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return (RSAPublicKey) keyFactory.generatePublic(new X509EncodedKeySpec(der)); + } catch (IllegalArgumentException | NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new IllegalStateException("JWT 公钥不是有效的 PEM(X.509/SubjectPublicKeyInfo) RSA 公钥", e); + } + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/service/UserService.java b/patbond-user/src/main/java/com/patbond/patbond/user/service/UserService.java index 49129ad..49fd658 100644 --- a/patbond-user/src/main/java/com/patbond/patbond/user/service/UserService.java +++ b/patbond-user/src/main/java/com/patbond/patbond/user/service/UserService.java @@ -6,6 +6,7 @@ import com.patbond.patbond.common.user.CreateUserRequest; import com.patbond.patbond.common.user.UserProfile; import com.patbond.patbond.common.user.VerifyPasswordRequest; import com.patbond.patbond.common.user.VerifyPasswordResponse; +import com.patbond.patbond.user.config.UserSecurityProperties; import com.patbond.patbond.user.repository.UserRepository; import com.patbond.patbond.user.support.UuidV7; import org.springframework.dao.DuplicateKeyException; @@ -15,12 +16,14 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.UUID; @Service public class UserService { private final UserRepository userRepository; + private final UserSecurityProperties properties; private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); /** @@ -29,8 +32,9 @@ public class UserService { */ private final String unknownUserHash = passwordEncoder.encode(UUID.randomUUID().toString()); - public UserService(UserRepository userRepository) { + public UserService(UserRepository userRepository, UserSecurityProperties properties) { this.userRepository = userRepository; + this.properties = properties; } @Transactional @@ -50,13 +54,31 @@ public class UserService { return new UserProfile(id, username, nickname, phone, createdAt); } + /** + * Password check with a database-backed lockout window (frozen policy, + * see openapi.yaml): repeated failures within the failure window lock the + * account for the configured duration and answer 423/42300, even for the + * correct password, until the lock lapses. Counters live on + * identity.user_credentials, so they survive restarts and are shared by + * every instance. A successful login resets the window. + */ public VerifyPasswordResponse verifyPassword(VerifyPasswordRequest request) { UserRepository.AuthRow auth = userRepository.findAuthByUsername(request.getUsername().trim()) .orElse(null); + if (auth != null && auth.lockedUntil() != null + && auth.lockedUntil().isAfter(OffsetDateTime.now(ZoneOffset.UTC))) { + throw new BusinessException(ErrorCode.LOGIN_LOCKED); + } String storedHash = auth == null ? unknownUserHash : auth.passwordHash(); if (!passwordEncoder.matches(request.getPassword(), storedHash) || auth == null) { + if (auth != null) { + UserSecurityProperties.LoginLock lock = properties.getLoginLock(); + userRepository.recordLoginFailure(auth.id(), lock.getFailureWindow().toSeconds(), + lock.getMaxFailures(), lock.getLockDuration().toSeconds()); + } throw new BusinessException(ErrorCode.INVALID_CREDENTIALS); } + userRepository.recordLoginSuccess(auth.id()); return new VerifyPasswordResponse(auth.id(), auth.username(), auth.nickname()); } diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionController.java b/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionController.java new file mode 100644 index 0000000..e0a3f3d --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionController.java @@ -0,0 +1,43 @@ +package com.patbond.patbond.user.session; + +import com.patbond.patbond.common.response.ApiResponse; +import com.patbond.patbond.common.session.CreateSessionRequest; +import com.patbond.patbond.common.session.RefreshSessionRequest; +import com.patbond.patbond.common.session.RevokeSessionRequest; +import com.patbond.patbond.common.session.SessionTokens; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Internal session API consumed by patbond-auth. Guarded by + * InternalAuthFilter like every /internal/** route. + */ +@RestController +@RequestMapping("/internal/sessions") +public class SessionController { + + private final SessionService sessionService; + + public SessionController(SessionService sessionService) { + this.sessionService = sessionService; + } + + @PostMapping + public ApiResponse create(@Valid @RequestBody CreateSessionRequest request) { + return ApiResponse.success(sessionService.create(request)); + } + + @PostMapping("/refresh") + public ApiResponse refresh(@Valid @RequestBody RefreshSessionRequest request) { + return ApiResponse.success(sessionService.refresh(request.getRefreshToken())); + } + + @PostMapping("/revoke") + public ApiResponse revoke(@Valid @RequestBody RevokeSessionRequest request) { + sessionService.revoke(request.getUserId(), request.getRefreshToken()); + return ApiResponse.success(null); + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionRepository.java b/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionRepository.java new file mode 100644 index 0000000..85da36c --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionRepository.java @@ -0,0 +1,108 @@ +package com.patbond.patbond.user.session; + +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +import java.time.OffsetDateTime; +import java.util.Optional; +import java.util.UUID; + +/** + * JDBC access to identity.auth_sessions. The table only ever sees the + * SHA-256 digest of a refresh token (ck_sessions_refresh_hash pins 32 bytes); + * plaintext tokens exist solely in transit back to the client. + */ +@Repository +public class SessionRepository { + + private final JdbcClient jdbcClient; + + public SessionRepository(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + public record SessionRow(UUID id, UUID userId, UUID tokenFamilyId, OffsetDateTime expiresAt, + OffsetDateTime revokedAt, OffsetDateTime rotatedAt) { + } + + public void insert(UUID id, UUID userId, UUID tokenFamilyId, byte[] refreshTokenHash, + String accessTokenJti, OffsetDateTime expiresAt, + String userAgent, String ipAddress) { + jdbcClient.sql(""" + INSERT INTO identity.auth_sessions + (id, user_id, token_family_id, refresh_token_hash, access_token_jti, + expires_at, user_agent, ip_address) + VALUES (:id, :userId, :familyId, :hash, :jti, :expiresAt, + :userAgent, CAST(:ipAddress AS inet)) + """) + .param("id", id) + .param("userId", userId) + .param("familyId", tokenFamilyId) + .param("hash", refreshTokenHash) + .param("jti", accessTokenJti) + .param("expiresAt", expiresAt) + .param("userAgent", userAgent) + .param("ipAddress", ipAddress) + .update(); + } + + public Optional findByTokenHash(byte[] refreshTokenHash) { + return jdbcClient.sql(""" + SELECT id, user_id, token_family_id, expires_at, revoked_at, rotated_at + FROM identity.auth_sessions + WHERE refresh_token_hash = :hash + """) + .param("hash", refreshTokenHash) + .query((rs, rowNum) -> new SessionRow( + rs.getObject("id", UUID.class), + rs.getObject("user_id", UUID.class), + rs.getObject("token_family_id", UUID.class), + rs.getObject("expires_at", OffsetDateTime.class), + rs.getObject("revoked_at", OffsetDateTime.class), + rs.getObject("rotated_at", OffsetDateTime.class))) + .optional(); + } + + /** + * Closes the old session as part of a rotation. The revoked_at IS NULL + * guard makes concurrent rotations of the same token detectable: exactly + * one caller sees 1 row updated, every other sees 0 (= reuse). + */ + public int markRotated(UUID oldSessionId, UUID newSessionId) { + return jdbcClient.sql(""" + UPDATE identity.auth_sessions + SET revoked_at = now(), rotated_at = now(), + revoke_reason = 'rotated', replaced_by_session_id = :newId, + last_seen_at = now() + WHERE id = :oldId AND revoked_at IS NULL + """) + .param("oldId", oldSessionId) + .param("newId", newSessionId) + .update(); + } + + /** Revokes every live session of the family (refresh-token reuse response). */ + public int revokeFamily(UUID tokenFamilyId, String reason) { + return jdbcClient.sql(""" + UPDATE identity.auth_sessions + SET revoked_at = now(), revoke_reason = :reason + WHERE token_family_id = :familyId AND revoked_at IS NULL + """) + .param("familyId", tokenFamilyId) + .param("reason", reason) + .update(); + } + + /** Logout: revokes the one live session holding this token for this user. */ + public int revokeByTokenHashAndUser(byte[] refreshTokenHash, UUID userId, String reason) { + return jdbcClient.sql(""" + UPDATE identity.auth_sessions + SET revoked_at = now(), revoke_reason = :reason + WHERE refresh_token_hash = :hash AND user_id = :userId AND revoked_at IS NULL + """) + .param("hash", refreshTokenHash) + .param("userId", userId) + .param("reason", reason) + .update(); + } +} diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionService.java b/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionService.java new file mode 100644 index 0000000..1d06502 --- /dev/null +++ b/patbond-user/src/main/java/com/patbond/patbond/user/session/SessionService.java @@ -0,0 +1,122 @@ +package com.patbond.patbond.user.session; + +import com.patbond.patbond.common.error.BusinessException; +import com.patbond.patbond.common.error.ErrorCode; +import com.patbond.patbond.common.session.CreateSessionRequest; +import com.patbond.patbond.common.session.SessionTokens; +import com.patbond.patbond.user.config.UserSecurityProperties; +import com.patbond.patbond.user.support.UuidV7; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.UUID; + +/** + * Refresh-session lifecycle on identity.auth_sessions, implementing ADR-003: + * 30-day (configurable) refresh tokens, rotation on every refresh, reuse of a + * rotated token revokes the whole token family, logout revokes only the + * current session so parallel device sessions stay alive. + * + *

Refresh tokens are 256-bit random values; only their SHA-256 digest is + * persisted. Neither the plaintext token nor its digest is ever logged. + */ +@Service +public class SessionService { + + private static final Logger log = LoggerFactory.getLogger(SessionService.class); + private static final SecureRandom RANDOM = new SecureRandom(); + + private final SessionRepository sessionRepository; + private final UserSecurityProperties properties; + private final TransactionTemplate transactionTemplate; + + public SessionService(SessionRepository sessionRepository, UserSecurityProperties properties, + TransactionTemplate transactionTemplate) { + this.sessionRepository = sessionRepository; + this.properties = properties; + this.transactionTemplate = transactionTemplate; + } + + /** Opens a new session (= new token family) for a freshly authenticated user. */ + public SessionTokens create(CreateSessionRequest request) { + return insertSession(request.getUserId(), UuidV7.generate(), + request.getUserAgent(), request.getIpAddress()); + } + + /** + * Rotates a refresh token. The old session is closed and chained to its + * replacement; presenting a token that was already rotated or revoked is + * treated as reuse and kills every live session of the family (40102). + */ + public SessionTokens refresh(String refreshToken) { + byte[] hash = sha256(refreshToken); + SessionRepository.SessionRow session = sessionRepository.findByTokenHash(hash) + .orElseThrow(() -> new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID)); + + if (session.revokedAt() != null) { + // A rotated (or logged-out) token came back: someone other than the + // rightful holder may have it. Revoke the whole family (ADR-003). + int revoked = sessionRepository.revokeFamily(session.tokenFamilyId(), "reuse_detected"); + log.warn("Refresh token reuse detected: family={} of user={} revoked ({} live sessions)", + session.tokenFamilyId(), session.userId(), revoked); + throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID); + } + if (!session.expiresAt().isAfter(OffsetDateTime.now(ZoneOffset.UTC))) { + throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID); + } + + SessionTokens rotated = transactionTemplate.execute(status -> { + SessionTokens tokens = insertSession(session.userId(), session.tokenFamilyId(), null, null); + if (sessionRepository.markRotated(session.id(), tokens.getSessionId()) != 1) { + status.setRollbackOnly(); + return null; + } + return tokens; + }); + if (rotated == null) { + // Lost a race against a concurrent rotation of the same token — + // by definition the token was presented twice: treat as reuse. + int revoked = sessionRepository.revokeFamily(session.tokenFamilyId(), "reuse_detected"); + log.warn("Concurrent refresh detected: family={} of user={} revoked ({} live sessions)", + session.tokenFamilyId(), session.userId(), revoked); + throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID); + } + return rotated; + } + + /** Logout: revokes the session holding this token; idempotent by design. */ + public void revoke(UUID userId, String refreshToken) { + sessionRepository.revokeByTokenHashAndUser(sha256(refreshToken), userId, "logout"); + } + + private SessionTokens insertSession(UUID userId, UUID familyId, String userAgent, String ipAddress) { + UUID sessionId = UuidV7.generate(); + String jti = UuidV7.generate().toString(); + byte[] tokenBytes = new byte[32]; + RANDOM.nextBytes(tokenBytes); + String refreshToken = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); + OffsetDateTime expiresAt = OffsetDateTime.now(ZoneOffset.UTC) + .plus(properties.getSession().getRefreshTtl()); + + sessionRepository.insert(sessionId, userId, familyId, sha256(refreshToken), jti, + expiresAt, userAgent, ipAddress); + return new SessionTokens(sessionId, userId, jti, refreshToken, expiresAt); + } + + private static byte[] sha256(String token) { + try { + return MessageDigest.getInstance("SHA-256").digest(token.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } +} diff --git a/patbond-user/src/main/resources/application.yml.sample b/patbond-user/src/main/resources/application.yml.sample index 19d0f7f..fb2fe9c 100644 --- a/patbond-user/src/main/resources/application.yml.sample +++ b/patbond-user/src/main/resources/application.yml.sample @@ -11,6 +11,27 @@ spring: flyway: locations: classpath:db/migration +patbond: + # /internal/** 服务间共享密钥,需与 patbond-auth 配置同一值;生产环境必须 + # 通过 PATBOND_INTERNAL_TOKEN 注入强随机值(如 `openssl rand -hex 32`)。 + internal-token: ${PATBOND_INTERNAL_TOKEN:dev-only-internal-token} + jwt: + # RS256 公钥,用于本地校验 patbond-auth 签发的 access token。 + # 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。 + # 密钥对生成(私钥只给 patbond-auth,绝不入库): + # openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt-private.pem + # openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem + # 然后:export PATBOND_JWT_PUBLIC_KEY=/path/to/jwt-public.pem + public-key: ${PATBOND_JWT_PUBLIC_KEY:} + session: + # ADR-003:refresh token 30 天,刷新即轮换;值可配置。 + refresh-ttl: ${PATBOND_REFRESH_TTL:30d} + login-lock: + # 登录失败限制:窗口内连续失败达到阈值后锁定账号(返回 423/42300)。 + max-failures: ${PATBOND_LOGIN_LOCK_MAX_FAILURES:5} + failure-window: ${PATBOND_LOGIN_LOCK_WINDOW:15m} + lock-duration: ${PATBOND_LOGIN_LOCK_DURATION:15m} + # Development seed data (regions reference rows) is opt-in. To load it, # activate a dev profile that widens the Flyway locations: # diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeEndpointTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeEndpointTest.java new file mode 100644 index 0000000..0213bde --- /dev/null +++ b/patbond-user/src/test/java/com/patbond/patbond/user/controller/MeEndpointTest.java @@ -0,0 +1,109 @@ +package com.patbond.patbond.user.controller; + +import com.jayway.jsonpath.JsonPath; +import com.patbond.patbond.user.TestcontainersConfiguration; +import com.patbond.patbond.user.security.InternalAuthFilter; +import com.patbond.patbond.user.support.TestJwtKeys; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Duration; +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * GET /api/v1/me behind BearerAuthFilter: RS256 tokens are verified locally + * against the configured public key (generated per test run — no committed + * key material). Response shape is the frozen contract: + * {userId, username, phone, createdAt} and nothing else. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class MeEndpointTest { + + @Autowired + private MockMvc mockMvc; + + @DynamicPropertySource + static void jwtPublicKey(DynamicPropertyRegistry registry) { + registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem); + } + + private String registerUser(String username, String phone) throws Exception { + String body = mockMvc.perform(post("/internal/users") + .header(InternalAuthFilter.HEADER, "test-internal-token") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"username\":\"%s\",\"password\":\"secret123\",\"phone\":\"%s\"}" + .formatted(username, phone))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return JsonPath.read(body, "$.data.id"); + } + + @Test + void meReturnsExactlyTheFrozenContractFields() throws Exception { + String userId = registerUser("me_happy", "+8613800000401"); + String token = TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(), + UUID.fromString(userId), Duration.ofMinutes(15)); + + mockMvc.perform(get("/api/v1/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.userId").value(userId)) + .andExpect(jsonPath("$.data.username").value("me_happy")) + .andExpect(jsonPath("$.data.phone").value("+8613800000401")) + .andExpect(jsonPath("$.data.createdAt").isNotEmpty()) + // Frozen contract: no other identity fields leak out. + .andExpect(jsonPath("$.data.id").doesNotExist()) + .andExpect(jsonPath("$.data.nickname").doesNotExist()); + } + + @Test + void meWithoutTokenReturns40101() throws Exception { + mockMvc.perform(get("/api/v1/me")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void meWithExpiredTokenReturns40101() throws Exception { + String userId = registerUser("me_expired", "+8613800000402"); + String token = TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(), + UUID.fromString(userId), Duration.ofMinutes(-1)); + + mockMvc.perform(get("/api/v1/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void meWithForgedTokenReturns40101() throws Exception { + String userId = registerUser("me_forged", "+8613800000403"); + // Signed with a key the service does not trust. + String token = TestJwtKeys.accessToken(TestJwtKeys.WRONG_KEY_PAIR.getPrivate(), + UUID.fromString(userId), Duration.ofMinutes(15)); + + mockMvc.perform(get("/api/v1/me").header("Authorization", "Bearer " + token)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void meWithGarbageTokenReturns40101() throws Exception { + mockMvc.perform(get("/api/v1/me").header("Authorization", "Bearer not.a.jwt")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } +} diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/controller/UserControllerTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/controller/UserControllerTest.java index 045aab9..f639797 100644 --- a/patbond-user/src/test/java/com/patbond/patbond/user/controller/UserControllerTest.java +++ b/patbond-user/src/test/java/com/patbond/patbond/user/controller/UserControllerTest.java @@ -2,6 +2,7 @@ package com.patbond.patbond.user.controller; import com.jayway.jsonpath.JsonPath; import com.patbond.patbond.user.TestcontainersConfiguration; +import com.patbond.patbond.user.security.InternalAuthFilter; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -9,6 +10,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import java.util.UUID; @@ -21,19 +23,31 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. /** * MockMvc tests against the PostgreSQL-backed UserService (Testcontainers). * The database lives for the whole test context, so each test uses its own - * username/phone to stay independent. + * username/phone to stay independent. Every /internal/** call carries the + * shared service token configured in the test application.yml; requests + * without it are covered by InternalAuthFilterTest. */ @SpringBootTest @AutoConfigureMockMvc @Import(TestcontainersConfiguration.class) class UserControllerTest { + private static final String INTERNAL_TOKEN = "test-internal-token"; + private static final String UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; @Autowired private MockMvc mockMvc; + private static MockHttpServletRequestBuilder internalPost(String path) { + return post(path).header(InternalAuthFilter.HEADER, INTERNAL_TOKEN); + } + + private static MockHttpServletRequestBuilder internalGet(String path, Object... uriVariables) { + return get(path, uriVariables).header(InternalAuthFilter.HEADER, INTERNAL_TOKEN); + } + private static String createUserBody(String username) { return """ {"username":"%s","password":"secret123","nickname":"Nick"} @@ -48,7 +62,7 @@ class UserControllerTest { @Test void createUserReturnsUuidProfileWithoutPassword() throws Exception { - mockMvc.perform(post("/internal/users") + mockMvc.perform(internalPost("/internal/users") .contentType(MediaType.APPLICATION_JSON) .content(createUserBody("alice_create", "+8613800000101"))) .andExpect(status().isOk()) @@ -63,12 +77,12 @@ class UserControllerTest { @Test void createUserWithDuplicateUsernameReturnsConflict() throws Exception { - mockMvc.perform(post("/internal/users") + mockMvc.perform(internalPost("/internal/users") .contentType(MediaType.APPLICATION_JSON) .content(createUserBody("bob_dup"))) .andExpect(status().isOk()); - mockMvc.perform(post("/internal/users") + mockMvc.perform(internalPost("/internal/users") .contentType(MediaType.APPLICATION_JSON) .content(createUserBody("bob_dup"))) .andExpect(status().isConflict()) @@ -77,13 +91,13 @@ class UserControllerTest { @Test void duplicateUsernameCheckIsCaseInsensitive() throws Exception { - mockMvc.perform(post("/internal/users") + mockMvc.perform(internalPost("/internal/users") .contentType(MediaType.APPLICATION_JSON) .content(createUserBody("casey_case"))) .andExpect(status().isOk()); // identity.users.username is citext: uniqueness ignores case. - mockMvc.perform(post("/internal/users") + mockMvc.perform(internalPost("/internal/users") .contentType(MediaType.APPLICATION_JSON) .content(createUserBody("CASEY_CASE"))) .andExpect(status().isConflict()) @@ -92,12 +106,12 @@ class UserControllerTest { @Test void createUserWithDuplicatePhoneReturnsConflict() throws Exception { - mockMvc.perform(post("/internal/users") + mockMvc.perform(internalPost("/internal/users") .contentType(MediaType.APPLICATION_JSON) .content(createUserBody("pia_phone1", "+8613800000202"))) .andExpect(status().isOk()); - mockMvc.perform(post("/internal/users") + mockMvc.perform(internalPost("/internal/users") .contentType(MediaType.APPLICATION_JSON) .content(createUserBody("pia_phone2", "+8613800000202"))) .andExpect(status().isConflict()) @@ -106,7 +120,7 @@ class UserControllerTest { @Test void createUserWithInvalidPayloadReturnsBadRequest() throws Exception { - mockMvc.perform(post("/internal/users") + mockMvc.perform(internalPost("/internal/users") .contentType(MediaType.APPLICATION_JSON) .content("{\"username\":\"ab\",\"password\":\"123\"}")) .andExpect(status().isBadRequest()) @@ -117,7 +131,7 @@ class UserControllerTest { void createUserWithNonE164PhoneReturnsBadRequest() throws Exception { // 13800000000 passed the old length-only rule but violates the DB // ck_users_phone CHECK; the DTO now rejects it up front (issue B4). - mockMvc.perform(post("/internal/users") + mockMvc.perform(internalPost("/internal/users") .contentType(MediaType.APPLICATION_JSON) .content(createUserBody("nina_badphone", "13800000000"))) .andExpect(status().isBadRequest()) @@ -126,12 +140,12 @@ class UserControllerTest { @Test void verifyPasswordSucceedsWithCorrectCredentials() throws Exception { - mockMvc.perform(post("/internal/users") + mockMvc.perform(internalPost("/internal/users") .contentType(MediaType.APPLICATION_JSON) .content(createUserBody("carol_verify"))) .andExpect(status().isOk()); - mockMvc.perform(post("/internal/users/verify-password") + mockMvc.perform(internalPost("/internal/users/verify-password") .contentType(MediaType.APPLICATION_JSON) .content("{\"username\":\"carol_verify\",\"password\":\"secret123\"}")) .andExpect(status().isOk()) @@ -142,12 +156,12 @@ class UserControllerTest { @Test void verifyPasswordWithWrongPasswordReturnsUnauthorized() throws Exception { - mockMvc.perform(post("/internal/users") + mockMvc.perform(internalPost("/internal/users") .contentType(MediaType.APPLICATION_JSON) .content(createUserBody("dave_wrongpw"))) .andExpect(status().isOk()); - mockMvc.perform(post("/internal/users/verify-password") + mockMvc.perform(internalPost("/internal/users/verify-password") .contentType(MediaType.APPLICATION_JSON) .content("{\"username\":\"dave_wrongpw\",\"password\":\"wrong-password\"}")) .andExpect(status().isUnauthorized()) @@ -156,7 +170,7 @@ class UserControllerTest { @Test void verifyPasswordForUnknownUserReturnsUnauthorized() throws Exception { - mockMvc.perform(post("/internal/users/verify-password") + mockMvc.perform(internalPost("/internal/users/verify-password") .contentType(MediaType.APPLICATION_JSON) .content("{\"username\":\"no_such_user\",\"password\":\"whatever1\"}")) .andExpect(status().isUnauthorized()) @@ -165,14 +179,14 @@ class UserControllerTest { @Test void getByIdReturnsProfileForExistingUser() throws Exception { - String body = mockMvc.perform(post("/internal/users") + String body = mockMvc.perform(internalPost("/internal/users") .contentType(MediaType.APPLICATION_JSON) .content(createUserBody("erin_getbyid"))) .andExpect(status().isOk()) .andReturn().getResponse().getContentAsString(); String id = JsonPath.read(body, "$.data.id"); - mockMvc.perform(get("/internal/users/{id}", id)) + mockMvc.perform(internalGet("/internal/users/{id}", id)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.id").value(id)) .andExpect(jsonPath("$.data.username").value("erin_getbyid")); @@ -180,30 +194,30 @@ class UserControllerTest { @Test void getByIdForUnknownUserReturnsNotFound() throws Exception { - mockMvc.perform(get("/internal/users/{id}", UUID.randomUUID())) + mockMvc.perform(internalGet("/internal/users/{id}", UUID.randomUUID())) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value(40400)); } @Test void getByIdWithMalformedUuidReturnsBadRequest() throws Exception { - mockMvc.perform(get("/internal/users/{id}", "not-a-uuid")) + mockMvc.perform(internalGet("/internal/users/{id}", "not-a-uuid")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(40000)); } @Test void getByUsernameReturnsProfileAndNotFoundForUnknown() throws Exception { - mockMvc.perform(post("/internal/users") + mockMvc.perform(internalPost("/internal/users") .contentType(MediaType.APPLICATION_JSON) .content(createUserBody("frank_byname"))) .andExpect(status().isOk()); - mockMvc.perform(get("/internal/users/by-username/{username}", "frank_byname")) + mockMvc.perform(internalGet("/internal/users/by-username/{username}", "frank_byname")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.username").value("frank_byname")); - mockMvc.perform(get("/internal/users/by-username/{username}", "ghost_user")) + mockMvc.perform(internalGet("/internal/users/by-username/{username}", "ghost_user")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.code").value(40400)); } diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/security/InternalAuthFilterTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/security/InternalAuthFilterTest.java new file mode 100644 index 0000000..020e4d5 --- /dev/null +++ b/patbond-user/src/test/java/com/patbond/patbond/user/security/InternalAuthFilterTest.java @@ -0,0 +1,60 @@ +package com.patbond.patbond.user.security; + +import com.patbond.patbond.user.TestcontainersConfiguration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * /internal/** requires the shared service secret (development-plan 6): + * requests without a credential — or with a wrong one — answer 401 before + * any controller code runs. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class InternalAuthFilterTest { + + @Autowired + private MockMvc mockMvc; + + private static final String VALID_BODY = """ + {"username":"filter_probe","password":"secret123"} + """; + + @Test + void internalCallWithoutTokenIsRejectedWith401() throws Exception { + mockMvc.perform(post("/internal/users") + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_BODY)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void internalCallWithWrongTokenIsRejectedWith401() throws Exception { + mockMvc.perform(post("/internal/users") + .header(InternalAuthFilter.HEADER, "not-the-configured-secret") + .contentType(MediaType.APPLICATION_JSON) + .content(VALID_BODY)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } + + @Test + void sessionEndpointsAreGuardedToo() throws Exception { + mockMvc.perform(post("/internal/sessions/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"refreshToken\":\"whatever\"}")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40101)); + } +} diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/service/LoginLockoutIntegrationTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/service/LoginLockoutIntegrationTest.java new file mode 100644 index 0000000..504eadd --- /dev/null +++ b/patbond-user/src/test/java/com/patbond/patbond/user/service/LoginLockoutIntegrationTest.java @@ -0,0 +1,107 @@ +package com.patbond.patbond.user.service; + +import com.jayway.jsonpath.JsonPath; +import com.patbond.patbond.user.TestcontainersConfiguration; +import com.patbond.patbond.user.security.InternalAuthFilter; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Database-backed login-failure lockout (policy documented in openapi.yaml): + * maxFailures wrong passwords inside the failure window lock the account for + * lockDuration; while locked even the correct password answers 423/42300; a + * successful login resets the window. Threshold lowered to 3 here to keep + * the tests fast. + */ +@SpringBootTest(properties = "patbond.login-lock.max-failures=3") +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class LoginLockoutIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcClient jdbcClient; + + private String register(String username) throws Exception { + String body = mockMvc.perform(internalPost("/internal/users") + .content("{\"username\":\"%s\",\"password\":\"secret123\"}".formatted(username))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return JsonPath.read(body, "$.data.id"); + } + + private org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder internalPost(String path) { + return post(path) + .header(InternalAuthFilter.HEADER, "test-internal-token") + .contentType(MediaType.APPLICATION_JSON); + } + + private org.springframework.test.web.servlet.ResultActions verify(String username, String password) + throws Exception { + return mockMvc.perform(internalPost("/internal/users/verify-password") + .content("{\"username\":\"%s\",\"password\":\"%s\"}".formatted(username, password))); + } + + @Test + void accountLocksAfterMaxFailuresEvenForTheCorrectPassword() throws Exception { + register("lock_basic"); + for (int i = 0; i < 3; i++) { + verify("lock_basic", "wrong-password") + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(40100)); + } + verify("lock_basic", "secret123") + .andExpect(status().is(423)) + .andExpect(jsonPath("$.code").value(42300)); + } + + @Test + void successfulLoginResetsTheFailureWindow() throws Exception { + register("lock_reset"); + verify("lock_reset", "wrong-password").andExpect(status().isUnauthorized()); + verify("lock_reset", "wrong-password").andExpect(status().isUnauthorized()); + verify("lock_reset", "secret123").andExpect(status().isOk()); + + // Without the reset, these two would be failures 3 and 4 → locked. + verify("lock_reset", "wrong-password").andExpect(status().isUnauthorized()); + verify("lock_reset", "wrong-password").andExpect(status().isUnauthorized()); + verify("lock_reset", "secret123").andExpect(status().isOk()); + } + + @Test + void lockExpiryAllowsLoggingInAgain() throws Exception { + String userId = register("lock_expiry"); + for (int i = 0; i < 3; i++) { + verify("lock_expiry", "wrong-password").andExpect(status().isUnauthorized()); + } + verify("lock_expiry", "secret123").andExpect(status().is(423)); + + // Simulate the lock lapsing instead of sleeping 15 minutes. + jdbcClient.sql(""" + UPDATE identity.user_credentials + SET locked_until = now() - interval '1 second', + failure_window_started_at = now() - interval '1 hour' + WHERE user_id = :userId + """) + .param("userId", UUID.fromString(userId)) + .update(); + + verify("lock_expiry", "secret123") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } +} diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/session/SessionLifecycleIntegrationTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/session/SessionLifecycleIntegrationTest.java new file mode 100644 index 0000000..e4583f5 --- /dev/null +++ b/patbond-user/src/test/java/com/patbond/patbond/user/session/SessionLifecycleIntegrationTest.java @@ -0,0 +1,211 @@ +package com.patbond.patbond.user.session; + +import com.jayway.jsonpath.JsonPath; +import com.patbond.patbond.user.TestcontainersConfiguration; +import com.patbond.patbond.user.security.InternalAuthFilter; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integration tests of the refresh-session lifecycle (ADR-003) against a + * real postgres:18: hashed storage, rotation chaining, family revocation on + * reuse, logout scope, expiry. + */ +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class SessionLifecycleIntegrationTest { + + private static final String INTERNAL_TOKEN = "test-internal-token"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JdbcClient jdbcClient; + + private MockHttpServletRequestBuilder internalPost(String path) { + return post(path) + .header(InternalAuthFilter.HEADER, INTERNAL_TOKEN) + .contentType(MediaType.APPLICATION_JSON); + } + + private String registerUser(String username) throws Exception { + String body = mockMvc.perform(internalPost("/internal/users") + .content("{\"username\":\"%s\",\"password\":\"secret123\"}".formatted(username))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + return JsonPath.read(body, "$.data.id"); + } + + private Map createSession(String userId) throws Exception { + String body = mockMvc.perform(internalPost("/internal/sessions") + .content("{\"userId\":\"%s\",\"userAgent\":\"junit\",\"ipAddress\":\"127.0.0.1\"}" + .formatted(userId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(); + return JsonPath.read(body, "$.data"); + } + + private String refresh(String refreshToken) throws Exception { + return mockMvc.perform(internalPost("/internal/sessions/refresh") + .content("{\"refreshToken\":\"%s\"}".formatted(refreshToken))) + .andReturn().getResponse().getContentAsString(); + } + + @Test + void createSessionStoresSha256DigestNotPlaintext() throws Exception { + String userId = registerUser("sess_digest"); + Map session = createSession(userId); + String refreshToken = (String) session.get("refreshToken"); + assertThat(refreshToken).isNotBlank(); + assertThat((String) session.get("jti")).isNotBlank(); + assertThat((String) session.get("refreshTokenExpiresAt")).contains("T"); + + byte[] storedHash = jdbcClient.sql(""" + SELECT refresh_token_hash FROM identity.auth_sessions WHERE id = :id + """) + .param("id", UUID.fromString((String) session.get("sessionId"))) + .query(byte[].class) + .single(); + byte[] expected = MessageDigest.getInstance("SHA-256") + .digest(refreshToken.getBytes(StandardCharsets.UTF_8)); + assertThat(storedHash).isEqualTo(expected).hasSize(32); + // The plaintext token appears nowhere in the row. + assertThat(new String(storedHash, StandardCharsets.ISO_8859_1)).isNotEqualTo(refreshToken); + } + + @Test + void refreshRotatesTokenAndChainsSessions() throws Exception { + String userId = registerUser("sess_rotate"); + Map first = createSession(userId); + + String body = refresh((String) first.get("refreshToken")); + assertThat((int) JsonPath.read(body, "$.code")).isZero(); + String newToken = JsonPath.read(body, "$.data.refreshToken"); + String newSessionId = JsonPath.read(body, "$.data.sessionId"); + assertThat(newToken).isNotEqualTo(first.get("refreshToken")); + assertThat((String) JsonPath.read(body, "$.data.userId")).isEqualTo(userId); + + Map oldRow = jdbcClient.sql(""" + SELECT revoked_at, rotated_at, revoke_reason, + replaced_by_session_id::text AS replaced_by, + token_family_id::text AS family + FROM identity.auth_sessions WHERE id = :id + """) + .param("id", UUID.fromString((String) first.get("sessionId"))) + .query() + .singleRow(); + assertThat(oldRow.get("revoked_at")).isNotNull(); + assertThat(oldRow.get("rotated_at")).isNotNull(); + assertThat(oldRow.get("revoke_reason")).isEqualTo("rotated"); + assertThat(oldRow.get("replaced_by")).isEqualTo(newSessionId); + + String newFamily = jdbcClient.sql( + "SELECT token_family_id::text FROM identity.auth_sessions WHERE id = :id") + .param("id", UUID.fromString(newSessionId)) + .query(String.class) + .single(); + assertThat(newFamily).isEqualTo(oldRow.get("family")); + } + + @Test + void reuseOfRotatedTokenRevokesWholeFamily() throws Exception { + String userId = registerUser("sess_reuse"); + Map first = createSession(userId); + String rotatedAway = (String) first.get("refreshToken"); + + String current = JsonPath.read(refresh(rotatedAway), "$.data.refreshToken"); + + // Replay of the rotated token: rejected and the family is killed. + String reuse = refresh(rotatedAway); + assertThat((int) JsonPath.read(reuse, "$.code")).isEqualTo(40102); + + // The (previously valid) current token died with the family. + String afterKill = refresh(current); + assertThat((int) JsonPath.read(afterKill, "$.code")).isEqualTo(40102); + + Integer live = jdbcClient.sql(""" + SELECT count(*) FROM identity.auth_sessions + WHERE user_id = :userId AND revoked_at IS NULL + """) + .param("userId", UUID.fromString(userId)) + .query(Integer.class) + .single(); + assertThat(live).isZero(); + } + + @Test + void logoutRevokesOnlyTheCurrentSession() throws Exception { + String userId = registerUser("sess_logout"); + Map phone = createSession(userId); + Map tablet = createSession(userId); + + mockMvc.perform(internalPost("/internal/sessions/revoke") + .content("{\"userId\":\"%s\",\"refreshToken\":\"%s\"}" + .formatted(userId, phone.get("refreshToken")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + // The logged-out session's token is dead … + assertThat((int) JsonPath.read(refresh((String) phone.get("refreshToken")), "$.code")) + .isEqualTo(40102); + // … while the other device keeps working (ADR-003 multi-device). + assertThat((int) JsonPath.read(refresh((String) tablet.get("refreshToken")), "$.code")) + .isZero(); + } + + @Test + void logoutWithForeignUserIdDoesNotRevokeTheSession() throws Exception { + String owner = registerUser("sess_owner"); + String attacker = registerUser("sess_attacker"); + Map session = createSession(owner); + + mockMvc.perform(internalPost("/internal/sessions/revoke") + .content("{\"userId\":\"%s\",\"refreshToken\":\"%s\"}" + .formatted(attacker, session.get("refreshToken")))) + .andExpect(status().isOk()); + + assertThat((int) JsonPath.read(refresh((String) session.get("refreshToken")), "$.code")) + .isZero(); + } + + @Test + void expiredRefreshTokenIsRejected() throws Exception { + String userId = registerUser("sess_expired"); + Map session = createSession(userId); + jdbcClient.sql(""" + UPDATE identity.auth_sessions + SET expires_at = created_at + interval '1 millisecond' WHERE id = :id + """) + .param("id", UUID.fromString((String) session.get("sessionId"))) + .update(); + + assertThat((int) JsonPath.read(refresh((String) session.get("refreshToken")), "$.code")) + .isEqualTo(40102); + } + + @Test + void unknownRefreshTokenIsRejected() throws Exception { + String body = refresh("bm90LWEtcmVhbC10b2tlbi1hdC1hbGwtanVzdC1iYXNlNjQ"); + assertThat((int) JsonPath.read(body, "$.code")).isEqualTo(40102); + } +} diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/support/TestJwtKeys.java b/patbond-user/src/test/java/com/patbond/patbond/user/support/TestJwtKeys.java new file mode 100644 index 0000000..282af91 --- /dev/null +++ b/patbond-user/src/test/java/com/patbond/patbond/user/support/TestJwtKeys.java @@ -0,0 +1,59 @@ +package com.patbond.patbond.user.support; + +import io.jsonwebtoken.Jwts; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import java.util.UUID; + +/** + * Runtime-generated RSA material for JWT tests. Nothing here is committed + * key material (git-workflow: no credentials in the repository) — every test + * run mints a fresh pair and injects the public key via + * {@code @DynamicPropertySource}. + */ +public final class TestJwtKeys { + + public static final KeyPair KEY_PAIR = generate(); + /** A second pair, for signing tokens the service must reject. */ + public static final KeyPair WRONG_KEY_PAIR = generate(); + + private TestJwtKeys() { + } + + public static String publicPem() { + return "-----BEGIN PUBLIC KEY-----\n" + + Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded()) + + "\n-----END PUBLIC KEY-----"; + } + + /** Signs an access token the way patbond-auth does (sub/jti/sid/iat/exp). */ + public static String accessToken(PrivateKey key, UUID userId, Duration ttl) { + Instant now = Instant.now(); + return Jwts.builder() + .id(UUID.randomUUID().toString()) + .subject(userId.toString()) + .issuer("patbond-auth") + .claim("sid", UUID.randomUUID().toString()) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plus(ttl))) + .signWith(key, Jwts.SIG.RS256) + .compact(); + } + + private static KeyPair generate() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + return generator.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/patbond-user/src/test/resources/application.yml b/patbond-user/src/test/resources/application.yml new file mode 100644 index 0000000..df6e9e4 --- /dev/null +++ b/patbond-user/src/test/resources/application.yml @@ -0,0 +1,11 @@ +# Test-only configuration: keeps @SpringBootTest deterministic on a clean +# checkout, where the git-ignored main application.yml does not exist. The +# datasource comes from Testcontainers (@ServiceConnection); the JWT public +# key, when a test needs one, is generated at runtime and injected through +# @DynamicPropertySource — no key material is committed. +spring: + application: + name: patbond-user + +patbond: + internal-token: test-internal-token