22 KiB
S02 Access/Refresh Token 隔离、轮换与吊销设计
状态:S02 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收。
文档类型:解释与技术参考。
目标读者:产品负责人、后端开发、前端联调、安全复验和真实环境验收人员。
解释版本:s02-auth-token-isolation-v1
1. 目标
S02 修复当前 Refresh Token 可以作为 Access Token 使用的认证边界缺陷,并建立可轮换、可吊销、可检测重放的登录会话模型。
本设计必须保证:
- 普通业务接口只接受 Access Token;
- 刷新接口只接受 Refresh Token;
- Token 原文不进入数据库、日志、审计、URL 或错误响应;
- 登录、刷新、登出、账号注销和用户禁用具有明确的吊销语义;
- 同一个 Refresh Token 在并发或重放场景中最多成功刷新一次;
- 旧的无
typeToken 不被永久兼容; - 不改变手机号验证码登录、角色、资源归属、评分、排名、推荐、邀约、申诉、消息撤回和简历上传语义。
2. 当前实现事实
截至基线提交 96a920b:
JwtTokenProvider.generateAccessToken没有强制写入type=access。generateRefreshToken写入了type=refresh。JwtAuthenticationFilter只验证 JWT 签名和过期时间,不验证 Token 类型。- Access Token 与 Refresh Token 使用同一
app.jwt.secret。 - 因此签名有效且未过期的 Refresh Token 可以通过过滤器并建立普通业务登录态。
- 登录响应已经包含
accessToken、refreshToken和expiresIn。 - 当前没有
/auth/refresh和/auth/logout。 auth_tokens表、AuthToken实体和AuthTokenRepository已存在,但未被登录、刷新、鉴权、登出或账号注销使用。- 当前 JWT 没有
sid和jti,不能标识登录会话和单个 Token。 - 当前账号注销完成不会使已经签发的 JWT 立即失效。
- 当前 JWT 包含完整手机号;普通鉴权实际只需要用户 ID 和会话信息。
application.yml的refresh-expiration-ms当前错误地位于app.resume下,而application-dev.yml位于正确的app.jwt下。非 dev 环境可能依赖 Java 默认值,配置契约不一致。- 现有 Access Token 默认有效期为 24 小时,Refresh Token 默认有效期为 7 天。
- 现有
auth_tokens表没有会话标识、轮换父子关系、消费时间、撤销时间和乐观锁版本。
3. 威胁模型
本版重点防护以下风险:
- Refresh Token 被复制到
Authorization: Bearer后直接调用普通业务接口; - 已登出或已注销用户继续使用旧 Access Token;
- Refresh Token 在日志、数据库、错误响应或 URL 中泄露;
- 同一个 Refresh Token 被并发刷新两次,产生两组有效 Token;
- 已消费 Refresh Token 被攻击者再次使用而系统无法识别;
- Token 类型缺失、大小写变化或未知值被宽松接受;
- 非法 subject、
sid或jti引发 500 或回显内部数据; - 数据库或会话校验异常时过滤器错误放行;
- 多设备登录时,一个设备的普通登出误伤其他设备;
- 账号注销、用户禁用等全局安全事件没有撤销全部会话。
不在本版解决:终端设备指纹、地理异常检测、硬件绑定、OAuth/OIDC、短信供应商、验证码摘要化、密码登录和单点登录。
4. 设计决策
D01:Token 类型强制隔离
- Access Token 必须包含精确字符串
type=access。 - Refresh Token 必须包含精确字符串
type=refresh。 - Token 类型区分大小写。
- 类型缺失、空白、未知或不匹配均拒绝。
- 普通业务过滤器只能解析 Access Token。
- 刷新服务只能解析 Refresh Token。
D02:Access 与 Refresh 使用独立签名密钥
新增配置:
app:
jwt:
access-secret: ${JWT_ACCESS_SECRET}
refresh-secret: ${JWT_REFRESH_SECRET}
token-hash-secret: ${JWT_TOKEN_HASH_SECRET}
三项密钥必须彼此独立。生产环境不允许自动回退到同一个默认密钥。测试配置使用固定的测试专用值,但不得进入生产配置。
独立密钥是类型 claim 之外的第二道隔离:即使未来某处遗漏类型判断,Refresh Token 也不能通过 Access Token 验签器。
D03:旧 Token 采用强制重新登录
部署 S02 后:
- 无
type的旧 Access Token 拒绝; - 旧 Refresh Token 虽有
type=refresh,但没有sid、jti和服务端记录,同样拒绝; - 用户需要重新完成一次手机号验证码登录;
- 不提供永久兼容开关;
- 不把无
typeToken 猜测为 Access Token。
前端必须统一处理 401:清除本地 Token,跳转登录页,不循环刷新。
D04:保持现有有效期
- Access Token 默认 24 小时;
- Refresh Token 默认 7 天;
- 登录响应的
expiresIn=86400保持现有含义; - S02 不顺带缩短 Token 有效期;
- 有效期必须来自配置并经过正数、上限和单位校验。
D05:每次登录创建独立设备会话
- 每次成功登录生成新的
sessionId; - 同一用户可以同时拥有多个活动会话;
- 普通登出只撤销当前
sessionId; - Refresh Token 重放只撤销对应
sessionId; - 账号注销完成、用户禁用及未来的敏感认证信息变更撤销该用户全部会话。
本版不采集或保存设备指纹,不通过 User-Agent 判断设备身份。
D06:JWT Claim 白名单
Access Token 必须包含:
| Claim | 说明 |
|---|---|
sub |
十进制正整数用户 ID |
type |
固定为 access |
sid |
UUID 格式的会话 ID |
jti |
UUID 格式的 Token ID |
role |
登录时角色,仅作响应和诊断辅助;资源权限仍以现有服务层校验为准 |
iat |
签发时间 |
exp |
过期时间 |
iss |
固定发行者 mydreamjob |
aud |
固定受众 mydreamjob-api |
Refresh Token 必须包含:
| Claim | 说明 |
|---|---|
sub |
十进制正整数用户 ID |
type |
固定为 refresh |
sid |
与当前登录会话一致 |
jti |
本次 Refresh Token 的唯一 ID |
iat |
签发时间 |
exp |
过期时间 |
iss |
固定发行者 mydreamjob |
aud |
固定受众 mydreamjob-auth |
新 Token 不再包含手机号。不得加入验证码、邮箱、昵称、简历地址或其他个人资料。
D07:Refresh Token 原文不落库
数据库只保存:
HMAC-SHA256(JWT_TOKEN_HASH_SECRET, rawRefreshToken)
- 不使用普通可逆加密;
- 不使用无密钥的快速摘要作为唯一保护;
- 比较摘要时使用常量时间比较;
- Token、摘要和签名密钥均不得写日志或审计;
token_hash的业务含义固定为 Refresh Token 摘要。
D08:每次刷新强制轮换
刷新成功必须:
- 捕获一次
capturedNow; - 使用 Refresh 专用密钥验签;
- 验证
type、iss、aud、sub、sid、jti、iat和exp; - 计算原始 Refresh Token 摘要;
- 查询对应服务端记录;
- 原子消费旧记录;
- 生成新的 Access/Refresh Token 对;
- 插入新的 Refresh Token 记录;
- 提交事务后返回响应。
旧 Refresh Token 被消费后永久失效。
D09:Refresh Token 重放关闭当前会话
若签名有效的 Refresh Token 对应记录已经被消费或撤销:
- 本次刷新返回 HTTP 401;
- 撤销相同
sessionId下的全部 Token 记录; - 当前会话的所有 Access Token 随即失效;
- 不影响同一用户的其他设备会话;
- 写一条不含 Token、摘要或个人信息的安全审计;
- 错误响应不得说明记录是否存在、已消费还是已撤销。
D10:Access Token 必须检查服务端会话状态
JwtAuthenticationFilter 在完成 Access JWT 验签后,还必须确认:
sid属于sub对应用户;- 会话存在活动、未消费且未撤销的 Refresh Token 记录;
- 会话未过期;
- 用户存在、处于 active 状态且未完成注销。
任一条件不满足时不得建立 SecurityContext。
数据库是第一版的事实来源。Redis 缓存不属于 S02 首版;后续若增加缓存,数据库仍是最终事实来源,缓存异常不得放行。
D11:普通登出只撤销当前会话
POST /auth/logout:
- 需要有效 Access Token;
- 从 Token 中读取
sub和sid,不接受客户端传 userId/sessionId; - 撤销该用户当前
sid的所有 Token 记录; - 重复登出保持幂等;
- 不影响其他设备会话;
- 成功响应不返回 Token。
D12:全局安全事件撤销全部会话
以下事件必须调用统一的 revokeAllUserSessions(userId, reason, capturedNow):
- 账号注销完成;
- 用户被禁用;
- 未来的手机号或认证凭据变更。
账号注销申请处于冷静期时不立即撤销全部会话,保持当前产品语义;注销完成时必须撤销。取消注销不恢复已经撤销的会话。
D13:单次时间捕获
登录、刷新、登出、重放处置和全局吊销每个入口只允许调用一次 Clock:
- JWT
iat、exp; - 数据库
createdAt、expiredAt、consumedAt、revokedAt; - 审计时间判断;
- 响应有效期;
均使用同一个 capturedNow。不得依赖 new Date()、LocalDateTime.now() 或系统默认时区散落取时。
固定业务时区为 Asia/Shanghai,数据库时间按项目现有约定落库。
D14:失败关闭
以下情况一律不建立认证:
- Token 解析异常;
- 类型、发行者或受众错误;
- subject、
sid或jti非法; - 服务端记录不存在;
- 会话已撤销、已消费或过期;
- 用户不存在、被禁用或已注销;
- 数据库读取或摘要计算失败。
过滤器只记录固定安全文本和 requestId,不记录异常消息、Token、摘要或 claim 原值。
D15:错误码与 HTTP 语义
优先复用现有:
UNAUTHORIZED(2001);TOKEN_EXPIRED(2003);TOKEN_INVALID(2004);SERVICE_UNAVAILABLE(9001)。
公共认证失败统一表现为 HTTP 401。不得通过不同响应告诉客户端 Token 是“不存在”“已消费”“已撤销”还是“重放”。
除非实现阶段发现现有错误映射无法满足固定 401,才允许提出新增 ErrorCode;未获确认不得新增。
D16:接口增量固定为两个 operation
新增:
POST /auth/refresh;POST /auth/logout。
预计 OpenAPI 从 173 paths / 211 operations 变为 175 paths / 213 operations。
不得新增会话管理列表、全部设备登出、Token introspection 或管理员代操作接口。
D17:并发由数据库条件更新保证
同一 Refresh Token 并发刷新:
- 使用包含
id、consumed_at IS NULL、revoked=0和version的条件更新; - 只有一个事务可以消费成功;
- 其他事务不得生成第二组有效 Token;
- 失败方执行重放处置或返回固定 401;
- 不使用 JVM 本地锁作为正确性基础。
D18:设计范围隔离
S02 不改变:
- 短信验证码生成、过期、锁定和发送语义;
- 登录自动创建用户的现有规则;
- 用户角色及资源归属;
- 岗位、投递、Offer、评分、排名、推荐、人才发现、邀约;
- 申诉、消息撤回和简历上传;
- V1–V41 迁移内容。
5. Token 决策表
| Token/状态 | 普通业务接口 | /auth/refresh |
/auth/logout |
注销完成后 | 过期后 |
|---|---|---|---|---|---|
| 有效 Access | 允许 | 拒绝 | 允许 | 拒绝 | 拒绝 |
| 有效 Refresh | 拒绝 | 允许并轮换 | 拒绝 | 拒绝 | 拒绝 |
无 type 旧 Token |
拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 |
未知 type |
拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 |
| 已消费 Refresh | 拒绝 | 401 并撤销当前会话 | 拒绝 | 拒绝 | 拒绝 |
| 已撤销 Token | 拒绝 | 拒绝 | 幂等 | 拒绝 | 拒绝 |
| 会话不存在 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 |
| 用户被禁用 | 拒绝 | 拒绝 | 拒绝 | 拒绝 | 拒绝 |
6. 状态机
单个 Refresh Token 记录状态:
ACTIVE -> CONSUMED
ACTIVE -> REVOKED
CONSUMED -> REVOKED (重放或全局吊销)
不允许从 CONSUMED 或 REVOKED 恢复为 ACTIVE。
会话状态由同一 sessionId 下的记录推导:存在一个未消费、未撤销且未过期的当前 Refresh Token 记录时,会话为活动状态。
7. 数据模型
7.1 迁移原则
- 编码前重新确认最新 Flyway;当前建议文件名为
V42__auth_token_sessions.sql; - 若 V42 已被占用,停止并重新编号;
- 不修改 V1–V41;
- 不删除旧记录;
- 新字段先允许旧数据为空,新签发记录由应用强制非空;
- Hibernate 使用
ddl-auto=validate; - 迁移必须兼容真实 MySQL 8.4 和项目 H2 测试模式。
7.2 auth_tokens 扩展字段
建议增加:
| 字段 | 类型 | 说明 |
|---|---|---|
session_id |
VARCHAR(36) NULL | 每次登录的会话 UUID |
token_jti |
VARCHAR(36) NULL | Refresh Token 的 jti |
parent_token_id |
BIGINT NULL | 轮换前一条记录 ID |
consumed_at |
DATETIME NULL | 成功刷新时消费时间 |
revoked_at |
DATETIME NULL | 撤销时间 |
revocation_reason |
VARCHAR(40) NULL | 固定白名单原因 |
version |
BIGINT NOT NULL DEFAULT 0 | 乐观锁/条件更新版本 |
固定含义:
token_hash:Refresh Token 的 HMAC-SHA256 摘要;token_type:新记录固定REFRESH;expired_at:Refresh Token 到期时间;revoked:现有兼容布尔值;refresh_token_hash:旧字段保留但新逻辑不依赖,后续版本再评估移除。
索引建议:
INDEX idx_auth_tokens_session_active (session_id, revoked, consumed_at, expired_at)
INDEX idx_auth_tokens_user_session (user_id, session_id)
UNIQUE INDEX uk_auth_tokens_token_jti (token_jti)
不得在本轮清理或改写旧 auth_tokens 数据。session_id IS NULL 的旧记录永远不能被新鉴权逻辑视为活动会话。
8. 组件职责
JwtTokenProvider
- 分别生成和解析 Access/Refresh Token;
- 使用不同签名密钥;
- 强制校验类型、发行者、受众和必需 claim;
- 接收外部传入的
capturedNow,不自行重复取时; - 不访问数据库。
AuthTokenHasher
- 只负责 Refresh Token HMAC 摘要;
- 使用独立密钥;
- 支持常量时间比较;
- 不记录输入和输出。
AuthSessionService
- 创建会话和首个 Refresh Token 记录;
- 验证、消费、轮换 Refresh Token;
- 判断 Access Token 所属会话是否活动;
- 撤销当前会话或用户全部会话;
- 承担事务与并发控制。
JwtAuthenticationFilter
- 只接受 Access Token;
- 调用 Provider 完成密码学和 claim 校验;
- 调用 Session Service 验证服务端会话状态;
- 通过后建立 SecurityContext;
- 失败时保持匿名,由 SecurityFilterChain 返回既有 401。
AuthService
- 登录成功时创建服务端会话;
- 只在会话记录成功持久化后返回 Token;
- 编排刷新和登出;
- 保持现有用户登录/自动创建语义。
AuthController
- 接收白名单 DTO;
- 不解析 JWT、不计算摘要、不接收 userId/sessionId;
- 不在异常响应中回显 Token;
- 不返回 Entity 或数据库字段。
9. API 契约
9.1 登录
现有 POST /auth/login 请求不变。
响应字段保持:
userIdphonenicknameroleaccessTokenrefreshTokenexpiresIn
新 Token 从 S02 起包含完整类型和会话 claim。旧客户端不需要解析 JWT 内部 claim。
9.2 刷新
POST /auth/refresh
请求:
{
"refreshToken": "<refresh-token>"
}
成功响应:
{
"code": 1000,
"data": {
"accessToken": "<new-access-token>",
"refreshToken": "<new-refresh-token>",
"expiresIn": 86400
},
"requestId": "<request-id>"
}
要求:
- 请求体只允许
refreshToken; - 不能为空,设置合理长度上限;
- 不接受查询参数或路径参数中的 Token;
- 旧 Refresh Token 刷新成功后立即失效。
9.3 登出
POST /auth/logout
- 使用
Authorization: Bearer <access-token>; - 无 request body;
- 成功返回现有
ApiResponse包装的固定消息; - 重复登出按幂等成功处理,但已经撤销的 Access Token通常无法再通过过滤器,因此客户端不应依赖第二次请求;
- 不接收 Refresh Token、userId 或 sessionId 参数。
10. SecurityConfig
/auth/login、/auth/sms/send和/auth/refresh允许匿名进入;/auth/logout必须 authenticated;permitAll只表示可进入 Controller,不代表 Refresh Token 校验成功;- 其他接口规则不变;
- 不扩大
/auth/**为整体公开; - Refresh Token 放入 Authorization 头访问任何普通接口必须得到 401。
11. 事务与并发
登录
用户读取/创建、会话记录创建和登录审计在一个事务内完成。会话记录失败时不能返回 Token。
刷新
旧 Token 条件消费和新 Token 记录插入在一个事务内完成。提交失败时不能返回新 Token。
登出
同一事务撤销当前会话记录并写脱敏审计。
重放
发现已消费 Token 时,在独立明确事务中撤销对应会话,避免刷新事务回滚后撤销也丢失。实现必须通过测试证明最终会话已撤销。
12. 审计与日志
允许的审计动作建议:
AUTH_SESSION_CREATEDAUTH_TOKEN_REFRESHEDAUTH_SESSION_REVOKEDAUTH_REFRESH_REUSE_DETECTED
若新增审计动作不需要新增 ErrorCode,也不需要新表。
审计详情仅允许:
- userId;
- sessionId 的 HMAC 摘要或固定截断不可逆标识;
- reason 白名单;
- requestId;
- 结果状态。
禁止:
- Access Token;
- Refresh Token;
- Token 摘要;
- JWT claim 全文;
- 完整手机号、邮箱;
- Authorization 请求头;
- 异常对象
toString()。
13. 前端联调语义
前端必须:
- 登录后分别保存 Access 和 Refresh Token;
- 普通请求只携带 Access Token;
- 收到普通 401 时最多执行一次刷新;
- 刷新成功后原子替换两个 Token;
- 刷新失败时清除两个 Token并跳转登录;
- 防止多个并发请求同时用同一 Refresh Token刷新;
- 登出时调用
/auth/logout,随后无条件清除本地 Token; - S02 上线后接受一次强制重新登录。
不允许把 Refresh Token 放入 URL、本地日志、埋点或错误上报正文。
14. 自动化测试要求
至少覆盖:
Provider
- Access 包含
type=access; - Refresh 包含
type=refresh; - 两类 Token 使用不同密钥;
- 交叉验签失败;
- issuer/audience/type/sub/sid/jti 缺失或错误时拒绝;
- 过期边界;
- 新 Token 不含手机号。
Filter
- Access Token 可访问普通接口;
- Refresh Token 访问普通接口为 401;
- 无 type、未知 type、非法 subject/sid/jti 为 401;
- 已撤销会话、用户禁用、注销完成为 401;
- 服务端检查异常时不放行;
- 响应和日志不回显 Token。
登录与存储
- 登录生成一条活动会话记录;
- 数据库只保存摘要;
- 登录响应结构不变;
- 会话持久化失败不返回 Token;
- 多次登录生成不同 sessionId。
刷新与并发
- 正常刷新轮换 Token;
- 原 Token 变为 consumed;
- 新旧 jti 不同、sid 相同;
- 同一 Refresh Token 第二次使用返回 401并撤销当前会话;
- 两线程并发刷新最多一个成功;
- 最终只有一个后继 Token;
- 无 500、死锁或重复活动记录。
登出与注销
- 登出撤销当前设备;
- 其他设备仍可用;
- 重复撤销幂等;
- 注销完成撤销全部设备;
- 注销取消不恢复旧会话;
- 被禁用用户 Token 无效。
JSON 与日志脱敏
递归禁止出现 Token、摘要、Authorization、手机号、邮箱、secret、password 和内部对象。
15. 真实 MySQL/Docker 验收
必须验证:
- 新迁移在真实 MySQL 8.4 成功;
ddl-auto=validate通过;- 登录记录只含摘要;
- Refresh Token 无法调用普通接口;
- Access Token 无法调用刷新接口;
- 正常轮换、重放和两线程并发符合设计;
- 登出后旧 Access/Refresh 均失效;
- 多设备隔离;
- 注销完成全部失效;
- 运行时 OpenAPI 为预计的
175/213; - 日志、审计和数据库无 Token 原文;
- RabbitMQ、通知、评分和其他业务表无新增副作用。
16. 发布与回滚
发布前
- 前端必须先支持刷新失败后重新登录;
- 配置三个独立强密钥;
- 在隔离环境完成真实 MySQL 迁移和黑盒;
- 确认所有节点同时升级,避免新旧 Token 策略混跑。
发布结果
- 所有旧 Token失效;
- 所有用户需要重新登录一次;
- 新登录产生服务端会话记录。
回滚
- 不回滚或修改已执行 Flyway;
- 应用回滚到旧版会重新接受只看签名的 Token,存在安全倒退;
- 因此 S02 发布后不允许直接回滚到未隔离版本;
- 紧急回滚应发布保持类型隔离的兼容修复版本,而不是恢复旧过滤器。
17. 验收门槛
只有全部满足才可冻结 S02:
- Refresh Token 不能访问任何普通业务接口;
- Access Token 不能用于刷新;
- 无 type 旧 Token 被拒绝;
- Token 原文不落库、不入日志、不入审计;
- 每次刷新完成轮换;
- 重放撤销当前会话;
- 并发刷新最多成功一次;
- 登出立即使当前会话 Access/Refresh 失效;
- 多设备会话互不误伤;
- 注销完成和用户禁用使全部会话失效;
- 旧登录响应字段保持兼容;
- 自动化测试 0 Failures/Errors/Skipped;
mvn clean verify成功;- 静态和运行时 OpenAPI 一致;
- 真实 MySQL迁移和 schema validation成功;
- 不改变其他业务模块结果。
18. 明确非目标与剩余风险
本版不实现:
- 查看全部登录设备;
- 远程踢出单个其他设备;
- OAuth/OIDC;
- 短信验证码数据库摘要化;
- 真实短信供应商;
- Redis 会话缓存;
- Access Token 有效期缩短;
- 浏览器 HttpOnly Cookie 模式;
- 设备指纹和异常登录检测。
后续可以单独评估,但不得作为 S02 验收通过的替代条件。