# 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 使用独立签名密钥 新增配置: ```yaml 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 原文不落库 数据库只保存: ```text 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 记录状态: ```text 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`:旧字段保留但新逻辑不依赖,后续版本再评估移除。 索引建议: ```sql 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` 请求: ```json { "refreshToken": "" } ``` 成功响应: ```json { "code": 1000, "data": { "accessToken": "", "refreshToken": "", "expiresIn": 86400 }, "requestId": "" } ``` 要求: - 请求体只允许 `refreshToken`; - 不能为空,设置合理长度上限; - 不接受查询参数或路径参数中的 Token; - 旧 Refresh Token 刷新成功后立即失效。 ### 9.3 登出 `POST /auth/logout` - 使用 `Authorization: Bearer `; - 无 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 验收通过的替代条件。