轻松聘AI开发
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

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 在并发或重放场景中最多成功刷新一次;
  • 旧的无 type Token 不被永久兼容;
  • 不改变手机号验证码登录、角色、资源归属、评分、排名、推荐、邀约、申诉、消息撤回和简历上传语义。

2. 当前实现事实

截至基线提交 96a920b:

  1. JwtTokenProvider.generateAccessToken 没有强制写入 type=access。
  2. generateRefreshToken 写入了 type=refresh。
  3. JwtAuthenticationFilter 只验证 JWT 签名和过期时间,不验证 Token 类型。
  4. Access Token 与 Refresh Token 使用同一 app.jwt.secret。
  5. 因此签名有效且未过期的 Refresh Token 可以通过过滤器并建立普通业务登录态。
  6. 登录响应已经包含 accessToken、refreshToken 和 expiresIn。
  7. 当前没有 /auth/refresh 和 /auth/logout。
  8. auth_tokens 表、AuthToken 实体和 AuthTokenRepository 已存在,但未被登录、刷新、鉴权、登出或账号注销使用。
  9. 当前 JWT 没有 sid 和 jti,不能标识登录会话和单个 Token。
  10. 当前账号注销完成不会使已经签发的 JWT 立即失效。
  11. 当前 JWT 包含完整手机号;普通鉴权实际只需要用户 ID 和会话信息。
  12. application.yml 的 refresh-expiration-ms 当前错误地位于 app.resume 下,而 application-dev.yml 位于正确的 app.jwt 下。非 dev 环境可能依赖 Java 默认值,配置契约不一致。
  13. 现有 Access Token 默认有效期为 24 小时,Refresh Token 默认有效期为 7 天。
  14. 现有 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 和服务端记录,同样拒绝;
  • 用户需要重新完成一次手机号验证码登录;
  • 不提供永久兼容开关;
  • 不把无 type Token 猜测为 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:每次刷新强制轮换

刷新成功必须:

  1. 捕获一次 capturedNow;
  2. 使用 Refresh 专用密钥验签;
  3. 验证 type、iss、aud、sub、sid、jti、iat 和 exp;
  4. 计算原始 Refresh Token 摘要;
  5. 查询对应服务端记录;
  6. 原子消费旧记录;
  7. 生成新的 Access/Refresh Token 对;
  8. 插入新的 Refresh Token 记录;
  9. 提交事务后返回响应。

旧 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 请求不变。

响应字段保持:

  • userId
  • phone
  • nickname
  • role
  • accessToken
  • refreshToken
  • expiresIn

新 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_CREATED
  • AUTH_TOKEN_REFRESHED
  • AUTH_SESSION_REVOKED
  • AUTH_REFRESH_REUSE_DETECTED

若新增审计动作不需要新增 ErrorCode,也不需要新表。

审计详情仅允许:

  • userId;
  • sessionId 的 HMAC 摘要或固定截断不可逆标识;
  • reason 白名单;
  • requestId;
  • 结果状态。

禁止:

  • Access Token;
  • Refresh Token;
  • Token 摘要;
  • JWT claim 全文;
  • 完整手机号、邮箱;
  • Authorization 请求头;
  • 异常对象 toString()。

13. 前端联调语义

前端必须:

  1. 登录后分别保存 Access 和 Refresh Token;
  2. 普通请求只携带 Access Token;
  3. 收到普通 401 时最多执行一次刷新;
  4. 刷新成功后原子替换两个 Token;
  5. 刷新失败时清除两个 Token并跳转登录;
  6. 防止多个并发请求同时用同一 Refresh Token刷新;
  7. 登出时调用 /auth/logout,随后无条件清除本地 Token;
  8. 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:

  1. Refresh Token 不能访问任何普通业务接口;
  2. Access Token 不能用于刷新;
  3. 无 type 旧 Token 被拒绝;
  4. Token 原文不落库、不入日志、不入审计;
  5. 每次刷新完成轮换;
  6. 重放撤销当前会话;
  7. 并发刷新最多成功一次;
  8. 登出立即使当前会话 Access/Refresh 失效;
  9. 多设备会话互不误伤;
  10. 注销完成和用户禁用使全部会话失效;
  11. 旧登录响应字段保持兼容;
  12. 自动化测试 0 Failures/Errors/Skipped;
  13. mvn clean verify 成功;
  14. 静态和运行时 OpenAPI 一致;
  15. 真实 MySQL迁移和 schema validation成功;
  16. 不改变其他业务模块结果。

18. 明确非目标与剩余风险

本版不实现:

  • 查看全部登录设备;
  • 远程踢出单个其他设备;
  • OAuth/OIDC;
  • 短信验证码数据库摘要化;
  • 真实短信供应商;
  • Redis 会话缓存;
  • Access Token 有效期缩短;
  • 浏览器 HttpOnly Cookie 模式;
  • 设备指纹和异常登录检测。

后续可以单独评估,但不得作为 S02 验收通过的替代条件。