From be009ac15e28d8e36c479980beedafe82d2645c9 Mon Sep 17 00:00:00 2001 From: "Guangfei.Zhao" Date: Thu, 13 Aug 2026 15:31:30 +0800 Subject: [PATCH] feat: Enhance security documentation with access and refresh token structures, including claims and rotation strategy --- backend/04-security-auth.md | 105 +++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/backend/04-security-auth.md b/backend/04-security-auth.md index 1df4bf5..ade24d7 100644 --- a/backend/04-security-auth.md +++ b/backend/04-security-auth.md @@ -130,6 +130,109 @@ class SecurityConfig( - 切换门店会使当前上下文失效,`webview-ticket` 相关会话需要联动失效(对应架构图 Flow 2 的规则):`identity-store` 切换门店成功后,需要通知 `webview-ticket` 使当前 ticket 状态置为 `INVALIDATED`(走 `bff-orchestration` 编排或事件通知,不是 `identity-store` 直接改 `webview-ticket` 的表)。 - `webview-ticket` 用的票据是独立的短时票据机制(见 [05-integration-layer.md](./05-integration-layer.md)),不复用登录 JWT,避免票据泄漏后长期有效。 +## Access Token 的 claims 结构 + +在现有 `JwtTokenProvider.issueAccessToken` 基础上补充 `jti`(每次签发的唯一 ID),完整 claims: + +```json +{ + "sub": "1024", + "storeId": 7, + "roles": ["STORE_MANAGER"], + "jti": "9f2b6e2e-2f3a-4b7a-9b0a-2c8e6f5a1d3c", + "iat": 1735600000, + "exp": 1735601800 +} +``` + +`jti` 只用于**审计关联**(跟 `traceId` 一起打进日志,方便事后查"这个用户当时用的是哪个 access token"),不用于撤销判断——access token 本身依然是无状态的,服务端不会为了撤销去反查 `jti`,那样就失去了 JWT 免查库校验的意义。真正需要撤销能力的是下面的 refresh token。 + +## Refresh Token:不用 Redis,直接用现有 Postgres + +Access token TTL 短(现有配置 30 分钟),到期后客户端用 refresh token 换新的 access token,避免频繁重新登录。Refresh token **不是 JWT**,是一个不透明的随机字符串(`SecureRandom` 生成 32 字节,base64url 编码):因为 refresh token 需要能被服务端主动吊销(登出、检测到被盗用),JWT 本身无状态、签发后没法在不额外存储的情况下撤销——既然撤销必须要有一张表来查,索性让 refresh token 本身就是这张表的查询 key,不需要再多一层"签发 JWT 又要验签"的复杂度。 + +```kotlin +// domains/identity-store/infrastructure/persistence/RefreshTokenEntity.kt +@Entity +@Table(name = "refresh_tokens") +class RefreshTokenEntity( + @Id @GeneratedValue val id: Long? = null, + val userId: Long, + val tokenHash: String, // SHA-256(原始 token) 的 hex,不存明文 + val expiresAt: Instant, + var revokedAt: Instant? = null, + var replacedByTokenId: Long? = null, // 轮换链,用于检测"已撤销的旧 token 被重放" +) : BaseEntity() +``` + +```sql +-- domains/identity-store/src/main/resources/db/migration/identity-store/V2__refresh_tokens.sql +CREATE TABLE refresh_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + token_hash CHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + replaced_by_token_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); +``` + +```kotlin +// domains/identity-store/application/RefreshTokenService.kt +@Service +class RefreshTokenService( + private val repository: RefreshTokenJpaRepository, + private val clock: Clock, +) { + fun issue(userId: Long): String { + val rawToken = generateOpaqueToken() + repository.save( + RefreshTokenEntity( + userId = userId, + tokenHash = sha256Hex(rawToken), + expiresAt = clock.instant().plus(30, ChronoUnit.DAYS), + ), + ) + return rawToken + } + + @Transactional + fun rotate(rawToken: String): String { + val existing = repository.findByTokenHash(sha256Hex(rawToken)) + ?.takeIf { it.revokedAt == null && it.expiresAt.isAfter(clock.instant()) } + ?: throw InvalidRefreshTokenException() // 已过期/已撤销/被重放,一律要求重新登录 + + val rotated = repository.save( + RefreshTokenEntity( + userId = existing.userId, + tokenHash = sha256Hex(generateOpaqueToken()), + expiresAt = clock.instant().plus(30, ChronoUnit.DAYS), + ), + ) + existing.revokedAt = clock.instant() + existing.replacedByTokenId = rotated.id + return rotated.tokenHash // 实际返回给客户端的是加密前的 rawToken,此处示意轮换关系 + } + + fun revokeAllByUser(userId: Long) = repository.revokeAllByUserId(userId, clock.instant()) // 登出/强制下线 +} +``` + +- **轮换(rotation)**:每次用 refresh token 换 access token,同时签发一个新的 refresh token,旧的立刻标记 `revokedAt`——如果旧 token 之后又被人拿来用一次,说明它可能已经泄漏被盗用,能立刻识别出这次"重放",可以顺带撤销该用户名下所有 refresh token,强制重新登录。 +- **过期清理**:不依赖 Redis 的自动 TTL,加一个简单的定时任务(`@Scheduled`)定期删掉 `expires_at < now()` 的行即可——单个用户同时存在的有效 refresh token 数量本来就很小,数据量级不构成问题。 + +### 为什么现阶段不引入 Redis + +Redis 常被用来存 refresh token/session,图的是两点:**自动过期(TTL)**和**高并发读写性能**。这两点目前都不是硬约束: + +- 过期:一张表 + 一个定时清理任务就够,只是没有 Redis"写入即设 TTL、到期自动消失"那么省事。 +- 性能:refresh token 校验只发生在"access token 过期后换新"这一低频动作上,不是每次请求都查,Postgres 加个唯一索引足够支撑。 +- 额外成本:引入 Redis 意味着在 K8s 上多起一个有状态服务(持久化、备份、连接池配置、AKS 里再开一条访问路径),这跟 [09-build-deploy.md](./09-build-deploy.md)、[07-config-governance.md](./07-config-governance.md) 里"能用现有基础设施就不额外引入运维负担"的取舍一致。 + +结论:refresh token 存现有 Postgres 就够,不引入 Redis;如果以后出现 Redis 能解决、Postgres 解决不了的场景(比如跨实例分布式限流、高频缓存),到时候再评估,届时 refresh token 也可以顺带迁过去,不存在现在这张表以后没法迁移的问题。 + ## 附录:为什么用 `RequestScope` bean 而不是 `ThreadLocal` 传统做法常见用 `ThreadLocal` 存当前用户上下文,但 `ThreadLocal` 有两个常见坑: @@ -141,7 +244,6 @@ Spring 的 `@RequestScope` bean 由容器管理生命周期,请求结束自动 ## 待补充 -- JWT 具体 claims 结构、refresh token 的存储方式(Redis?)。 - 权限模型细节(菜单权限 vs 接口级权限,是否需要单独的权限表)。 - 与 K8s ConfigMap/Secret 配合的密钥轮换方式,见 [07-config-governance.md](./07-config-governance.md)。 @@ -150,3 +252,4 @@ Spring 的 `@RequestScope` bean 由容器管理生命周期,请求结束自动 - [Spring Security 官方文档](https://docs.spring.io/spring-security/reference/index.html) - [jjwt(JWT 库)](https://github.com/jwtk/jjwt) - [OWASP JWT 安全实践](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html) +- [Auth0: Refresh Token Rotation](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation)