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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 12:15:40 +08:00
parent 43ab6c5827
commit 4dc3dcdfa3
51 changed files with 2768 additions and 151 deletions
@@ -11,9 +11,12 @@ public enum ErrorCode {
VALIDATION_ERROR(40000, 400, "参数校验失败"),
INVALID_CREDENTIALS(40100, 401, "用户名或密码错误"),
TOKEN_INVALID(40101, 401, "token 无效或过期"),
REFRESH_TOKEN_INVALID(40102, 401, "refresh token 已失效或被重用"),
USER_NOT_FOUND(40400, 404, "用户不存在"),
USERNAME_EXISTS(40900, 409, "用户名已存在"),
PHONE_EXISTS(40901, 409, "手机号已被使用"),
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
@@ -0,0 +1,56 @@
package com.patbond.patbond.common.session;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.UUID;
/**
* Internal contract: patbond-auth asks patbond-user (the identity schema
* owner) to open a refresh session for a just-authenticated user. Device
* metadata is optional observability data for the multi-device session list.
*/
public class CreateSessionRequest {
@NotNull(message = "userId 不能为空")
private UUID userId;
@Size(max = 512, message = "userAgent 长度不能超过512位")
private String userAgent;
@Size(max = 45, message = "ipAddress 长度不能超过45位")
private String ipAddress;
public CreateSessionRequest() {
}
public CreateSessionRequest(UUID userId, String userAgent, String ipAddress) {
this.userId = userId;
this.userAgent = userAgent;
this.ipAddress = ipAddress;
}
public UUID getUserId() {
return userId;
}
public void setUserId(UUID userId) {
this.userId = userId;
}
public String getUserAgent() {
return userAgent;
}
public void setUserAgent(String userAgent) {
this.userAgent = userAgent;
}
public String getIpAddress() {
return ipAddress;
}
public void setIpAddress(String ipAddress) {
this.ipAddress = ipAddress;
}
}
@@ -0,0 +1,25 @@
package com.patbond.patbond.common.session;
import jakarta.validation.constraints.NotBlank;
/** Internal contract: rotate a refresh session (ADR-003). */
public class RefreshSessionRequest {
@NotBlank(message = "refreshToken 不能为空")
private String refreshToken;
public RefreshSessionRequest() {
}
public RefreshSessionRequest(String refreshToken) {
this.refreshToken = refreshToken;
}
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
}
@@ -0,0 +1,45 @@
package com.patbond.patbond.common.session;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
/**
* Internal contract: logout — revoke the one session holding this refresh
* token, scoped to the user taken from the verified access token so a token
* from another account cannot be revoked (ADR-003: logout revokes only the
* current session; other devices stay logged in).
*/
public class RevokeSessionRequest {
@NotNull(message = "userId 不能为空")
private UUID userId;
@NotBlank(message = "refreshToken 不能为空")
private String refreshToken;
public RevokeSessionRequest() {
}
public RevokeSessionRequest(UUID userId, String refreshToken) {
this.userId = userId;
this.refreshToken = refreshToken;
}
public UUID getUserId() {
return userId;
}
public void setUserId(UUID userId) {
this.userId = userId;
}
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
}
@@ -0,0 +1,72 @@
package com.patbond.patbond.common.session;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* Internal contract: the session material patbond-user hands back to
* patbond-auth after creating or rotating a session. The refresh token is the
* only plaintext copy that ever exists (the database stores its SHA-256
* digest); the jti is minted here so auth can embed it in the access token
* it signs, matching auth_sessions.access_token_jti without a second call.
*/
public class SessionTokens {
private UUID sessionId;
private UUID userId;
private String jti;
private String refreshToken;
private OffsetDateTime refreshTokenExpiresAt;
public SessionTokens() {
}
public SessionTokens(UUID sessionId, UUID userId, String jti,
String refreshToken, OffsetDateTime refreshTokenExpiresAt) {
this.sessionId = sessionId;
this.userId = userId;
this.jti = jti;
this.refreshToken = refreshToken;
this.refreshTokenExpiresAt = refreshTokenExpiresAt;
}
public UUID getSessionId() {
return sessionId;
}
public void setSessionId(UUID sessionId) {
this.sessionId = sessionId;
}
public UUID getUserId() {
return userId;
}
public void setUserId(UUID userId) {
this.userId = userId;
}
public String getJti() {
return jti;
}
public void setJti(String jti) {
this.jti = jti;
}
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
public OffsetDateTime getRefreshTokenExpiresAt() {
return refreshTokenExpiresAt;
}
public void setRefreshTokenExpiresAt(OffsetDateTime refreshTokenExpiresAt) {
this.refreshTokenExpiresAt = refreshTokenExpiresAt;
}
}