Compare commits
2 Commits
bd20adc700
...
4dc3dcdfa3
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dc3dcdfa3 | |||
| 43ab6c5827 |
@@ -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`.
|
||||
|
||||
@@ -35,11 +35,52 @@
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-openfeign</artifactId>
|
||||
</dependency>
|
||||
<!-- Real HTTP client for Feign: the JDK HttpURLConnection default
|
||||
returns a null error stream on 401 replies to streamed POSTs, so
|
||||
downstream error envelopes (40100/40102…) were unreadable and
|
||||
collapsed to 503 (found by AuthE2eIntegrationTest). -->
|
||||
<dependency>
|
||||
<groupId>io.github.openfeign</groupId>
|
||||
<artifactId>feign-hc5</artifactId>
|
||||
</dependency>
|
||||
<!-- Access token issuing (RS256): jjwt is not in the Boot BOM, version
|
||||
pinned here and in patbond-user in step. -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- End-to-end vertical test: boots the real user service (against a
|
||||
Testcontainers postgres:18) in the same JVM and drives the full
|
||||
register → login → me → refresh → logout flow over HTTP. -->
|
||||
<dependency>
|
||||
<groupId>com.patbond.patbond</groupId>
|
||||
<artifactId>patbond-user</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.testcontainers</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package com.patbond.patbond.auth;
|
||||
|
||||
import com.patbond.patbond.auth.config.FeignInternalConfig;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
|
||||
@EnableFeignClients
|
||||
@EnableFeignClients(defaultConfiguration = FeignInternalConfig.class)
|
||||
@SpringBootApplication
|
||||
public class AuthApplication {
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.patbond.patbond.auth.client;
|
||||
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import com.patbond.patbond.common.session.CreateSessionRequest;
|
||||
import com.patbond.patbond.common.session.RefreshSessionRequest;
|
||||
import com.patbond.patbond.common.session.RevokeSessionRequest;
|
||||
import com.patbond.patbond.common.session.SessionTokens;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
/**
|
||||
* Session lifecycle API of patbond-user, the identity schema owner. The
|
||||
* X-Internal-Token header is attached by the interceptor in FeignConfig.
|
||||
*/
|
||||
@FeignClient(name = "patbond-user-sessions", url = "${patbond.user-service.url}")
|
||||
public interface SessionClient {
|
||||
|
||||
@PostMapping("/internal/sessions")
|
||||
ApiResponse<SessionTokens> create(@RequestBody CreateSessionRequest request);
|
||||
|
||||
@PostMapping("/internal/sessions/refresh")
|
||||
ApiResponse<SessionTokens> refresh(@RequestBody RefreshSessionRequest request);
|
||||
|
||||
@PostMapping("/internal/sessions/revoke")
|
||||
ApiResponse<Void> revoke(@RequestBody RevokeSessionRequest request);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import com.patbond.patbond.common.response.ApiResponse;
|
||||
import feign.Response;
|
||||
import feign.Util;
|
||||
import feign.codec.ErrorDecoder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -19,6 +21,8 @@ import java.io.IOException;
|
||||
*/
|
||||
public class ApiErrorDecoder implements ErrorDecoder {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ApiErrorDecoder.class);
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ApiErrorDecoder(ObjectMapper objectMapper) {
|
||||
@@ -35,8 +39,10 @@ public class ApiErrorDecoder implements ErrorDecoder {
|
||||
return new BusinessException(envelope.getCode(), response.status(), envelope.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException ignored) {
|
||||
// Not a Patbond envelope; fall through to the generic error below.
|
||||
} catch (IOException | RuntimeException e) {
|
||||
// Not a Patbond envelope; report as DOWNSTREAM_UNAVAILABLE below.
|
||||
// The body itself is not logged (it may echo request data).
|
||||
log.warn("Undecodable {} reply from {}: {}", response.status(), methodKey, e.toString());
|
||||
}
|
||||
return new BusinessException(ErrorCode.DOWNSTREAM_UNAVAILABLE);
|
||||
}
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.patbond.patbond.auth.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Security knobs of the auth service. ADR-003 mandates configurable token
|
||||
* lifetimes; the committed default is the ADR value (access 15 minutes).
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "patbond")
|
||||
public class AuthSecurityProperties {
|
||||
|
||||
/** Shared secret sent as X-Internal-Token on every call to patbond-user. */
|
||||
private String internalToken;
|
||||
|
||||
private final Jwt jwt = new Jwt();
|
||||
|
||||
public String getInternalToken() {
|
||||
return internalToken;
|
||||
}
|
||||
|
||||
public void setInternalToken(String internalToken) {
|
||||
this.internalToken = internalToken;
|
||||
}
|
||||
|
||||
public Jwt getJwt() {
|
||||
return jwt;
|
||||
}
|
||||
|
||||
public static class Jwt {
|
||||
|
||||
/**
|
||||
* RS256 private key (PKCS#8): either inline PEM (starts with
|
||||
* -----BEGIN) or a filesystem path. Injected via environment
|
||||
* variable; the key never enters the repository.
|
||||
*/
|
||||
private String privateKey;
|
||||
|
||||
/** Access token lifetime (ADR-003: 15 minutes). */
|
||||
private Duration accessTtl = Duration.ofMinutes(15);
|
||||
|
||||
private String issuer = "patbond-auth";
|
||||
|
||||
public String getPrivateKey() {
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
public void setPrivateKey(String privateKey) {
|
||||
this.privateKey = privateKey;
|
||||
}
|
||||
|
||||
public Duration getAccessTtl() {
|
||||
return accessTtl;
|
||||
}
|
||||
|
||||
public void setAccessTtl(Duration accessTtl) {
|
||||
this.accessTtl = accessTtl;
|
||||
}
|
||||
|
||||
public String getIssuer() {
|
||||
return issuer;
|
||||
}
|
||||
|
||||
public void setIssuer(String issuer) {
|
||||
this.issuer = issuer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.patbond.patbond.auth.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import feign.codec.ErrorDecoder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
public class FeignConfig {
|
||||
|
||||
@Bean
|
||||
public ErrorDecoder apiErrorDecoder(ObjectMapper objectMapper) {
|
||||
return new ApiErrorDecoder(objectMapper);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.patbond.patbond.auth.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import feign.RequestInterceptor;
|
||||
import feign.codec.ErrorDecoder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
|
||||
/**
|
||||
* Registered via {@code @EnableFeignClients(defaultConfiguration = …)} so
|
||||
* these beans land INSIDE each Feign child context. Deliberately not
|
||||
* annotated with @Configuration: a component-scanned ErrorDecoder only
|
||||
* reaches the parent context, where the child's own @ConditionalOnMissingBean
|
||||
* default shadows it — downstream business errors would silently collapse to
|
||||
* 503 on real HTTP calls (caught by AuthE2eIntegrationTest).
|
||||
*/
|
||||
public class FeignInternalConfig {
|
||||
|
||||
/**
|
||||
* Re-raises downstream {code, message} envelopes as BusinessException so
|
||||
* the user service's business code and HTTP status reach the client
|
||||
* unchanged (audit issue M1).
|
||||
*/
|
||||
@Bean
|
||||
public ErrorDecoder apiErrorDecoder(ObjectMapper objectMapper) {
|
||||
return new ApiErrorDecoder(objectMapper);
|
||||
}
|
||||
|
||||
/** Presents the shared service secret on every call to patbond-user. */
|
||||
@Bean
|
||||
public RequestInterceptor internalTokenInterceptor(AuthSecurityProperties properties) {
|
||||
return template -> template.header("X-Internal-Token", properties.getInternalToken());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.patbond.patbond.auth.config;
|
||||
|
||||
import com.patbond.patbond.auth.security.JwtSigner;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Application-context security wiring; the Feign-specific beans live in
|
||||
* {@link FeignInternalConfig} (see the note there).
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(AuthSecurityProperties.class)
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public JwtSigner jwtSigner(AuthSecurityProperties properties) {
|
||||
return new JwtSigner(
|
||||
properties.getJwt().getPrivateKey(),
|
||||
properties.getJwt().getAccessTtl(),
|
||||
properties.getJwt().getIssuer());
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,23 @@ package com.patbond.patbond.auth.controller;
|
||||
|
||||
import com.patbond.patbond.auth.dto.AuthTokenResponse;
|
||||
import com.patbond.patbond.auth.dto.LoginRequest;
|
||||
import com.patbond.patbond.auth.dto.LogoutRequest;
|
||||
import com.patbond.patbond.auth.dto.RefreshRequest;
|
||||
import com.patbond.patbond.auth.dto.RegisterRequest;
|
||||
import com.patbond.patbond.auth.service.AuthService;
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Public auth endpoints under the /api/v1 prefix (development-plan 6.2). */
|
||||
@RestController
|
||||
@RequestMapping("/auth")
|
||||
@RequestMapping("/api/v1/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
@@ -22,12 +28,39 @@ public class AuthController {
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ApiResponse<AuthTokenResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
return ApiResponse.success(authService.register(request));
|
||||
public ApiResponse<AuthTokenResponse> register(@Valid @RequestBody RegisterRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
return ApiResponse.success(authService.register(request, clientInfo(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<AuthTokenResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||
return ApiResponse.success(authService.login(request));
|
||||
public ApiResponse<AuthTokenResponse> login(@Valid @RequestBody LoginRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
return ApiResponse.success(authService.login(request, clientInfo(httpRequest)));
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
public ApiResponse<AuthTokenResponse> refresh(@Valid @RequestBody RefreshRequest request) {
|
||||
return ApiResponse.success(authService.refresh(request));
|
||||
}
|
||||
|
||||
@PostMapping("/logout")
|
||||
public ApiResponse<Void> logout(
|
||||
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
|
||||
@Valid @RequestBody LogoutRequest request) {
|
||||
authService.logout(authorization, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
private AuthService.ClientInfo clientInfo(HttpServletRequest request) {
|
||||
String userAgent = request.getHeader(HttpHeaders.USER_AGENT);
|
||||
if (userAgent != null && userAgent.length() > 512) {
|
||||
userAgent = userAgent.substring(0, 512);
|
||||
}
|
||||
String forwarded = request.getHeader("X-Forwarded-For");
|
||||
String ip = forwarded != null && !forwarded.isBlank()
|
||||
? forwarded.split(",")[0].trim()
|
||||
: request.getRemoteAddr();
|
||||
return new AuthService.ClientInfo(userAgent, ip);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
package com.patbond.patbond.auth.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Frozen public contract for register/login/refresh responses — exactly
|
||||
* {userId, tokenType, accessToken, accessTokenExpiresAt, refreshToken,
|
||||
* refreshTokenExpiresAt}; timestamps serialize as ISO 8601 with offset.
|
||||
*/
|
||||
public class AuthTokenResponse {
|
||||
|
||||
private UUID userId;
|
||||
private String tokenType;
|
||||
private String accessToken;
|
||||
private LocalDateTime expiresAt;
|
||||
private UUID userId;
|
||||
private String username;
|
||||
private String nickname;
|
||||
private OffsetDateTime accessTokenExpiresAt;
|
||||
private String refreshToken;
|
||||
private OffsetDateTime refreshTokenExpiresAt;
|
||||
|
||||
public AuthTokenResponse(String tokenType, String accessToken, LocalDateTime expiresAt,
|
||||
UUID userId, String username, String nickname) {
|
||||
public AuthTokenResponse(UUID userId, String tokenType,
|
||||
String accessToken, OffsetDateTime accessTokenExpiresAt,
|
||||
String refreshToken, OffsetDateTime refreshTokenExpiresAt) {
|
||||
this.userId = userId;
|
||||
this.tokenType = tokenType;
|
||||
this.accessToken = accessToken;
|
||||
this.expiresAt = expiresAt;
|
||||
this.userId = userId;
|
||||
this.username = username;
|
||||
this.nickname = nickname;
|
||||
this.accessTokenExpiresAt = accessTokenExpiresAt;
|
||||
this.refreshToken = refreshToken;
|
||||
this.refreshTokenExpiresAt = refreshTokenExpiresAt;
|
||||
}
|
||||
|
||||
public UUID getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getTokenType() {
|
||||
@@ -30,19 +40,15 @@ public class AuthTokenResponse {
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
public LocalDateTime getExpiresAt() {
|
||||
return expiresAt;
|
||||
public OffsetDateTime getAccessTokenExpiresAt() {
|
||||
return accessTokenExpiresAt;
|
||||
}
|
||||
|
||||
public UUID getUserId() {
|
||||
return userId;
|
||||
public String getRefreshToken() {
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String getNickname() {
|
||||
return nickname;
|
||||
public OffsetDateTime getRefreshTokenExpiresAt() {
|
||||
return refreshTokenExpiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.patbond.patbond.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class LogoutRequest {
|
||||
|
||||
@NotBlank(message = "refreshToken 不能为空")
|
||||
private String refreshToken;
|
||||
|
||||
public String getRefreshToken() {
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
public void setRefreshToken(String refreshToken) {
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.patbond.patbond.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public class RefreshRequest {
|
||||
|
||||
@NotBlank(message = "refreshToken 不能为空")
|
||||
private String refreshToken;
|
||||
|
||||
public String getRefreshToken() {
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
public void setRefreshToken(String refreshToken) {
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.patbond.patbond.auth.security;
|
||||
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import io.jsonwebtoken.JwtParser;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
|
||||
import java.security.interfaces.RSAPrivateCrtKey;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Issues (and, for logout, verifies) RS256 access tokens. Claims: sub = user
|
||||
* UUID, jti = the id minted by patbond-user and stored on the session row
|
||||
* (auth_sessions.access_token_jti), sid = session UUID, plus iss/iat/exp.
|
||||
* Resource services verify these tokens locally with the public key only —
|
||||
* the private key never leaves this service.
|
||||
*/
|
||||
public class JwtSigner {
|
||||
|
||||
public static final String SESSION_ID_CLAIM = "sid";
|
||||
|
||||
private final RSAPrivateCrtKey privateKey;
|
||||
private final Duration accessTtl;
|
||||
private final String issuer;
|
||||
private final JwtParser parser;
|
||||
|
||||
public record AccessToken(String token, OffsetDateTime expiresAt) {
|
||||
}
|
||||
|
||||
public JwtSigner(String privateKeyLocation, Duration accessTtl, String issuer) {
|
||||
if (privateKeyLocation == null || privateKeyLocation.isBlank()) {
|
||||
throw new IllegalStateException(
|
||||
"patbond.jwt.private-key 未配置:请生成 RS256 密钥对并通过环境变量注入"
|
||||
+ "(见 application.yml.sample)");
|
||||
}
|
||||
this.privateKey = RsaPrivateKeyLoader.load(privateKeyLocation);
|
||||
this.accessTtl = accessTtl;
|
||||
this.issuer = issuer;
|
||||
this.parser = Jwts.parser()
|
||||
.verifyWith(RsaPrivateKeyLoader.derivePublicKey(privateKey))
|
||||
.build();
|
||||
}
|
||||
|
||||
public AccessToken sign(UUID userId, UUID sessionId, String jti) {
|
||||
Instant now = Instant.now();
|
||||
Instant expiresAt = now.plus(accessTtl);
|
||||
String token = Jwts.builder()
|
||||
.id(jti)
|
||||
.subject(userId.toString())
|
||||
.issuer(issuer)
|
||||
.claim(SESSION_ID_CLAIM, sessionId.toString())
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(expiresAt))
|
||||
.signWith(privateKey, Jwts.SIG.RS256)
|
||||
.compact();
|
||||
return new AccessToken(token, OffsetDateTime.ofInstant(expiresAt, ZoneOffset.UTC));
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BusinessException 40101 when the token is forged, malformed or expired
|
||||
*/
|
||||
public Claims verify(String token) {
|
||||
try {
|
||||
return parser.parseSignedClaims(token).getPayload();
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
throw new BusinessException(ErrorCode.TOKEN_INVALID);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.patbond.patbond.auth.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.interfaces.RSAPrivateCrtKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.RSAPublicKeySpec;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Loads the RS256 signing key from either inline PEM content (value starts
|
||||
* with -----BEGIN, e.g. injected through an environment variable) or a
|
||||
* filesystem path to a PEM file. Only the PKCS#8 form produced by
|
||||
* `openssl genpkey` is supported. The matching public key is derived from
|
||||
* the CRT parameters, so this service needs no second configuration value.
|
||||
*/
|
||||
public final class RsaPrivateKeyLoader {
|
||||
|
||||
private RsaPrivateKeyLoader() {
|
||||
}
|
||||
|
||||
public static RSAPrivateCrtKey load(String pemOrPath) {
|
||||
String pem = pemOrPath.trim();
|
||||
if (!pem.startsWith("-----BEGIN")) {
|
||||
try {
|
||||
pem = Files.readString(Path.of(pem));
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("无法读取 JWT 私钥文件: " + pemOrPath, e);
|
||||
}
|
||||
}
|
||||
String base64 = pem
|
||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
try {
|
||||
byte[] der = Base64.getDecoder().decode(base64);
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
return (RSAPrivateCrtKey) keyFactory.generatePrivate(new PKCS8EncodedKeySpec(der));
|
||||
} catch (IllegalArgumentException | ClassCastException
|
||||
| NoSuchAlgorithmException | InvalidKeySpecException e) {
|
||||
throw new IllegalStateException("JWT 私钥不是有效的 PEM(PKCS#8) RSA 私钥", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static RSAPublicKey derivePublicKey(RSAPrivateCrtKey privateKey) {
|
||||
try {
|
||||
return (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(
|
||||
new RSAPublicKeySpec(privateKey.getModulus(), privateKey.getPublicExponent()));
|
||||
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
|
||||
throw new IllegalStateException("无法从 RSA 私钥推导公钥", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,53 @@
|
||||
package com.patbond.patbond.auth.service;
|
||||
|
||||
import com.patbond.patbond.auth.client.SessionClient;
|
||||
import com.patbond.patbond.auth.client.UserClient;
|
||||
import com.patbond.patbond.auth.dto.AuthTokenResponse;
|
||||
import com.patbond.patbond.auth.dto.LoginRequest;
|
||||
import com.patbond.patbond.auth.dto.LogoutRequest;
|
||||
import com.patbond.patbond.auth.dto.RefreshRequest;
|
||||
import com.patbond.patbond.auth.dto.RegisterRequest;
|
||||
import com.patbond.patbond.auth.security.JwtSigner;
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import com.patbond.patbond.common.session.CreateSessionRequest;
|
||||
import com.patbond.patbond.common.session.RefreshSessionRequest;
|
||||
import com.patbond.patbond.common.session.RevokeSessionRequest;
|
||||
import com.patbond.patbond.common.session.SessionTokens;
|
||||
import com.patbond.patbond.common.user.CreateUserRequest;
|
||||
import com.patbond.patbond.common.user.UserProfile;
|
||||
import com.patbond.patbond.common.user.VerifyPasswordRequest;
|
||||
import com.patbond.patbond.common.user.VerifyPasswordResponse;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Thin public entry for authentication (per the adopted architecture from
|
||||
* the iteration-1 technical assessment): credentials and sessions live in
|
||||
* patbond-user, the identity schema owner; this service validates input,
|
||||
* orchestrates the internal calls, and signs RS256 access tokens.
|
||||
*/
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
private final UserClient userClient;
|
||||
|
||||
public AuthService(UserClient userClient) {
|
||||
this.userClient = userClient;
|
||||
/** Device metadata forwarded to the session record (observability only). */
|
||||
public record ClientInfo(String userAgent, String ipAddress) {
|
||||
}
|
||||
|
||||
public AuthTokenResponse register(RegisterRequest request) {
|
||||
private final UserClient userClient;
|
||||
private final SessionClient sessionClient;
|
||||
private final JwtSigner jwtSigner;
|
||||
|
||||
public AuthService(UserClient userClient, SessionClient sessionClient, JwtSigner jwtSigner) {
|
||||
this.userClient = userClient;
|
||||
this.sessionClient = sessionClient;
|
||||
this.jwtSigner = jwtSigner;
|
||||
}
|
||||
|
||||
public AuthTokenResponse register(RegisterRequest request, ClientInfo clientInfo) {
|
||||
ApiResponse<UserProfile> response = userClient.createUser(new CreateUserRequest(
|
||||
request.getUsername(),
|
||||
request.getPassword(),
|
||||
@@ -33,28 +55,63 @@ public class AuthService {
|
||||
request.getPhone()
|
||||
));
|
||||
UserProfile user = requireData(response, "注册失败");
|
||||
return buildToken(user.getId(), user.getUsername(), user.getNickname());
|
||||
return openSession(user.getId(), clientInfo);
|
||||
}
|
||||
|
||||
public AuthTokenResponse login(LoginRequest request) {
|
||||
public AuthTokenResponse login(LoginRequest request, ClientInfo clientInfo) {
|
||||
ApiResponse<VerifyPasswordResponse> response = userClient.verifyPassword(
|
||||
new VerifyPasswordRequest(request.getUsername(), request.getPassword())
|
||||
);
|
||||
VerifyPasswordResponse user = requireData(response, "登录失败");
|
||||
return buildToken(user.getUserId(), user.getUsername(), user.getNickname());
|
||||
return openSession(user.getUserId(), clientInfo);
|
||||
}
|
||||
|
||||
private AuthTokenResponse buildToken(UUID userId, String username, String nickname) {
|
||||
/** ADR-003: every refresh rotates the refresh token (handled downstream). */
|
||||
public AuthTokenResponse refresh(RefreshRequest request) {
|
||||
SessionTokens tokens = requireData(
|
||||
sessionClient.refresh(new RefreshSessionRequest(request.getRefreshToken())),
|
||||
"刷新失败");
|
||||
return assemble(tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR-003: logout revokes only the current session. The user is taken
|
||||
* from the verified access token, so a caller can only revoke their own
|
||||
* session; an invalid or expired access token answers 40101.
|
||||
*/
|
||||
public void logout(String authorizationHeader, LogoutRequest request) {
|
||||
Claims claims = requireBearer(authorizationHeader);
|
||||
UUID userId = UUID.fromString(claims.getSubject());
|
||||
requireSuccess(sessionClient.revoke(
|
||||
new RevokeSessionRequest(userId, request.getRefreshToken())), "退出失败");
|
||||
}
|
||||
|
||||
private AuthTokenResponse openSession(UUID userId, ClientInfo clientInfo) {
|
||||
SessionTokens tokens = requireData(sessionClient.create(new CreateSessionRequest(
|
||||
userId, clientInfo.userAgent(), clientInfo.ipAddress())), "创建会话失败");
|
||||
return assemble(tokens);
|
||||
}
|
||||
|
||||
private AuthTokenResponse assemble(SessionTokens tokens) {
|
||||
JwtSigner.AccessToken accessToken =
|
||||
jwtSigner.sign(tokens.getUserId(), tokens.getSessionId(), tokens.getJti());
|
||||
return new AuthTokenResponse(
|
||||
tokens.getUserId(),
|
||||
"Bearer",
|
||||
UUID.randomUUID().toString().replace("-", ""),
|
||||
LocalDateTime.now().plusHours(2),
|
||||
userId,
|
||||
username,
|
||||
nickname
|
||||
accessToken.token(),
|
||||
accessToken.expiresAt(),
|
||||
tokens.getRefreshToken(),
|
||||
tokens.getRefreshTokenExpiresAt()
|
||||
);
|
||||
}
|
||||
|
||||
private Claims requireBearer(String authorizationHeader) {
|
||||
if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
|
||||
throw new BusinessException(ErrorCode.TOKEN_INVALID);
|
||||
}
|
||||
return jwtSigner.verify(authorizationHeader.substring("Bearer ".length()).trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Downstream business failures arrive as BusinessException via the Feign
|
||||
* ErrorDecoder and never reach this method; this only guards against a
|
||||
@@ -67,4 +124,12 @@ public class AuthService {
|
||||
}
|
||||
return response.getData();
|
||||
}
|
||||
|
||||
/** Same guard for envelopes that legitimately carry no data (revoke). */
|
||||
private void requireSuccess(ApiResponse<Void> response, String defaultMessage) {
|
||||
if (response == null || !response.isSuccess()) {
|
||||
String message = response == null || response.getMessage() == null ? defaultMessage : response.getMessage();
|
||||
throw new BusinessException(ErrorCode.INTERNAL_ERROR, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,18 @@ spring:
|
||||
patbond:
|
||||
user-service:
|
||||
url: ${PATBOND_USER_SERVICE_URL:http://127.0.0.1:8082}
|
||||
# /internal/** 服务间共享密钥,需与 patbond-user 配置同一值;生产环境必须
|
||||
# 通过 PATBOND_INTERNAL_TOKEN 注入强随机值(如 `openssl rand -hex 32`)。
|
||||
internal-token: ${PATBOND_INTERNAL_TOKEN:dev-only-internal-token}
|
||||
jwt:
|
||||
# RS256 私钥(PKCS#8),用于签发 access token;对应公钥配置给 patbond-user。
|
||||
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
|
||||
# 生成密钥对(私钥绝不提交进仓库):
|
||||
# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt-private.pem
|
||||
# openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
|
||||
# 然后:export PATBOND_JWT_PRIVATE_KEY=/path/to/jwt-private.pem
|
||||
# 未配置时服务启动失败(fail-fast)。
|
||||
private-key: ${PATBOND_JWT_PRIVATE_KEY:}
|
||||
# ADR-003:access token 15 分钟,可配置。
|
||||
access-ttl: ${PATBOND_ACCESS_TTL:15m}
|
||||
issuer: patbond-auth
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
package com.patbond.patbond.auth;
|
||||
|
||||
import com.patbond.patbond.auth.support.SpringTestSupport;
|
||||
import com.patbond.patbond.auth.support.TestJwtKeys;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
|
||||
@SpringBootTest
|
||||
@SpringBootTest(properties = SpringTestSupport.EXCLUDE_JDBC_AUTOCONFIG)
|
||||
class AuthApplicationTests {
|
||||
|
||||
@DynamicPropertySource
|
||||
static void jwtKey(DynamicPropertyRegistry registry) {
|
||||
registry.add("patbond.jwt.private-key", TestJwtKeys::privatePem);
|
||||
}
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
// Verifies the auth service starts with the committed application.yml:
|
||||
// the Feign client resolves patbond.user-service.url from the config
|
||||
// default without Nacos or a running user service (ADR-002).
|
||||
// Verifies the auth service starts with the committed configuration:
|
||||
// the Feign clients resolve patbond.user-service.url without Nacos or
|
||||
// a running user service (ADR-002), and the JwtSigner comes up from
|
||||
// an injected private key (here: generated per test run).
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package com.patbond.patbond.auth;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.auth.support.SpringTestSupport;
|
||||
import com.patbond.patbond.auth.support.TestJwtKeys;
|
||||
import com.patbond.patbond.user.UserApplication;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* End-to-end vertical over real HTTP: this class boots the actual user
|
||||
* service (Flyway V1 on a clean Testcontainers postgres:18) in the same JVM
|
||||
* and drives the full M1 acceptance flow through the auth service —
|
||||
* register → me → refresh (rotation) → reuse rejected + family revoked →
|
||||
* login → logout → refresh dead. Also pins 40101 for expired/forged access
|
||||
* tokens, 401 for /internal without the service credential, and the login
|
||||
* failure lockout.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = SpringTestSupport.EXCLUDE_JDBC_AUTOCONFIG)
|
||||
class AuthE2eIntegrationTest {
|
||||
|
||||
private static final String INTERNAL_TOKEN = "e2e-internal-token";
|
||||
|
||||
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:18");
|
||||
private static ConfigurableApplicationContext userApp;
|
||||
private static String userBaseUrl;
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void bootUserServiceAndWireAuth(DynamicPropertyRegistry registry) {
|
||||
POSTGRES.start();
|
||||
userApp = new SpringApplicationBuilder(UserApplication.class).run(
|
||||
"--server.port=0",
|
||||
"--spring.application.name=patbond-user",
|
||||
"--spring.datasource.url=" + POSTGRES.getJdbcUrl(),
|
||||
"--spring.datasource.username=" + POSTGRES.getUsername(),
|
||||
"--spring.datasource.password=" + POSTGRES.getPassword(),
|
||||
"--patbond.internal-token=" + INTERNAL_TOKEN,
|
||||
"--patbond.jwt.public-key=" + TestJwtKeys.publicPem(),
|
||||
"--patbond.login-lock.max-failures=3");
|
||||
userBaseUrl = "http://127.0.0.1:" + userApp.getEnvironment().getProperty("local.server.port");
|
||||
|
||||
registry.add("patbond.user-service.url", () -> userBaseUrl);
|
||||
registry.add("patbond.internal-token", () -> INTERNAL_TOKEN);
|
||||
registry.add("patbond.jwt.private-key", TestJwtKeys::privatePem);
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void shutdown() {
|
||||
if (userApp != null) {
|
||||
userApp.close();
|
||||
}
|
||||
POSTGRES.stop();
|
||||
}
|
||||
|
||||
private ResponseEntity<String> postJson(String url, String body, String bearerToken) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
if (bearerToken != null) {
|
||||
headers.setBearerAuth(bearerToken);
|
||||
}
|
||||
return restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(body, headers), String.class);
|
||||
}
|
||||
|
||||
private ResponseEntity<String> getWithBearer(String url, String bearerToken) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
if (bearerToken != null) {
|
||||
headers.setBearerAuth(bearerToken);
|
||||
}
|
||||
return restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(headers), String.class);
|
||||
}
|
||||
|
||||
private String register(String username, String phone) {
|
||||
ResponseEntity<String> response = postJson("/api/v1/auth/register",
|
||||
"{\"username\":\"%s\",\"phone\":\"%s\",\"password\":\"secret123\"}"
|
||||
.formatted(username, phone), null);
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
return response.getBody();
|
||||
}
|
||||
|
||||
private ResponseEntity<String> login(String username, String password) {
|
||||
return postJson("/api/v1/auth/login",
|
||||
"{\"username\":\"%s\",\"password\":\"%s\"}".formatted(username, password), null);
|
||||
}
|
||||
|
||||
private ResponseEntity<String> refresh(String refreshToken) {
|
||||
return postJson("/api/v1/auth/refresh",
|
||||
"{\"refreshToken\":\"%s\"}".formatted(refreshToken), null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullAuthVerticalFlow() {
|
||||
// Register: frozen contract shape, ISO 8601 timestamps with offset.
|
||||
String registered = register("e2e_alice", "+8613800000501");
|
||||
String userId = JsonPath.read(registered, "$.data.userId");
|
||||
String accessToken = JsonPath.read(registered, "$.data.accessToken");
|
||||
String refreshToken = JsonPath.read(registered, "$.data.refreshToken");
|
||||
assertThat((String) JsonPath.read(registered, "$.data.tokenType")).isEqualTo("Bearer");
|
||||
OffsetDateTime accessExpiry =
|
||||
OffsetDateTime.parse(JsonPath.read(registered, "$.data.accessTokenExpiresAt"));
|
||||
OffsetDateTime refreshExpiry =
|
||||
OffsetDateTime.parse(JsonPath.read(registered, "$.data.refreshTokenExpiresAt"));
|
||||
OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC);
|
||||
assertThat(accessExpiry).isAfter(now.plusMinutes(13)).isBefore(now.plusMinutes(17));
|
||||
assertThat(refreshExpiry).isAfter(now.plusDays(29));
|
||||
|
||||
// Me on the user service, authenticated purely by local RS256 verification.
|
||||
ResponseEntity<String> me = getWithBearer(userBaseUrl + "/api/v1/me", accessToken);
|
||||
assertThat(me.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat((String) JsonPath.read(me.getBody(), "$.data.userId")).isEqualTo(userId);
|
||||
assertThat((String) JsonPath.read(me.getBody(), "$.data.username")).isEqualTo("e2e_alice");
|
||||
assertThat((String) JsonPath.read(me.getBody(), "$.data.phone")).isEqualTo("+8613800000501");
|
||||
assertThat((String) JsonPath.read(me.getBody(), "$.data.createdAt")).contains("T");
|
||||
|
||||
// Refresh rotates the pair.
|
||||
ResponseEntity<String> rotated = refresh(refreshToken);
|
||||
assertThat(rotated.getStatusCode().value()).isEqualTo(200);
|
||||
String rotatedRefresh = JsonPath.read(rotated.getBody(), "$.data.refreshToken");
|
||||
assertThat(rotatedRefresh).isNotEqualTo(refreshToken);
|
||||
assertThat((String) JsonPath.read(rotated.getBody(), "$.data.userId")).isEqualTo(userId);
|
||||
|
||||
// Replaying the rotated-away token is rejected and kills the family …
|
||||
ResponseEntity<String> reuse = refresh(refreshToken);
|
||||
assertThat(reuse.getStatusCode().value()).isEqualTo(401);
|
||||
assertThat((int) JsonPath.read(reuse.getBody(), "$.code")).isEqualTo(40102);
|
||||
|
||||
// … including the freshly rotated token.
|
||||
ResponseEntity<String> familyDead = refresh(rotatedRefresh);
|
||||
assertThat(familyDead.getStatusCode().value()).isEqualTo(401);
|
||||
assertThat((int) JsonPath.read(familyDead.getBody(), "$.code")).isEqualTo(40102);
|
||||
|
||||
// Login again (new family), then logout revokes that session.
|
||||
ResponseEntity<String> reLogin = login("e2e_alice", "secret123");
|
||||
assertThat(reLogin.getStatusCode().value()).isEqualTo(200);
|
||||
String access2 = JsonPath.read(reLogin.getBody(), "$.data.accessToken");
|
||||
String refresh2 = JsonPath.read(reLogin.getBody(), "$.data.refreshToken");
|
||||
|
||||
ResponseEntity<String> logout = postJson("/api/v1/auth/logout",
|
||||
"{\"refreshToken\":\"%s\"}".formatted(refresh2), access2);
|
||||
assertThat(logout.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat((int) JsonPath.read(logout.getBody(), "$.code")).isZero();
|
||||
|
||||
ResponseEntity<String> afterLogout = refresh(refresh2);
|
||||
assertThat(afterLogout.getStatusCode().value()).isEqualTo(401);
|
||||
assertThat((int) JsonPath.read(afterLogout.getBody(), "$.code")).isEqualTo(40102);
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredAndForgedAccessTokensAnswer40101() {
|
||||
String registered = register("e2e_bob", "+8613800000502");
|
||||
String userId = JsonPath.read(registered, "$.data.userId");
|
||||
|
||||
String expired = signToken(TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(-1));
|
||||
ResponseEntity<String> expiredMe = getWithBearer(userBaseUrl + "/api/v1/me", expired);
|
||||
assertThat(expiredMe.getStatusCode().value()).isEqualTo(401);
|
||||
assertThat((int) JsonPath.read(expiredMe.getBody(), "$.code")).isEqualTo(40101);
|
||||
|
||||
String forged = signToken(TestJwtKeys.WRONG_KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
|
||||
ResponseEntity<String> forgedMe = getWithBearer(userBaseUrl + "/api/v1/me", forged);
|
||||
assertThat(forgedMe.getStatusCode().value()).isEqualTo(401);
|
||||
assertThat((int) JsonPath.read(forgedMe.getBody(), "$.code")).isEqualTo(40101);
|
||||
}
|
||||
|
||||
@Test
|
||||
void internalEndpointsRejectCallsWithoutTheServiceCredential() {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
userBaseUrl + "/internal/users", HttpMethod.POST,
|
||||
new HttpEntity<>("{\"username\":\"e2e_intruder\",\"password\":\"secret123\"}", headers),
|
||||
String.class);
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void repeatedLoginFailuresLockTheAccount() {
|
||||
register("e2e_carol", "+8613800000503");
|
||||
for (int i = 0; i < 3; i++) {
|
||||
ResponseEntity<String> wrong = login("e2e_carol", "wrong-password");
|
||||
assertThat(wrong.getStatusCode().value()).isEqualTo(401);
|
||||
assertThat((int) JsonPath.read(wrong.getBody(), "$.code")).isEqualTo(40100);
|
||||
}
|
||||
ResponseEntity<String> locked = login("e2e_carol", "secret123");
|
||||
assertThat(locked.getStatusCode().value()).isEqualTo(423);
|
||||
assertThat((int) JsonPath.read(locked.getBody(), "$.code")).isEqualTo(42300);
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutOnOneDeviceKeepsOtherDevicesLoggedIn() {
|
||||
String device1 = register("e2e_dave", "+8613800000504");
|
||||
String refresh1 = JsonPath.read(device1, "$.data.refreshToken");
|
||||
|
||||
ResponseEntity<String> device2 = login("e2e_dave", "secret123");
|
||||
String access2 = JsonPath.read(device2.getBody(), "$.data.accessToken");
|
||||
String refresh2 = JsonPath.read(device2.getBody(), "$.data.refreshToken");
|
||||
|
||||
ResponseEntity<String> logout = postJson("/api/v1/auth/logout",
|
||||
"{\"refreshToken\":\"%s\"}".formatted(refresh2), access2);
|
||||
assertThat(logout.getStatusCode().value()).isEqualTo(200);
|
||||
|
||||
// Device 2's session is gone, device 1 refreshes on unaffected.
|
||||
assertThat(refresh(refresh2).getStatusCode().value()).isEqualTo(401);
|
||||
ResponseEntity<String> stillAlive = refresh(refresh1);
|
||||
assertThat(stillAlive.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat((int) JsonPath.read(stillAlive.getBody(), "$.code")).isZero();
|
||||
}
|
||||
|
||||
private static String signToken(java.security.PrivateKey key, String userId, Duration ttl) {
|
||||
Instant now = Instant.now();
|
||||
return Jwts.builder()
|
||||
.id(UUID.randomUUID().toString())
|
||||
.subject(userId)
|
||||
.issuer("patbond-auth")
|
||||
.claim("sid", UUID.randomUUID().toString())
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plus(ttl)))
|
||||
.signWith(key, Jwts.SIG.RS256)
|
||||
.compact();
|
||||
}
|
||||
}
|
||||
+169
-47
@@ -1,74 +1,115 @@
|
||||
package com.patbond.patbond.auth.controller;
|
||||
|
||||
import com.patbond.patbond.auth.client.SessionClient;
|
||||
import com.patbond.patbond.auth.client.UserClient;
|
||||
import com.patbond.patbond.auth.security.JwtSigner;
|
||||
import com.patbond.patbond.auth.support.SpringTestSupport;
|
||||
import com.patbond.patbond.auth.support.TestJwtKeys;
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import com.patbond.patbond.common.session.RevokeSessionRequest;
|
||||
import com.patbond.patbond.common.session.SessionTokens;
|
||||
import com.patbond.patbond.common.user.UserProfile;
|
||||
import com.patbond.patbond.common.user.VerifyPasswordResponse;
|
||||
import feign.FeignException;
|
||||
import feign.Request;
|
||||
import feign.Response;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.matchesPattern;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.http.MediaType.APPLICATION_JSON;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* MockMvc tests for the auth endpoints. The Feign UserClient is replaced with
|
||||
* a Mockito mock so no user-service process is required. Downstream business
|
||||
* failures are simulated as the BusinessException the ApiErrorDecoder raises,
|
||||
* so these tests pin the FIXED error contract: 409/401/400 pass through to
|
||||
* the client with stable business codes instead of collapsing to 400/500
|
||||
* (audit issue M1); transport-level Feign failures answer 503.
|
||||
* MockMvc tests for the public /api/v1/auth endpoints. The Feign clients are
|
||||
* Mockito mocks, so no user-service process is required; downstream business
|
||||
* failures are simulated as the BusinessException the ApiErrorDecoder raises.
|
||||
* These tests pin the FROZEN response contract: data carries exactly
|
||||
* {userId, tokenType, accessToken, accessTokenExpiresAt, refreshToken,
|
||||
* refreshTokenExpiresAt}, timestamps are ISO 8601 with offset, and error
|
||||
* codes pass through unchanged (40100/40102/40900/42300…).
|
||||
*/
|
||||
@SpringBootTest
|
||||
@SpringBootTest(properties = SpringTestSupport.EXCLUDE_JDBC_AUTOCONFIG)
|
||||
@AutoConfigureMockMvc
|
||||
class AuthControllerTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@MockitoBean
|
||||
private UserClient userClient;
|
||||
|
||||
private static final UUID USER_ID = UUID.fromString("019212aa-0000-7000-8000-000000000001");
|
||||
private static final UUID SESSION_ID = UUID.fromString("019212aa-0000-7000-8000-000000000002");
|
||||
/** ISO 8601 with a UTC offset, e.g. 2026-09-04T12:34:56.789Z or …+00:00. */
|
||||
private static final String ISO_OFFSET_PATTERN =
|
||||
"\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})";
|
||||
private static final String JWT_PATTERN =
|
||||
"[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+";
|
||||
|
||||
private static final String REGISTER_BODY = """
|
||||
{"username":"alice","password":"secret123","nickname":"Alice","phone":"+8613800138000"}
|
||||
{"username":"alice","password":"secret123","phone":"+8613800138000"}
|
||||
""";
|
||||
private static final String LOGIN_BODY = """
|
||||
{"username":"alice","password":"secret123"}
|
||||
""";
|
||||
|
||||
@Test
|
||||
void registerReturnsTokenWhenUserServiceSucceeds() throws Exception {
|
||||
UserProfile profile = new UserProfile(USER_ID, "alice", "Alice", null, OffsetDateTime.now());
|
||||
when(userClient.createUser(any())).thenReturn(ApiResponse.success(profile));
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
mockMvc.perform(post("/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(REGISTER_BODY))
|
||||
@Autowired
|
||||
private JwtSigner jwtSigner;
|
||||
|
||||
@MockitoBean
|
||||
private UserClient userClient;
|
||||
|
||||
@MockitoBean
|
||||
private SessionClient sessionClient;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void jwtKey(DynamicPropertyRegistry registry) {
|
||||
registry.add("patbond.jwt.private-key", TestJwtKeys::privatePem);
|
||||
}
|
||||
|
||||
private SessionTokens sessionTokens() {
|
||||
return new SessionTokens(SESSION_ID, USER_ID, "jti-1", "refresh-token-1",
|
||||
OffsetDateTime.now(ZoneOffset.UTC).plusDays(30));
|
||||
}
|
||||
|
||||
@Test
|
||||
void registerReturnsTheFrozenTokenContract() throws Exception {
|
||||
when(userClient.createUser(any())).thenReturn(ApiResponse.success(
|
||||
new UserProfile(USER_ID, "alice", null, "+8613800138000", OffsetDateTime.now())));
|
||||
when(sessionClient.create(any())).thenReturn(ApiResponse.success(sessionTokens()));
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/register").contentType(APPLICATION_JSON).content(REGISTER_BODY))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
|
||||
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()))
|
||||
.andExpect(jsonPath("$.data.username").value("alice"));
|
||||
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
|
||||
.andExpect(jsonPath("$.data.accessToken", matchesPattern(JWT_PATTERN)))
|
||||
.andExpect(jsonPath("$.data.accessTokenExpiresAt", matchesPattern(ISO_OFFSET_PATTERN)))
|
||||
.andExpect(jsonPath("$.data.refreshToken").value("refresh-token-1"))
|
||||
.andExpect(jsonPath("$.data.refreshTokenExpiresAt", matchesPattern(ISO_OFFSET_PATTERN)))
|
||||
// Frozen contract: exactly these six fields, nothing else.
|
||||
.andExpect(jsonPath("$.data.username").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.nickname").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.expiresAt").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -76,9 +117,7 @@ class AuthControllerTest {
|
||||
when(userClient.createUser(any()))
|
||||
.thenThrow(new BusinessException(ErrorCode.USERNAME_EXISTS));
|
||||
|
||||
mockMvc.perform(post("/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(REGISTER_BODY))
|
||||
mockMvc.perform(post("/api/v1/auth/register").contentType(APPLICATION_JSON).content(REGISTER_BODY))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value(40900))
|
||||
.andExpect(jsonPath("$.message").value("用户名已存在"));
|
||||
@@ -86,8 +125,7 @@ class AuthControllerTest {
|
||||
|
||||
@Test
|
||||
void registerRejectsInvalidPayloadWithoutCallingUserService() throws Exception {
|
||||
mockMvc.perform(post("/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
mockMvc.perform(post("/api/v1/auth/register").contentType(APPLICATION_JSON)
|
||||
.content("{\"username\":\"ab\",\"password\":\"123\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
@@ -95,8 +133,7 @@ class AuthControllerTest {
|
||||
|
||||
@Test
|
||||
void registerRejectsNonE164Phone() throws Exception {
|
||||
mockMvc.perform(post("/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
mockMvc.perform(post("/api/v1/auth/register").contentType(APPLICATION_JSON)
|
||||
.content("""
|
||||
{"username":"alice","password":"secret123","phone":"13800138000"}
|
||||
"""))
|
||||
@@ -105,17 +142,17 @@ class AuthControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginReturnsTokenWhenPasswordVerified() throws Exception {
|
||||
void loginReturnsTokenPairWhenPasswordVerified() throws Exception {
|
||||
when(userClient.verifyPassword(any()))
|
||||
.thenReturn(ApiResponse.success(new VerifyPasswordResponse(USER_ID, "alice", "Alice")));
|
||||
.thenReturn(ApiResponse.success(new VerifyPasswordResponse(USER_ID, "alice", null)));
|
||||
when(sessionClient.create(any())).thenReturn(ApiResponse.success(sessionTokens()));
|
||||
|
||||
mockMvc.perform(post("/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(LOGIN_BODY))
|
||||
mockMvc.perform(post("/api/v1/auth/login").contentType(APPLICATION_JSON).content(LOGIN_BODY))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()));
|
||||
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()))
|
||||
.andExpect(jsonPath("$.data.accessToken", matchesPattern(JWT_PATTERN)))
|
||||
.andExpect(jsonPath("$.data.refreshToken").value("refresh-token-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -123,18 +160,25 @@ class AuthControllerTest {
|
||||
when(userClient.verifyPassword(any()))
|
||||
.thenThrow(new BusinessException(ErrorCode.INVALID_CREDENTIALS));
|
||||
|
||||
mockMvc.perform(post("/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(LOGIN_BODY))
|
||||
mockMvc.perform(post("/api/v1/auth/login").contentType(APPLICATION_JSON).content(LOGIN_BODY))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40100))
|
||||
.andExpect(jsonPath("$.message").value("用户名或密码错误"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginPropagatesAccountLockAs423() throws Exception {
|
||||
when(userClient.verifyPassword(any()))
|
||||
.thenThrow(new BusinessException(ErrorCode.LOGIN_LOCKED));
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/login").contentType(APPLICATION_JSON).content(LOGIN_BODY))
|
||||
.andExpect(status().is(423))
|
||||
.andExpect(jsonPath("$.code").value(42300));
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginRejectsBlankCredentials() throws Exception {
|
||||
mockMvc.perform(post("/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
mockMvc.perform(post("/api/v1/auth/login").contentType(APPLICATION_JSON)
|
||||
.content("{\"username\":\"\",\"password\":\"\"}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
@@ -148,10 +192,88 @@ class AuthControllerTest {
|
||||
Response.builder().status(502).request(request).build());
|
||||
when(userClient.verifyPassword(any())).thenThrow(transportFailure);
|
||||
|
||||
mockMvc.perform(post("/auth/login")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(LOGIN_BODY))
|
||||
mockMvc.perform(post("/api/v1/auth/login").contentType(APPLICATION_JSON).content(LOGIN_BODY))
|
||||
.andExpect(status().isServiceUnavailable())
|
||||
.andExpect(jsonPath("$.code").value(50300));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshReturnsARotatedTokenPair() throws Exception {
|
||||
when(sessionClient.refresh(any())).thenReturn(ApiResponse.success(sessionTokens()));
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/refresh").contentType(APPLICATION_JSON)
|
||||
.content("{\"refreshToken\":\"old-refresh-token\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.userId").value(USER_ID.toString()))
|
||||
.andExpect(jsonPath("$.data.tokenType").value("Bearer"))
|
||||
.andExpect(jsonPath("$.data.accessToken", matchesPattern(JWT_PATTERN)))
|
||||
.andExpect(jsonPath("$.data.refreshToken").value("refresh-token-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshPropagatesInvalidatedTokenAs40102() throws Exception {
|
||||
when(sessionClient.refresh(any()))
|
||||
.thenThrow(new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID));
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/refresh").contentType(APPLICATION_JSON)
|
||||
.content("{\"refreshToken\":\"reused-refresh-token\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40102));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshRejectsMissingToken() throws Exception {
|
||||
mockMvc.perform(post("/api/v1/auth/refresh").contentType(APPLICATION_JSON).content("{}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutRevokesTheCurrentSessionWithAValidAccessToken() throws Exception {
|
||||
when(sessionClient.revoke(any())).thenReturn(ApiResponse.success(null));
|
||||
String accessToken = jwtSigner.sign(USER_ID, SESSION_ID, "jti-logout").token();
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/logout")
|
||||
.header("Authorization", "Bearer " + accessToken)
|
||||
.contentType(APPLICATION_JSON)
|
||||
.content("{\"refreshToken\":\"refresh-token-1\"}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
ArgumentCaptor<RevokeSessionRequest> captor = ArgumentCaptor.forClass(RevokeSessionRequest.class);
|
||||
verify(sessionClient).revoke(captor.capture());
|
||||
assertThat(captor.getValue().getUserId()).isEqualTo(USER_ID);
|
||||
assertThat(captor.getValue().getRefreshToken()).isEqualTo("refresh-token-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutWithoutAuthorizationHeaderAnswers40101() throws Exception {
|
||||
mockMvc.perform(post("/api/v1/auth/logout").contentType(APPLICATION_JSON)
|
||||
.content("{\"refreshToken\":\"refresh-token-1\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutWithGarbageAccessTokenAnswers40101() throws Exception {
|
||||
mockMvc.perform(post("/api/v1/auth/logout")
|
||||
.header("Authorization", "Bearer not.a.jwt")
|
||||
.contentType(APPLICATION_JSON)
|
||||
.content("{\"refreshToken\":\"refresh-token-1\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutRejectsMissingRefreshToken() throws Exception {
|
||||
String accessToken = jwtSigner.sign(USER_ID, SESSION_ID, "jti-logout2").token();
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/logout")
|
||||
.header("Authorization", "Bearer " + accessToken)
|
||||
.contentType(APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(40000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.patbond.patbond.auth.security;
|
||||
|
||||
import com.patbond.patbond.auth.support.TestJwtKeys;
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class JwtSignerTest {
|
||||
|
||||
private static final UUID USER_ID = UUID.fromString("019212aa-0000-7000-8000-000000000001");
|
||||
private static final UUID SESSION_ID = UUID.fromString("019212aa-0000-7000-8000-000000000002");
|
||||
|
||||
private JwtSigner signer(Duration ttl) {
|
||||
return new JwtSigner(TestJwtKeys.privatePem(), ttl, "patbond-auth");
|
||||
}
|
||||
|
||||
@Test
|
||||
void signedTokenCarriesTheExpectedClaimsAndVerifies() {
|
||||
JwtSigner signer = signer(Duration.ofMinutes(15));
|
||||
JwtSigner.AccessToken accessToken = signer.sign(USER_ID, SESSION_ID, "jti-123");
|
||||
|
||||
Claims claims = signer.verify(accessToken.token());
|
||||
assertThat(claims.getSubject()).isEqualTo(USER_ID.toString());
|
||||
assertThat(claims.getId()).isEqualTo("jti-123");
|
||||
assertThat(claims.get(JwtSigner.SESSION_ID_CLAIM, String.class))
|
||||
.isEqualTo(SESSION_ID.toString());
|
||||
assertThat(claims.getIssuer()).isEqualTo("patbond-auth");
|
||||
assertThat(accessToken.expiresAt())
|
||||
.isAfter(OffsetDateTime.now(ZoneOffset.UTC).plusMinutes(14))
|
||||
.isBefore(OffsetDateTime.now(ZoneOffset.UTC).plusMinutes(16));
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredTokenIsRejectedWith40101() {
|
||||
JwtSigner expiredSigner = signer(Duration.ofMinutes(-1));
|
||||
String token = expiredSigner.sign(USER_ID, SESSION_ID, "jti-exp").token();
|
||||
|
||||
assertThatThrownBy(() -> expiredSigner.verify(token))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.satisfies(e -> assertThat(((BusinessException) e).getCode()).isEqualTo(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tokenSignedWithAForeignKeyIsRejected() {
|
||||
String forged = new JwtSigner(TestJwtKeys.privatePem(TestJwtKeys.WRONG_KEY_PAIR),
|
||||
Duration.ofMinutes(15), "patbond-auth")
|
||||
.sign(USER_ID, SESSION_ID, "jti-forged").token();
|
||||
|
||||
assertThatThrownBy(() -> signer(Duration.ofMinutes(15)).verify(forged))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.satisfies(e -> assertThat(((BusinessException) e).getCode()).isEqualTo(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void tamperedTokenIsRejected() {
|
||||
JwtSigner signer = signer(Duration.ofMinutes(15));
|
||||
String token = signer.sign(USER_ID, SESSION_ID, "jti-tamper").token();
|
||||
String[] parts = token.split("\\.");
|
||||
String tampered = parts[0] + "." + parts[1].substring(0, parts[1].length() - 2) + "aa." + parts[2];
|
||||
|
||||
assertThatThrownBy(() -> signer.verify(tampered))
|
||||
.isInstanceOf(BusinessException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingPrivateKeyFailsFastAtConstruction() {
|
||||
assertThatThrownBy(() -> new JwtSigner("", Duration.ofMinutes(15), "patbond-auth"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("patbond.jwt.private-key");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.patbond.patbond.auth.support;
|
||||
|
||||
/**
|
||||
* The auth service has no database; JDBC and Flyway are only on the test
|
||||
* classpath because the end-to-end test boots the real user service in the
|
||||
* same JVM. Auth-only Spring contexts must exclude their auto-configuration
|
||||
* or they fail for lack of a DataSource.
|
||||
*/
|
||||
public final class SpringTestSupport {
|
||||
|
||||
public static final String EXCLUDE_JDBC_AUTOCONFIG = "spring.autoconfigure.exclude="
|
||||
+ "org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration,"
|
||||
+ "org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration";
|
||||
|
||||
private SpringTestSupport() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.patbond.patbond.auth.support;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Runtime-generated RSA material for JWT tests. Nothing here is committed
|
||||
* key material (git-workflow: no credentials in the repository) — every test
|
||||
* run mints a fresh pair and injects the PEM via {@code @DynamicPropertySource}.
|
||||
*/
|
||||
public final class TestJwtKeys {
|
||||
|
||||
public static final KeyPair KEY_PAIR = generate();
|
||||
/** A second pair, for tokens the services must reject. */
|
||||
public static final KeyPair WRONG_KEY_PAIR = generate();
|
||||
|
||||
private TestJwtKeys() {
|
||||
}
|
||||
|
||||
public static String privatePem() {
|
||||
return privatePem(KEY_PAIR);
|
||||
}
|
||||
|
||||
public static String privatePem(KeyPair keyPair) {
|
||||
return "-----BEGIN PRIVATE KEY-----\n"
|
||||
+ Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded())
|
||||
+ "\n-----END PRIVATE KEY-----";
|
||||
}
|
||||
|
||||
public static String publicPem() {
|
||||
return "-----BEGIN PUBLIC KEY-----\n"
|
||||
+ Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded())
|
||||
+ "\n-----END PUBLIC KEY-----";
|
||||
}
|
||||
|
||||
private static KeyPair generate() {
|
||||
try {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
return generator.generateKeyPair();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
# Test-only configuration: keeps @SpringBootTest self-contained on a clean
|
||||
# checkout, where the git-ignored application.yml does not exist yet.
|
||||
# checkout, where the git-ignored application.yml does not exist yet. The JWT
|
||||
# private key is generated at runtime per test class and injected through
|
||||
# @DynamicPropertySource — no key material is committed.
|
||||
spring:
|
||||
application:
|
||||
name: patbond-auth
|
||||
@@ -7,3 +9,4 @@ spring:
|
||||
patbond:
|
||||
user-service:
|
||||
url: http://127.0.0.1:8082
|
||||
internal-token: test-internal-token
|
||||
|
||||
@@ -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, "依赖服务暂不可用");
|
||||
|
||||
|
||||
+56
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.patbond.patbond.common.session;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Internal contract: logout — revoke the one session holding this refresh
|
||||
* token, scoped to the user taken from the verified access token so a token
|
||||
* from another account cannot be revoked (ADR-003: logout revokes only the
|
||||
* current session; other devices stay logged in).
|
||||
*/
|
||||
public class RevokeSessionRequest {
|
||||
|
||||
@NotNull(message = "userId 不能为空")
|
||||
private UUID userId;
|
||||
|
||||
@NotBlank(message = "refreshToken 不能为空")
|
||||
private String refreshToken;
|
||||
|
||||
public RevokeSessionRequest() {
|
||||
}
|
||||
|
||||
public RevokeSessionRequest(UUID userId, String refreshToken) {
|
||||
this.userId = userId;
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
|
||||
public UUID getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(UUID userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getRefreshToken() {
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
public void setRefreshToken(String refreshToken) {
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.patbond.patbond.common.session;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Internal contract: the session material patbond-user hands back to
|
||||
* patbond-auth after creating or rotating a session. The refresh token is the
|
||||
* only plaintext copy that ever exists (the database stores its SHA-256
|
||||
* digest); the jti is minted here so auth can embed it in the access token
|
||||
* it signs, matching auth_sessions.access_token_jti without a second call.
|
||||
*/
|
||||
public class SessionTokens {
|
||||
|
||||
private UUID sessionId;
|
||||
private UUID userId;
|
||||
private String jti;
|
||||
private String refreshToken;
|
||||
private OffsetDateTime refreshTokenExpiresAt;
|
||||
|
||||
public SessionTokens() {
|
||||
}
|
||||
|
||||
public SessionTokens(UUID sessionId, UUID userId, String jti,
|
||||
String refreshToken, OffsetDateTime refreshTokenExpiresAt) {
|
||||
this.sessionId = sessionId;
|
||||
this.userId = userId;
|
||||
this.jti = jti;
|
||||
this.refreshToken = refreshToken;
|
||||
this.refreshTokenExpiresAt = refreshTokenExpiresAt;
|
||||
}
|
||||
|
||||
public UUID getSessionId() {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
public void setSessionId(UUID sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
public UUID getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(UUID userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getJti() {
|
||||
return jti;
|
||||
}
|
||||
|
||||
public void setJti(String jti) {
|
||||
this.jti = jti;
|
||||
}
|
||||
|
||||
public String getRefreshToken() {
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
public void setRefreshToken(String refreshToken) {
|
||||
this.refreshToken = refreshToken;
|
||||
}
|
||||
|
||||
public OffsetDateTime getRefreshTokenExpiresAt() {
|
||||
return refreshTokenExpiresAt;
|
||||
}
|
||||
|
||||
public void setRefreshTokenExpiresAt(OffsetDateTime refreshTokenExpiresAt) {
|
||||
this.refreshTokenExpiresAt = refreshTokenExpiresAt;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,25 @@
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!-- Access token verification (RS256, public key only): jjwt is not in
|
||||
the Boot BOM, version pinned here and in patbond-auth in step. -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.patbond.patbond.user.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.patbond.patbond.user.security.BearerAuthFilter;
|
||||
import com.patbond.patbond.user.security.InternalAuthFilter;
|
||||
import com.patbond.patbond.user.security.JwtVerifier;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Wires the two servlet filters guarding this service without pulling in
|
||||
* spring-security: /internal/** requires the shared service secret,
|
||||
* /api/v1/** requires a valid RS256 access token.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(UserSecurityProperties.class)
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public JwtVerifier jwtVerifier(UserSecurityProperties properties) {
|
||||
return new JwtVerifier(properties.getJwt().getPublicKey());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<InternalAuthFilter> internalAuthFilter(
|
||||
UserSecurityProperties properties, ObjectMapper objectMapper) {
|
||||
FilterRegistrationBean<InternalAuthFilter> registration = new FilterRegistrationBean<>(
|
||||
new InternalAuthFilter(properties.getInternalToken(), objectMapper));
|
||||
registration.addUrlPatterns("/internal/*");
|
||||
registration.setOrder(10);
|
||||
return registration;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<BearerAuthFilter> bearerAuthFilter(
|
||||
JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
|
||||
FilterRegistrationBean<BearerAuthFilter> registration = new FilterRegistrationBean<>(
|
||||
new BearerAuthFilter(jwtVerifier, objectMapper));
|
||||
registration.addUrlPatterns("/api/v1/*");
|
||||
registration.setOrder(20);
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package com.patbond.patbond.user.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Security-related knobs of the user service. ADR-003 mandates that the
|
||||
* refresh TTL and the login-failure limits are configuration, not constants;
|
||||
* the committed defaults are the ADR values (refresh 30 days) and the login
|
||||
* lockout policy documented in openapi.yaml (5 failures / 15 min window /
|
||||
* 15 min lock).
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "patbond")
|
||||
public class UserSecurityProperties {
|
||||
|
||||
/** Shared secret expected in X-Internal-Token on every /internal/** call. */
|
||||
private String internalToken;
|
||||
|
||||
private final Jwt jwt = new Jwt();
|
||||
private final Session session = new Session();
|
||||
private final LoginLock loginLock = new LoginLock();
|
||||
|
||||
public String getInternalToken() {
|
||||
return internalToken;
|
||||
}
|
||||
|
||||
public void setInternalToken(String internalToken) {
|
||||
this.internalToken = internalToken;
|
||||
}
|
||||
|
||||
public Jwt getJwt() {
|
||||
return jwt;
|
||||
}
|
||||
|
||||
public Session getSession() {
|
||||
return session;
|
||||
}
|
||||
|
||||
public LoginLock getLoginLock() {
|
||||
return loginLock;
|
||||
}
|
||||
|
||||
public static class Jwt {
|
||||
|
||||
/**
|
||||
* RS256 public key for verifying access tokens signed by
|
||||
* patbond-auth: either inline PEM (starts with -----BEGIN) or a
|
||||
* filesystem path. The private key never reaches this service.
|
||||
*/
|
||||
private String publicKey;
|
||||
|
||||
public String getPublicKey() {
|
||||
return publicKey;
|
||||
}
|
||||
|
||||
public void setPublicKey(String publicKey) {
|
||||
this.publicKey = publicKey;
|
||||
}
|
||||
}
|
||||
|
||||
public static class Session {
|
||||
|
||||
/** Refresh token lifetime (ADR-003: 30 days). */
|
||||
private Duration refreshTtl = Duration.ofDays(30);
|
||||
|
||||
public Duration getRefreshTtl() {
|
||||
return refreshTtl;
|
||||
}
|
||||
|
||||
public void setRefreshTtl(Duration refreshTtl) {
|
||||
this.refreshTtl = refreshTtl;
|
||||
}
|
||||
}
|
||||
|
||||
public static class LoginLock {
|
||||
|
||||
/** Failures within the window that trigger a lock. */
|
||||
private int maxFailures = 5;
|
||||
|
||||
/** Sliding window in which failures accumulate. */
|
||||
private Duration failureWindow = Duration.ofMinutes(15);
|
||||
|
||||
/** How long the account stays locked once triggered. */
|
||||
private Duration lockDuration = Duration.ofMinutes(15);
|
||||
|
||||
public int getMaxFailures() {
|
||||
return maxFailures;
|
||||
}
|
||||
|
||||
public void setMaxFailures(int maxFailures) {
|
||||
this.maxFailures = maxFailures;
|
||||
}
|
||||
|
||||
public Duration getFailureWindow() {
|
||||
return failureWindow;
|
||||
}
|
||||
|
||||
public void setFailureWindow(Duration failureWindow) {
|
||||
this.failureWindow = failureWindow;
|
||||
}
|
||||
|
||||
public Duration getLockDuration() {
|
||||
return lockDuration;
|
||||
}
|
||||
|
||||
public void setLockDuration(Duration lockDuration) {
|
||||
this.lockDuration = lockDuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.patbond.patbond.user.controller;
|
||||
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import com.patbond.patbond.common.user.UserProfile;
|
||||
import com.patbond.patbond.user.dto.MeResponse;
|
||||
import com.patbond.patbond.user.security.BearerAuthFilter;
|
||||
import com.patbond.patbond.user.service.UserService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Public profile endpoint. Authentication happens in BearerAuthFilter (RS256
|
||||
* verification against the auth service's public key); by the time this
|
||||
* controller runs, the user id attribute is guaranteed to be present.
|
||||
*/
|
||||
@RestController
|
||||
public class MeController {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
public MeController(UserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@GetMapping("/api/v1/me")
|
||||
public ApiResponse<MeResponse> me(@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
|
||||
UserProfile profile = userService.getById(userId);
|
||||
return ApiResponse.success(new MeResponse(
|
||||
profile.getId(), profile.getUsername(), profile.getPhone(), profile.getCreatedAt()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.patbond.patbond.user.dto;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Public /api/v1/me payload — exactly the frozen contract fields
|
||||
* {userId, username, phone, createdAt}; nothing else leaks out.
|
||||
*/
|
||||
public record MeResponse(UUID userId, String username, String phone, OffsetDateTime createdAt) {
|
||||
}
|
||||
@@ -32,7 +32,8 @@ public class UserRepository {
|
||||
public record UserRow(UUID id, String username, String nickname, String phone, OffsetDateTime createdAt) {
|
||||
}
|
||||
|
||||
public record AuthRow(UUID id, String username, String nickname, String passwordHash) {
|
||||
public record AuthRow(UUID id, String username, String nickname, String passwordHash,
|
||||
OffsetDateTime lockedUntil) {
|
||||
}
|
||||
|
||||
/** Inserts the user row; created_at/updated_at come from the DB defaults. */
|
||||
@@ -87,7 +88,7 @@ public class UserRepository {
|
||||
|
||||
public Optional<AuthRow> findAuthByUsername(String username) {
|
||||
return jdbcClient.sql("""
|
||||
SELECT u.id, u.username::text AS username, u.nickname, c.password_hash
|
||||
SELECT u.id, u.username::text AS username, u.nickname, c.password_hash, c.locked_until
|
||||
FROM identity.users u
|
||||
JOIN identity.user_credentials c ON c.user_id = u.id
|
||||
WHERE u.deleted_at IS NULL AND u.username = :username
|
||||
@@ -97,7 +98,58 @@ public class UserRepository {
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("username"),
|
||||
rs.getString("nickname"),
|
||||
rs.getString("password_hash")))
|
||||
rs.getString("password_hash"),
|
||||
rs.getObject("locked_until", OffsetDateTime.class)))
|
||||
.optional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers one failed login attempt in a single atomic UPDATE: the
|
||||
* counter restarts when the failure window has lapsed, and locked_until
|
||||
* is set the moment the counter reaches the configured maximum. All CASE
|
||||
* expressions read the pre-update column values, so concurrent failures
|
||||
* cannot double-count or skip the lock.
|
||||
*/
|
||||
public void recordLoginFailure(UUID userId, long windowSeconds, int maxFailures, long lockSeconds) {
|
||||
jdbcClient.sql("""
|
||||
UPDATE identity.user_credentials SET
|
||||
failed_login_count = CASE
|
||||
WHEN failure_window_started_at IS NULL
|
||||
OR failure_window_started_at < now() - make_interval(secs => :windowSeconds)
|
||||
THEN 1 ELSE failed_login_count + 1 END,
|
||||
failure_window_started_at = CASE
|
||||
WHEN failure_window_started_at IS NULL
|
||||
OR failure_window_started_at < now() - make_interval(secs => :windowSeconds)
|
||||
THEN now() ELSE failure_window_started_at END,
|
||||
last_failed_at = now(),
|
||||
locked_until = CASE
|
||||
WHEN (CASE
|
||||
WHEN failure_window_started_at IS NULL
|
||||
OR failure_window_started_at < now() - make_interval(secs => :windowSeconds)
|
||||
THEN 1 ELSE failed_login_count + 1 END) >= :maxFailures
|
||||
THEN now() + make_interval(secs => :lockSeconds)
|
||||
ELSE locked_until END
|
||||
WHERE user_id = :userId
|
||||
""")
|
||||
.param("userId", userId)
|
||||
.param("windowSeconds", windowSeconds)
|
||||
.param("maxFailures", maxFailures)
|
||||
.param("lockSeconds", lockSeconds)
|
||||
.update();
|
||||
}
|
||||
|
||||
/** Successful login: clears the failure window and stamps last_login_at. */
|
||||
public void recordLoginSuccess(UUID userId) {
|
||||
jdbcClient.sql("""
|
||||
UPDATE identity.user_credentials
|
||||
SET failed_login_count = 0, failure_window_started_at = NULL, locked_until = NULL
|
||||
WHERE user_id = :userId
|
||||
AND (failed_login_count > 0 OR locked_until IS NOT NULL)
|
||||
""")
|
||||
.param("userId", userId)
|
||||
.update();
|
||||
jdbcClient.sql("UPDATE identity.users SET last_login_at = now() WHERE id = :userId")
|
||||
.param("userId", userId)
|
||||
.update();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.patbond.patbond.user.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Bearer authentication for the public /api/v1/** routes of this service
|
||||
* (currently GET /api/v1/me). Verifies the RS256 signature locally with the
|
||||
* auth service's public key — no network hop per request — and exposes the
|
||||
* authenticated user id as a request attribute. Missing, forged or expired
|
||||
* tokens all answer 401/40101 without detail (log-redaction rule: the token
|
||||
* itself is never logged).
|
||||
*/
|
||||
public class BearerAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
/** Request attribute holding the authenticated user's UUID. */
|
||||
public static final String USER_ID_ATTRIBUTE = "patbond.authenticatedUserId";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BearerAuthFilter.class);
|
||||
|
||||
private final JwtVerifier jwtVerifier;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public BearerAuthFilter(JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
|
||||
this.jwtVerifier = jwtVerifier;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
return !request.getRequestURI().startsWith("/api/v1/");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header == null || !header.startsWith("Bearer ")) {
|
||||
reject(response, ErrorCode.TOKEN_INVALID);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Claims claims = jwtVerifier.verify(header.substring("Bearer ".length()).trim());
|
||||
request.setAttribute(USER_ID_ATTRIBUTE, UUID.fromString(claims.getSubject()));
|
||||
} catch (BusinessException e) {
|
||||
reject(response, ErrorCode.TOKEN_INVALID);
|
||||
return;
|
||||
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||
log.error("Access token verification unavailable: {}", e.getMessage());
|
||||
reject(response, ErrorCode.INTERNAL_ERROR);
|
||||
return;
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private void reject(HttpServletResponse response, ErrorCode errorCode) throws IOException {
|
||||
response.setStatus(errorCode.getHttpStatus());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
objectMapper.writeValue(response.getWriter(),
|
||||
ApiResponse.failure(errorCode.getCode(), errorCode.getDefaultMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.patbond.patbond.user.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/**
|
||||
* Service-to-service authentication for /internal/** (development-plan 6:
|
||||
* internal routes must carry inter-service auth). The caller presents the
|
||||
* shared secret in X-Internal-Token; it is injected via environment variable
|
||||
* on both sides and never logged. Fails closed: with no secret configured
|
||||
* every internal call is rejected.
|
||||
*/
|
||||
public class InternalAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
public static final String HEADER = "X-Internal-Token";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(InternalAuthFilter.class);
|
||||
|
||||
private final String expectedToken;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public InternalAuthFilter(String expectedToken, ObjectMapper objectMapper) {
|
||||
this.expectedToken = expectedToken;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
return !request.getRequestURI().startsWith("/internal/");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
if (expectedToken == null || expectedToken.isBlank()) {
|
||||
log.error("patbond.internal-token 未配置,/internal/** 请求全部拒绝");
|
||||
reject(response);
|
||||
return;
|
||||
}
|
||||
String presented = request.getHeader(HEADER);
|
||||
if (presented == null || !MessageDigest.isEqual(
|
||||
presented.getBytes(StandardCharsets.UTF_8),
|
||||
expectedToken.getBytes(StandardCharsets.UTF_8))) {
|
||||
reject(response);
|
||||
return;
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private void reject(HttpServletResponse response) throws IOException {
|
||||
response.setStatus(401);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
objectMapper.writeValue(response.getWriter(),
|
||||
ApiResponse.failure(ErrorCode.TOKEN_INVALID.getCode(), "服务间凭证缺失或无效"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.patbond.patbond.user.security;
|
||||
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import io.jsonwebtoken.JwtParser;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
|
||||
/**
|
||||
* Verifies RS256 access tokens issued by patbond-auth against the configured
|
||||
* public key. The key is optional at startup so contexts that never serve
|
||||
* protected routes (most tests) can boot without one; any verification
|
||||
* attempt without a key fails loudly as a server misconfiguration instead of
|
||||
* being reported to the client as an authentication problem.
|
||||
*/
|
||||
public class JwtVerifier {
|
||||
|
||||
private final JwtParser parser;
|
||||
|
||||
public JwtVerifier(String publicKeyLocation) {
|
||||
this.parser = publicKeyLocation == null || publicKeyLocation.isBlank()
|
||||
? null
|
||||
: Jwts.parser().verifyWith(RsaPublicKeyLoader.load(publicKeyLocation)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the verified claims
|
||||
* @throws BusinessException 40101 when the token is forged, malformed or expired
|
||||
*/
|
||||
public Claims verify(String token) {
|
||||
if (parser == null) {
|
||||
throw new IllegalStateException("patbond.jwt.public-key 未配置,无法校验 access token");
|
||||
}
|
||||
try {
|
||||
return parser.parseSignedClaims(token).getPayload();
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
throw new BusinessException(ErrorCode.TOKEN_INVALID);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.patbond.patbond.user.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Loads an RSA public key from either inline PEM content (value starts with
|
||||
* -----BEGIN, e.g. injected through an environment variable) or a filesystem
|
||||
* path to a PEM file. Only the X.509 SubjectPublicKeyInfo form produced by
|
||||
* `openssl pkey -pubout` is supported.
|
||||
*/
|
||||
public final class RsaPublicKeyLoader {
|
||||
|
||||
private RsaPublicKeyLoader() {
|
||||
}
|
||||
|
||||
public static RSAPublicKey load(String pemOrPath) {
|
||||
String pem = pemOrPath.trim();
|
||||
if (!pem.startsWith("-----BEGIN")) {
|
||||
try {
|
||||
pem = Files.readString(Path.of(pem));
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException("无法读取 JWT 公钥文件: " + pemOrPath, e);
|
||||
}
|
||||
}
|
||||
String base64 = pem
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
try {
|
||||
byte[] der = Base64.getDecoder().decode(base64);
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
return (RSAPublicKey) keyFactory.generatePublic(new X509EncodedKeySpec(der));
|
||||
} catch (IllegalArgumentException | NoSuchAlgorithmException | InvalidKeySpecException e) {
|
||||
throw new IllegalStateException("JWT 公钥不是有效的 PEM(X.509/SubjectPublicKeyInfo) RSA 公钥", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.patbond.patbond.common.user.CreateUserRequest;
|
||||
import com.patbond.patbond.common.user.UserProfile;
|
||||
import com.patbond.patbond.common.user.VerifyPasswordRequest;
|
||||
import com.patbond.patbond.common.user.VerifyPasswordResponse;
|
||||
import com.patbond.patbond.user.config.UserSecurityProperties;
|
||||
import com.patbond.patbond.user.repository.UserRepository;
|
||||
import com.patbond.patbond.user.support.UuidV7;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
@@ -15,12 +16,14 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class UserService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final UserSecurityProperties properties;
|
||||
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||
|
||||
/**
|
||||
@@ -29,8 +32,9 @@ public class UserService {
|
||||
*/
|
||||
private final String unknownUserHash = passwordEncoder.encode(UUID.randomUUID().toString());
|
||||
|
||||
public UserService(UserRepository userRepository) {
|
||||
public UserService(UserRepository userRepository, UserSecurityProperties properties) {
|
||||
this.userRepository = userRepository;
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -50,13 +54,31 @@ public class UserService {
|
||||
return new UserProfile(id, username, nickname, phone, createdAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Password check with a database-backed lockout window (frozen policy,
|
||||
* see openapi.yaml): repeated failures within the failure window lock the
|
||||
* account for the configured duration and answer 423/42300, even for the
|
||||
* correct password, until the lock lapses. Counters live on
|
||||
* identity.user_credentials, so they survive restarts and are shared by
|
||||
* every instance. A successful login resets the window.
|
||||
*/
|
||||
public VerifyPasswordResponse verifyPassword(VerifyPasswordRequest request) {
|
||||
UserRepository.AuthRow auth = userRepository.findAuthByUsername(request.getUsername().trim())
|
||||
.orElse(null);
|
||||
if (auth != null && auth.lockedUntil() != null
|
||||
&& auth.lockedUntil().isAfter(OffsetDateTime.now(ZoneOffset.UTC))) {
|
||||
throw new BusinessException(ErrorCode.LOGIN_LOCKED);
|
||||
}
|
||||
String storedHash = auth == null ? unknownUserHash : auth.passwordHash();
|
||||
if (!passwordEncoder.matches(request.getPassword(), storedHash) || auth == null) {
|
||||
if (auth != null) {
|
||||
UserSecurityProperties.LoginLock lock = properties.getLoginLock();
|
||||
userRepository.recordLoginFailure(auth.id(), lock.getFailureWindow().toSeconds(),
|
||||
lock.getMaxFailures(), lock.getLockDuration().toSeconds());
|
||||
}
|
||||
throw new BusinessException(ErrorCode.INVALID_CREDENTIALS);
|
||||
}
|
||||
userRepository.recordLoginSuccess(auth.id());
|
||||
return new VerifyPasswordResponse(auth.id(), auth.username(), auth.nickname());
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.patbond.patbond.user.session;
|
||||
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import com.patbond.patbond.common.session.CreateSessionRequest;
|
||||
import com.patbond.patbond.common.session.RefreshSessionRequest;
|
||||
import com.patbond.patbond.common.session.RevokeSessionRequest;
|
||||
import com.patbond.patbond.common.session.SessionTokens;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Internal session API consumed by patbond-auth. Guarded by
|
||||
* InternalAuthFilter like every /internal/** route.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/internal/sessions")
|
||||
public class SessionController {
|
||||
|
||||
private final SessionService sessionService;
|
||||
|
||||
public SessionController(SessionService sessionService) {
|
||||
this.sessionService = sessionService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<SessionTokens> create(@Valid @RequestBody CreateSessionRequest request) {
|
||||
return ApiResponse.success(sessionService.create(request));
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
public ApiResponse<SessionTokens> refresh(@Valid @RequestBody RefreshSessionRequest request) {
|
||||
return ApiResponse.success(sessionService.refresh(request.getRefreshToken()));
|
||||
}
|
||||
|
||||
@PostMapping("/revoke")
|
||||
public ApiResponse<Void> revoke(@Valid @RequestBody RevokeSessionRequest request) {
|
||||
sessionService.revoke(request.getUserId(), request.getRefreshToken());
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.patbond.patbond.user.session;
|
||||
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* JDBC access to identity.auth_sessions. The table only ever sees the
|
||||
* SHA-256 digest of a refresh token (ck_sessions_refresh_hash pins 32 bytes);
|
||||
* plaintext tokens exist solely in transit back to the client.
|
||||
*/
|
||||
@Repository
|
||||
public class SessionRepository {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public SessionRepository(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
public record SessionRow(UUID id, UUID userId, UUID tokenFamilyId, OffsetDateTime expiresAt,
|
||||
OffsetDateTime revokedAt, OffsetDateTime rotatedAt) {
|
||||
}
|
||||
|
||||
public void insert(UUID id, UUID userId, UUID tokenFamilyId, byte[] refreshTokenHash,
|
||||
String accessTokenJti, OffsetDateTime expiresAt,
|
||||
String userAgent, String ipAddress) {
|
||||
jdbcClient.sql("""
|
||||
INSERT INTO identity.auth_sessions
|
||||
(id, user_id, token_family_id, refresh_token_hash, access_token_jti,
|
||||
expires_at, user_agent, ip_address)
|
||||
VALUES (:id, :userId, :familyId, :hash, :jti, :expiresAt,
|
||||
:userAgent, CAST(:ipAddress AS inet))
|
||||
""")
|
||||
.param("id", id)
|
||||
.param("userId", userId)
|
||||
.param("familyId", tokenFamilyId)
|
||||
.param("hash", refreshTokenHash)
|
||||
.param("jti", accessTokenJti)
|
||||
.param("expiresAt", expiresAt)
|
||||
.param("userAgent", userAgent)
|
||||
.param("ipAddress", ipAddress)
|
||||
.update();
|
||||
}
|
||||
|
||||
public Optional<SessionRow> findByTokenHash(byte[] refreshTokenHash) {
|
||||
return jdbcClient.sql("""
|
||||
SELECT id, user_id, token_family_id, expires_at, revoked_at, rotated_at
|
||||
FROM identity.auth_sessions
|
||||
WHERE refresh_token_hash = :hash
|
||||
""")
|
||||
.param("hash", refreshTokenHash)
|
||||
.query((rs, rowNum) -> new SessionRow(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getObject("user_id", UUID.class),
|
||||
rs.getObject("token_family_id", UUID.class),
|
||||
rs.getObject("expires_at", OffsetDateTime.class),
|
||||
rs.getObject("revoked_at", OffsetDateTime.class),
|
||||
rs.getObject("rotated_at", OffsetDateTime.class)))
|
||||
.optional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the old session as part of a rotation. The revoked_at IS NULL
|
||||
* guard makes concurrent rotations of the same token detectable: exactly
|
||||
* one caller sees 1 row updated, every other sees 0 (= reuse).
|
||||
*/
|
||||
public int markRotated(UUID oldSessionId, UUID newSessionId) {
|
||||
return jdbcClient.sql("""
|
||||
UPDATE identity.auth_sessions
|
||||
SET revoked_at = now(), rotated_at = now(),
|
||||
revoke_reason = 'rotated', replaced_by_session_id = :newId,
|
||||
last_seen_at = now()
|
||||
WHERE id = :oldId AND revoked_at IS NULL
|
||||
""")
|
||||
.param("oldId", oldSessionId)
|
||||
.param("newId", newSessionId)
|
||||
.update();
|
||||
}
|
||||
|
||||
/** Revokes every live session of the family (refresh-token reuse response). */
|
||||
public int revokeFamily(UUID tokenFamilyId, String reason) {
|
||||
return jdbcClient.sql("""
|
||||
UPDATE identity.auth_sessions
|
||||
SET revoked_at = now(), revoke_reason = :reason
|
||||
WHERE token_family_id = :familyId AND revoked_at IS NULL
|
||||
""")
|
||||
.param("familyId", tokenFamilyId)
|
||||
.param("reason", reason)
|
||||
.update();
|
||||
}
|
||||
|
||||
/** Logout: revokes the one live session holding this token for this user. */
|
||||
public int revokeByTokenHashAndUser(byte[] refreshTokenHash, UUID userId, String reason) {
|
||||
return jdbcClient.sql("""
|
||||
UPDATE identity.auth_sessions
|
||||
SET revoked_at = now(), revoke_reason = :reason
|
||||
WHERE refresh_token_hash = :hash AND user_id = :userId AND revoked_at IS NULL
|
||||
""")
|
||||
.param("hash", refreshTokenHash)
|
||||
.param("userId", userId)
|
||||
.param("reason", reason)
|
||||
.update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.patbond.patbond.user.session;
|
||||
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import com.patbond.patbond.common.session.CreateSessionRequest;
|
||||
import com.patbond.patbond.common.session.SessionTokens;
|
||||
import com.patbond.patbond.user.config.UserSecurityProperties;
|
||||
import com.patbond.patbond.user.support.UuidV7;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Refresh-session lifecycle on identity.auth_sessions, implementing ADR-003:
|
||||
* 30-day (configurable) refresh tokens, rotation on every refresh, reuse of a
|
||||
* rotated token revokes the whole token family, logout revokes only the
|
||||
* current session so parallel device sessions stay alive.
|
||||
*
|
||||
* <p>Refresh tokens are 256-bit random values; only their SHA-256 digest is
|
||||
* persisted. Neither the plaintext token nor its digest is ever logged.
|
||||
*/
|
||||
@Service
|
||||
public class SessionService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SessionService.class);
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final SessionRepository sessionRepository;
|
||||
private final UserSecurityProperties properties;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
public SessionService(SessionRepository sessionRepository, UserSecurityProperties properties,
|
||||
TransactionTemplate transactionTemplate) {
|
||||
this.sessionRepository = sessionRepository;
|
||||
this.properties = properties;
|
||||
this.transactionTemplate = transactionTemplate;
|
||||
}
|
||||
|
||||
/** Opens a new session (= new token family) for a freshly authenticated user. */
|
||||
public SessionTokens create(CreateSessionRequest request) {
|
||||
return insertSession(request.getUserId(), UuidV7.generate(),
|
||||
request.getUserAgent(), request.getIpAddress());
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates a refresh token. The old session is closed and chained to its
|
||||
* replacement; presenting a token that was already rotated or revoked is
|
||||
* treated as reuse and kills every live session of the family (40102).
|
||||
*/
|
||||
public SessionTokens refresh(String refreshToken) {
|
||||
byte[] hash = sha256(refreshToken);
|
||||
SessionRepository.SessionRow session = sessionRepository.findByTokenHash(hash)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID));
|
||||
|
||||
if (session.revokedAt() != null) {
|
||||
// A rotated (or logged-out) token came back: someone other than the
|
||||
// rightful holder may have it. Revoke the whole family (ADR-003).
|
||||
int revoked = sessionRepository.revokeFamily(session.tokenFamilyId(), "reuse_detected");
|
||||
log.warn("Refresh token reuse detected: family={} of user={} revoked ({} live sessions)",
|
||||
session.tokenFamilyId(), session.userId(), revoked);
|
||||
throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
|
||||
}
|
||||
if (!session.expiresAt().isAfter(OffsetDateTime.now(ZoneOffset.UTC))) {
|
||||
throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
|
||||
}
|
||||
|
||||
SessionTokens rotated = transactionTemplate.execute(status -> {
|
||||
SessionTokens tokens = insertSession(session.userId(), session.tokenFamilyId(), null, null);
|
||||
if (sessionRepository.markRotated(session.id(), tokens.getSessionId()) != 1) {
|
||||
status.setRollbackOnly();
|
||||
return null;
|
||||
}
|
||||
return tokens;
|
||||
});
|
||||
if (rotated == null) {
|
||||
// Lost a race against a concurrent rotation of the same token —
|
||||
// by definition the token was presented twice: treat as reuse.
|
||||
int revoked = sessionRepository.revokeFamily(session.tokenFamilyId(), "reuse_detected");
|
||||
log.warn("Concurrent refresh detected: family={} of user={} revoked ({} live sessions)",
|
||||
session.tokenFamilyId(), session.userId(), revoked);
|
||||
throw new BusinessException(ErrorCode.REFRESH_TOKEN_INVALID);
|
||||
}
|
||||
return rotated;
|
||||
}
|
||||
|
||||
/** Logout: revokes the session holding this token; idempotent by design. */
|
||||
public void revoke(UUID userId, String refreshToken) {
|
||||
sessionRepository.revokeByTokenHashAndUser(sha256(refreshToken), userId, "logout");
|
||||
}
|
||||
|
||||
private SessionTokens insertSession(UUID userId, UUID familyId, String userAgent, String ipAddress) {
|
||||
UUID sessionId = UuidV7.generate();
|
||||
String jti = UuidV7.generate().toString();
|
||||
byte[] tokenBytes = new byte[32];
|
||||
RANDOM.nextBytes(tokenBytes);
|
||||
String refreshToken = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
|
||||
OffsetDateTime expiresAt = OffsetDateTime.now(ZoneOffset.UTC)
|
||||
.plus(properties.getSession().getRefreshTtl());
|
||||
|
||||
sessionRepository.insert(sessionId, userId, familyId, sha256(refreshToken), jti,
|
||||
expiresAt, userAgent, ipAddress);
|
||||
return new SessionTokens(sessionId, userId, jti, refreshToken, expiresAt);
|
||||
}
|
||||
|
||||
private static byte[] sha256(String token) {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256").digest(token.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,27 @@ spring:
|
||||
flyway:
|
||||
locations: classpath:db/migration
|
||||
|
||||
patbond:
|
||||
# /internal/** 服务间共享密钥,需与 patbond-auth 配置同一值;生产环境必须
|
||||
# 通过 PATBOND_INTERNAL_TOKEN 注入强随机值(如 `openssl rand -hex 32`)。
|
||||
internal-token: ${PATBOND_INTERNAL_TOKEN:dev-only-internal-token}
|
||||
jwt:
|
||||
# RS256 公钥,用于本地校验 patbond-auth 签发的 access token。
|
||||
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
|
||||
# 密钥对生成(私钥只给 patbond-auth,绝不入库):
|
||||
# openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt-private.pem
|
||||
# openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
|
||||
# 然后:export PATBOND_JWT_PUBLIC_KEY=/path/to/jwt-public.pem
|
||||
public-key: ${PATBOND_JWT_PUBLIC_KEY:}
|
||||
session:
|
||||
# ADR-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:
|
||||
#
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.utility.DockerImageName;
|
||||
|
||||
/**
|
||||
* Shared Testcontainers setup: a disposable postgres:16 (the production
|
||||
* Shared Testcontainers setup: a disposable postgres:18 (the production
|
||||
* target version) wired into the Spring context via @ServiceConnection.
|
||||
* Flyway runs the real migrations against it on context startup, so every
|
||||
* @SpringBootTest in this module exercises the V1 baseline on a clean
|
||||
@@ -19,6 +19,6 @@ public class TestcontainersConfiguration {
|
||||
@Bean
|
||||
@ServiceConnection
|
||||
PostgreSQLContainer<?> postgresContainer() {
|
||||
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16"));
|
||||
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:18"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.patbond.patbond.user.controller;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.user.TestcontainersConfiguration;
|
||||
import com.patbond.patbond.user.security.InternalAuthFilter;
|
||||
import com.patbond.patbond.user.support.TestJwtKeys;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||
import org.springframework.test.context.DynamicPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* GET /api/v1/me behind BearerAuthFilter: RS256 tokens are verified locally
|
||||
* against the configured public key (generated per test run — no committed
|
||||
* key material). Response shape is the frozen contract:
|
||||
* {userId, username, phone, createdAt} and nothing else.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class MeEndpointTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@DynamicPropertySource
|
||||
static void jwtPublicKey(DynamicPropertyRegistry registry) {
|
||||
registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
|
||||
}
|
||||
|
||||
private String registerUser(String username, String phone) throws Exception {
|
||||
String body = mockMvc.perform(post("/internal/users")
|
||||
.header(InternalAuthFilter.HEADER, "test-internal-token")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"username\":\"%s\",\"password\":\"secret123\",\"phone\":\"%s\"}"
|
||||
.formatted(username, phone)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
return JsonPath.read(body, "$.data.id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void meReturnsExactlyTheFrozenContractFields() throws Exception {
|
||||
String userId = registerUser("me_happy", "+8613800000401");
|
||||
String token = TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(),
|
||||
UUID.fromString(userId), Duration.ofMinutes(15));
|
||||
|
||||
mockMvc.perform(get("/api/v1/me").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.userId").value(userId))
|
||||
.andExpect(jsonPath("$.data.username").value("me_happy"))
|
||||
.andExpect(jsonPath("$.data.phone").value("+8613800000401"))
|
||||
.andExpect(jsonPath("$.data.createdAt").isNotEmpty())
|
||||
// Frozen contract: no other identity fields leak out.
|
||||
.andExpect(jsonPath("$.data.id").doesNotExist())
|
||||
.andExpect(jsonPath("$.data.nickname").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void meWithoutTokenReturns40101() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/me"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void meWithExpiredTokenReturns40101() throws Exception {
|
||||
String userId = registerUser("me_expired", "+8613800000402");
|
||||
String token = TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(),
|
||||
UUID.fromString(userId), Duration.ofMinutes(-1));
|
||||
|
||||
mockMvc.perform(get("/api/v1/me").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void meWithForgedTokenReturns40101() throws Exception {
|
||||
String userId = registerUser("me_forged", "+8613800000403");
|
||||
// Signed with a key the service does not trust.
|
||||
String token = TestJwtKeys.accessToken(TestJwtKeys.WRONG_KEY_PAIR.getPrivate(),
|
||||
UUID.fromString(userId), Duration.ofMinutes(15));
|
||||
|
||||
mockMvc.perform(get("/api/v1/me").header("Authorization", "Bearer " + token))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
|
||||
@Test
|
||||
void meWithGarbageTokenReturns40101() throws Exception {
|
||||
mockMvc.perform(get("/api/v1/me").header("Authorization", "Bearer not.a.jwt"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.code").value(40101));
|
||||
}
|
||||
}
|
||||
+36
-22
@@ -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));
|
||||
}
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Verifies the Flyway V1 baseline and the real persistence semantics against
|
||||
* a clean postgres:16 container: migrations apply, constraints hold, and a
|
||||
* a clean postgres:18 container: migrations apply, constraints hold, and a
|
||||
* registered user is durably stored in PostgreSQL (readable over a fresh raw
|
||||
* JDBC connection, i.e. independent of any application-process memory — the
|
||||
* restart-survival semantics that killed the old ConcurrentHashMap storage).
|
||||
|
||||
+60
@@ -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));
|
||||
}
|
||||
}
|
||||
+107
@@ -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));
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package com.patbond.patbond.user.session;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.user.TestcontainersConfiguration;
|
||||
import com.patbond.patbond.user.security.InternalAuthFilter;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Integration tests of the refresh-session lifecycle (ADR-003) against a
|
||||
* real postgres:18: hashed storage, rotation chaining, family revocation on
|
||||
* reuse, logout scope, expiry.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
class SessionLifecycleIntegrationTest {
|
||||
|
||||
private static final String INTERNAL_TOKEN = "test-internal-token";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JdbcClient jdbcClient;
|
||||
|
||||
private MockHttpServletRequestBuilder internalPost(String path) {
|
||||
return post(path)
|
||||
.header(InternalAuthFilter.HEADER, INTERNAL_TOKEN)
|
||||
.contentType(MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
private String registerUser(String username) throws Exception {
|
||||
String body = mockMvc.perform(internalPost("/internal/users")
|
||||
.content("{\"username\":\"%s\",\"password\":\"secret123\"}".formatted(username)))
|
||||
.andExpect(status().isOk())
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
return JsonPath.read(body, "$.data.id");
|
||||
}
|
||||
|
||||
private Map<String, Object> createSession(String userId) throws Exception {
|
||||
String body = mockMvc.perform(internalPost("/internal/sessions")
|
||||
.content("{\"userId\":\"%s\",\"userAgent\":\"junit\",\"ipAddress\":\"127.0.0.1\"}"
|
||||
.formatted(userId)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
return JsonPath.read(body, "$.data");
|
||||
}
|
||||
|
||||
private String refresh(String refreshToken) throws Exception {
|
||||
return mockMvc.perform(internalPost("/internal/sessions/refresh")
|
||||
.content("{\"refreshToken\":\"%s\"}".formatted(refreshToken)))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createSessionStoresSha256DigestNotPlaintext() throws Exception {
|
||||
String userId = registerUser("sess_digest");
|
||||
Map<String, Object> session = createSession(userId);
|
||||
String refreshToken = (String) session.get("refreshToken");
|
||||
assertThat(refreshToken).isNotBlank();
|
||||
assertThat((String) session.get("jti")).isNotBlank();
|
||||
assertThat((String) session.get("refreshTokenExpiresAt")).contains("T");
|
||||
|
||||
byte[] storedHash = jdbcClient.sql("""
|
||||
SELECT refresh_token_hash FROM identity.auth_sessions WHERE id = :id
|
||||
""")
|
||||
.param("id", UUID.fromString((String) session.get("sessionId")))
|
||||
.query(byte[].class)
|
||||
.single();
|
||||
byte[] expected = MessageDigest.getInstance("SHA-256")
|
||||
.digest(refreshToken.getBytes(StandardCharsets.UTF_8));
|
||||
assertThat(storedHash).isEqualTo(expected).hasSize(32);
|
||||
// The plaintext token appears nowhere in the row.
|
||||
assertThat(new String(storedHash, StandardCharsets.ISO_8859_1)).isNotEqualTo(refreshToken);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshRotatesTokenAndChainsSessions() throws Exception {
|
||||
String userId = registerUser("sess_rotate");
|
||||
Map<String, Object> first = createSession(userId);
|
||||
|
||||
String body = refresh((String) first.get("refreshToken"));
|
||||
assertThat((int) JsonPath.read(body, "$.code")).isZero();
|
||||
String newToken = JsonPath.read(body, "$.data.refreshToken");
|
||||
String newSessionId = JsonPath.read(body, "$.data.sessionId");
|
||||
assertThat(newToken).isNotEqualTo(first.get("refreshToken"));
|
||||
assertThat((String) JsonPath.read(body, "$.data.userId")).isEqualTo(userId);
|
||||
|
||||
Map<String, Object> oldRow = jdbcClient.sql("""
|
||||
SELECT revoked_at, rotated_at, revoke_reason,
|
||||
replaced_by_session_id::text AS replaced_by,
|
||||
token_family_id::text AS family
|
||||
FROM identity.auth_sessions WHERE id = :id
|
||||
""")
|
||||
.param("id", UUID.fromString((String) first.get("sessionId")))
|
||||
.query()
|
||||
.singleRow();
|
||||
assertThat(oldRow.get("revoked_at")).isNotNull();
|
||||
assertThat(oldRow.get("rotated_at")).isNotNull();
|
||||
assertThat(oldRow.get("revoke_reason")).isEqualTo("rotated");
|
||||
assertThat(oldRow.get("replaced_by")).isEqualTo(newSessionId);
|
||||
|
||||
String newFamily = jdbcClient.sql(
|
||||
"SELECT token_family_id::text FROM identity.auth_sessions WHERE id = :id")
|
||||
.param("id", UUID.fromString(newSessionId))
|
||||
.query(String.class)
|
||||
.single();
|
||||
assertThat(newFamily).isEqualTo(oldRow.get("family"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reuseOfRotatedTokenRevokesWholeFamily() throws Exception {
|
||||
String userId = registerUser("sess_reuse");
|
||||
Map<String, Object> first = createSession(userId);
|
||||
String rotatedAway = (String) first.get("refreshToken");
|
||||
|
||||
String current = JsonPath.read(refresh(rotatedAway), "$.data.refreshToken");
|
||||
|
||||
// Replay of the rotated token: rejected and the family is killed.
|
||||
String reuse = refresh(rotatedAway);
|
||||
assertThat((int) JsonPath.read(reuse, "$.code")).isEqualTo(40102);
|
||||
|
||||
// The (previously valid) current token died with the family.
|
||||
String afterKill = refresh(current);
|
||||
assertThat((int) JsonPath.read(afterKill, "$.code")).isEqualTo(40102);
|
||||
|
||||
Integer live = jdbcClient.sql("""
|
||||
SELECT count(*) FROM identity.auth_sessions
|
||||
WHERE user_id = :userId AND revoked_at IS NULL
|
||||
""")
|
||||
.param("userId", UUID.fromString(userId))
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
assertThat(live).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutRevokesOnlyTheCurrentSession() throws Exception {
|
||||
String userId = registerUser("sess_logout");
|
||||
Map<String, Object> phone = createSession(userId);
|
||||
Map<String, Object> tablet = createSession(userId);
|
||||
|
||||
mockMvc.perform(internalPost("/internal/sessions/revoke")
|
||||
.content("{\"userId\":\"%s\",\"refreshToken\":\"%s\"}"
|
||||
.formatted(userId, phone.get("refreshToken"))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
// The logged-out session's token is dead …
|
||||
assertThat((int) JsonPath.read(refresh((String) phone.get("refreshToken")), "$.code"))
|
||||
.isEqualTo(40102);
|
||||
// … while the other device keeps working (ADR-003 multi-device).
|
||||
assertThat((int) JsonPath.read(refresh((String) tablet.get("refreshToken")), "$.code"))
|
||||
.isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void logoutWithForeignUserIdDoesNotRevokeTheSession() throws Exception {
|
||||
String owner = registerUser("sess_owner");
|
||||
String attacker = registerUser("sess_attacker");
|
||||
Map<String, Object> session = createSession(owner);
|
||||
|
||||
mockMvc.perform(internalPost("/internal/sessions/revoke")
|
||||
.content("{\"userId\":\"%s\",\"refreshToken\":\"%s\"}"
|
||||
.formatted(attacker, session.get("refreshToken"))))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
assertThat((int) JsonPath.read(refresh((String) session.get("refreshToken")), "$.code"))
|
||||
.isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiredRefreshTokenIsRejected() throws Exception {
|
||||
String userId = registerUser("sess_expired");
|
||||
Map<String, Object> session = createSession(userId);
|
||||
jdbcClient.sql("""
|
||||
UPDATE identity.auth_sessions
|
||||
SET expires_at = created_at + interval '1 millisecond' WHERE id = :id
|
||||
""")
|
||||
.param("id", UUID.fromString((String) session.get("sessionId")))
|
||||
.update();
|
||||
|
||||
assertThat((int) JsonPath.read(refresh((String) session.get("refreshToken")), "$.code"))
|
||||
.isEqualTo(40102);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownRefreshTokenIsRejected() throws Exception {
|
||||
String body = refresh("bm90LWEtcmVhbC10b2tlbi1hdC1hbGwtanVzdC1iYXNlNjQ");
|
||||
assertThat((int) JsonPath.read(body, "$.code")).isEqualTo(40102);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.patbond.patbond.user.support;
|
||||
|
||||
import io.jsonwebtoken.Jwts;
|
||||
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Runtime-generated RSA material for JWT tests. Nothing here is committed
|
||||
* key material (git-workflow: no credentials in the repository) — every test
|
||||
* run mints a fresh pair and injects the public key via
|
||||
* {@code @DynamicPropertySource}.
|
||||
*/
|
||||
public final class TestJwtKeys {
|
||||
|
||||
public static final KeyPair KEY_PAIR = generate();
|
||||
/** A second pair, for signing tokens the service must reject. */
|
||||
public static final KeyPair WRONG_KEY_PAIR = generate();
|
||||
|
||||
private TestJwtKeys() {
|
||||
}
|
||||
|
||||
public static String publicPem() {
|
||||
return "-----BEGIN PUBLIC KEY-----\n"
|
||||
+ Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded())
|
||||
+ "\n-----END PUBLIC KEY-----";
|
||||
}
|
||||
|
||||
/** Signs an access token the way patbond-auth does (sub/jti/sid/iat/exp). */
|
||||
public static String accessToken(PrivateKey key, UUID userId, Duration ttl) {
|
||||
Instant now = Instant.now();
|
||||
return Jwts.builder()
|
||||
.id(UUID.randomUUID().toString())
|
||||
.subject(userId.toString())
|
||||
.issuer("patbond-auth")
|
||||
.claim("sid", UUID.randomUUID().toString())
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plus(ttl)))
|
||||
.signWith(key, Jwts.SIG.RS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
private static KeyPair generate() {
|
||||
try {
|
||||
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||
generator.initialize(2048);
|
||||
return generator.generateKeyPair();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Test-only configuration: keeps @SpringBootTest deterministic on a clean
|
||||
# checkout, where the git-ignored main application.yml does not exist. The
|
||||
# datasource comes from Testcontainers (@ServiceConnection); the JWT public
|
||||
# key, when a test needs one, is generated at runtime and injected through
|
||||
# @DynamicPropertySource — no key material is committed.
|
||||
spring:
|
||||
application:
|
||||
name: patbond-user
|
||||
|
||||
patbond:
|
||||
internal-token: test-internal-token
|
||||
Reference in New Issue
Block a user