# S02 Access/Refresh Token 隔离与吊销:编码实施指令 > 本文可原样交给编码执行者。权威设计为 `docs/s02-auth-token-isolation-design.md`。任何与设计冲突、会改变其他业务语义或需要扩大公共接口的情况,必须停止并报告,不得自行改设计。 ## 一、任务目标 执行 S02「Access/Refresh Token 隔离、轮换与吊销」。 项目根目录: ```text C:\Users\15642\Desktop\zhilai ``` 基线要求: - 必须包含 `b5084fd`(S01 认证安全整改); - 必须包含 `96a920b`(简历上传安全加固); - 当前最新 Flyway 应为 `V41__message_recall.sql`; - 当前静态 OpenAPI 应为 `173 paths / 211 operations`; - S02 预计新增两个接口,完成后为 `175 paths / 213 operations`。 本轮只实现 S02,不进入 S03、Q01 或 R01。 ## 二、硬性边界 允许: 1. 修改认证 Token 生成、解析、过滤、登录、刷新和登出链路; 2. 扩展现有 `auth_tokens` 表; 3. 新增一个 Flyway,建议 `V42__auth_token_sessions.sql`; 4. 新增 `/auth/refresh` 和 `/auth/logout`; 5. 将账号注销完成、用户禁用接入会话吊销; 6. 更新认证文档、前端联调文档和 OpenAPI; 7. 新增自动化测试。 禁止: 1. 修改 V1–V41; 2. 删除或重建 `auth_tokens` 表; 3. 在数据库、日志、审计、异常或 URL 中保存/输出原始 Token; 4. 永久兼容无 `type` 旧 Token; 5. 把 Refresh Token 当 Access Token; 6. 使用同一密钥签发 Access 和 Refresh; 7. 使用 JVM 本地锁代替数据库并发控制; 8. 修改短信验证码、登录自动注册、角色和资源归属语义; 9. 修改评分、排名、推荐、人才发现、邀约、Offer、申诉、消息撤回或简历上传; 10. 新增设备列表、全部设备登出、Token introspection 等接口; 11. 删除、禁用或放宽既有测试; 12. `git reset`、`git restore`、`git checkout`、`git clean`; 13. `git add -A`、commit、push; 14. 删除、移动或覆盖历史未跟踪脚本、日志、JSON、SQL、xlsx、`.env`; 15. 操作 Docker或执行真实 MySQL 黑盒,除非负责人另行授权。 ## 三、开始前检查 执行并记录: ```powershell cd C:\Users\15642\Desktop\zhilai git branch --show-current git status --short git diff --cached --name-only git log -5 --oneline git diff --check git diff --name-only -- mydreamjob-server/src/main/resources/db/migration ``` 要求: - 暂存区为空; - 记录 HEAD; - 确认基线包含 `96a920b`; - 保护全部已有修改和未跟踪文件; - 搜索并完整阅读适用的 `AGENTS.md`,不存在则记录; - 若认证相关文件存在不属于 S02 的未提交修改,停止并报告; - 建议从已包含 `96a920b` 的基线创建独立分支 `feat/s02-token-isolation`,但不得覆盖用户现有分支或工作树。 ## 四、必须完整阅读 权威文档: ```text docs/s02-auth-token-isolation-design.md docs/api-v0.1.md docs/frontend-integration.md docs/phase1-scope-and-acceptance.md docs/backend-development-roadmap.md docs/openapi-rc.json docs/verify_openapi.py ``` 生产代码: ```text mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtTokenProvider.java mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthToken.java mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenRepository.java mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java mydreamjob-server/src/main/java/com/mydreamjob/common/exception/GlobalExceptionHandler.java mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionService.java mydreamjob-server/src/main/java/com/mydreamjob/deletion/AccountDeletionCompletionWorker.java mydreamjob-server/src/main/java/com/mydreamjob/deletion/DeletionGuardInterceptor.java mydreamjob-server/src/main/java/com/mydreamjob/audit/AuditService.java mydreamjob-server/src/main/resources/application.yml mydreamjob-server/src/main/resources/application-dev.yml mydreamjob-server/src/main/resources/db/migration/V1__base_schema.sql ``` 测试: ```text mydreamjob-server/src/test/java/com/mydreamjob/auth/** mydreamjob-server/src/test/java/com/mydreamjob/security/** mydreamjob-server/src/test/java/com/mydreamjob/deletion/** mydreamjob-server/src/test/resources/application-test.yml mydreamjob-server/src/test/resources/application-integration-test.yml ``` 搜索: ```powershell rg -n "generateAccessToken|generateRefreshToken|validateToken|parseUserId|AuthToken|AuthTokenRepository|refreshToken|Authorization|logout|type.*refresh|JWT_SECRET|refresh-expiration" mydreamjob-server/src docs ``` ## 五、先补失败测试 编码前先增加失败测试,证明当前缺陷存在。至少包括: 1. `refreshTokenCannotAuthenticateOrdinaryEndpoint`:当前 Refresh Token 访问 `/auth/me` 不应成功;修复前应失败。 2. `accessTokenMustContainAccessType`:Access Token 必须有 `type=access`。 3. `accessTokenCannotBeUsedAtRefreshEndpoint`。 4. `legacyTokenWithoutTypeIsRejected`。 5. `accessAndRefreshTokensCannotCrossVerify`。 6. `revokedSessionCannotAuthenticate`。 7. `sameRefreshTokenCannotBeConsumedTwice`。 保留修复前失败输出。不得通过修改期望状态码掩盖问题。 ## 六、配置整改 修改 `application.yml`: 1. 将错误位于 `app.resume.refresh-expiration-ms` 的配置移回 `app.jwt`; 2. 增加: ```yaml app: jwt: access-secret: ${JWT_ACCESS_SECRET} refresh-secret: ${JWT_REFRESH_SECRET} token-hash-secret: ${JWT_TOKEN_HASH_SECRET} expiration-ms: ${JWT_EXPIRATION_MS:86400000} refresh-expiration-ms: ${JWT_REFRESH_EXPIRATION_MS:604800000} issuer: mydreamjob access-audience: mydreamjob-api refresh-audience: mydreamjob-auth ``` 要求: - 生产配置不得提供秘密默认值; - 三项密钥不得互相回退; - 启动时校验密钥长度和互异性; - 测试 profile 使用固定测试密钥; - 不打印密钥; - 原 `app.jwt.secret` 仅作为旧配置迁移项处理,S02 新 Token 不使用它; - 缺失、空值或三项相同必须启动失败,并给出固定、不含密钥的安全配置错误。 新增配置测试: - 三项有效且互异时启动成功; - 任一缺失、空白或过短时失败; - access/refresh 相同时失败; - hash secret 与签名密钥相同时失败; - 错误日志不包含密钥值。 ## 七、JWT Provider 重构 修改 `JwtTokenProvider`,禁止继续使用通用的 `validateToken + parseUserId` 组合来判断 Token 用途。 建议提供明确 API: ```java IssuedToken generateAccessToken(..., Instant capturedNow); IssuedToken generateRefreshToken(..., Instant capturedNow); AccessTokenClaims parseAndValidateAccessToken(String rawToken, Instant capturedNow); RefreshTokenClaims parseAndValidateRefreshToken(String rawToken, Instant capturedNow); ``` 要求: - Access 使用 access secret; - Refresh 使用 refresh secret; - 精确校验 `type`、`iss` 和 `aud`; - 验证 `sub` 为正整数; - 验证 `sid`、`jti` 为规范 UUID; - Access 必须含 `role`; - 新 Token 不含 `phone`; - `iat`、`exp` 使用调用方传入的同一 `capturedNow`; - 过期判断边界固定:`exp <= capturedNow` 视为过期; - 不向调用方返回原始 Claims 对象; - 异常消息不得包含 Token 或 claim 原值。 增加强类型内部 record/DTO,例如: ```text AccessTokenClaims RefreshTokenClaims IssuedToken ``` 不要让 Controller 直接依赖 JJWT 类型。 ## 八、Token 摘要 新增内部服务,例如: ```text mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenHasher.java ``` 要求: - `HmacSHA256`; - 密钥为 `app.jwt.token-hash-secret`; - 输出固定长度十六进制或 Base64URL; - 不存原文; - 不记录输入、输出或异常原值; - 对固定输入输出稳定; - 比较使用 `MessageDigest.isEqual`; - 单元测试使用公开测试向量或确定性断言。 ## 九、Flyway V42 编码前执行: ```powershell Get-ChildItem mydreamjob-server/src/main/resources/db/migration | Sort-Object Name | Select-Object -Last 10 ``` 只有 V42 未被占用时才新增: ```text mydreamjob-server/src/main/resources/db/migration/V42__auth_token_sessions.sql ``` 扩展 `auth_tokens`: ```text session_id VARCHAR(36) NULL token_jti VARCHAR(36) NULL parent_token_id BIGINT NULL consumed_at DATETIME NULL revoked_at DATETIME NULL revocation_reason VARCHAR(40) NULL version BIGINT NOT NULL DEFAULT 0 ``` 新增索引: ```text idx_auth_tokens_session_active(session_id, revoked, consumed_at, expired_at) idx_auth_tokens_user_session(user_id, session_id) uk_auth_tokens_token_jti(token_jti) UNIQUE ``` 要求: - 不修改旧列、不删除旧数据; - `session_id IS NULL` 的旧行不参与新会话认证; - 不新增其他表; - 不添加级联删除; - CHECK/索引语法必须通过 MySQL 8.4 和 H2 MODE=MySQL; - 更新 `AuthToken` 实体并使用 `@Version`; - `token_hash` 保存 Refresh Token HMAC 摘要; - 新记录 `token_type=REFRESH`; - `refresh_token_hash` 保留但新逻辑不依赖。 新增持久化测试: - 旧格式行仍可存在; - 新记录字段正确; - `token_jti` 唯一; - 默认 version=0; - 两个 EntityManager 的旧 version 更新冲突; - 不保存原始 JWT。 ## 十、Repository 原子操作 `AuthTokenRepository` 增加明确查询和条件更新,禁止 `findById -> set -> save` 作为并发消费保证。 至少实现: 1. 按 `tokenHash` 查询候选 Refresh 记录; 2. 按 `userId + sessionId` 判断活动会话; 3. 原子消费:条件包含 `id`、`expectedVersion`、`revoked=false`、`consumedAt IS NULL`、`expiredAt > capturedNow`; 4. 按 `userId + sessionId` 撤销当前会话; 5. 按 `userId` 撤销全部会话。 所有更新返回受影响行数并严格检查。不得通过捕获异常后继续返回成功。 ## 十一、AuthSessionService 新增: ```text mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java ``` 职责: - 创建登录会话; - 保存首个 Refresh Token 摘要; - 校验 Access 所属会话; - 消费并轮换 Refresh Token; - 撤销当前会话; - 撤销用户全部会话; - 处理重放。 ### 登录 - 捕获一次时间; - 生成随机 UUID `sid`; - 生成 Access 和 Refresh 的独立 `jti`; - 生成 Token; - 保存 Refresh 摘要; - 事务提交成功后才返回; - 多次登录必须得到不同 `sid`。 ### 刷新 - Controller 传入原始 Refresh Token,但原文只在内存短暂存在; - Provider 校验 Refresh; - 摘要查询记录; - claim 中的 userId/sid/jti 必须与数据库一致; - 原子消费旧记录; - 插入同 sid 的新记录; - 新旧 jti 不同; - 新 Access 和 Refresh 使用同一次 `capturedNow`; - 旧 Token 再次使用返回固定 401。 ### 重放 - 已消费或已撤销 Token 被再次提交时撤销整个当前 session; - 撤销必须在不会随当前失败事务回滚丢失的事务边界执行; - 写一条脱敏审计; - 不回显“已消费”“已撤销”或数据库状态。 ### 活动会话判断 - session 必须属于 user; - 存在未消费、未撤销、未过期的当前 Refresh 记录; - 用户 active; - 账号注销未完成; - 数据库错误失败关闭。 ## 十二、登录服务 修改 `AuthService.mockLogin`: - 保持验证码、自动注册、注销墓碑和响应字段不变; - 改为创建 AuthSession; - Access Token 必须带 `type=access`; - Refresh Token 必须带 `type=refresh`; - 响应仍包含 `userId/phone/nickname/role/accessToken/refreshToken/expiresIn`; - 数据库只保存 Refresh 摘要; - 会话持久化失败时整个登录事务失败,不得返回 Token或写成功审计; - 不改变 `expiresIn=86400`。 ## 十三、认证过滤器 修改 `JwtAuthenticationFilter`: - 只调用 `parseAndValidateAccessToken`; - Refresh Token、无 type、未知 type、错误 issuer/audience 必须拒绝; - 验证服务端 session; - 验证用户 active 和注销状态; - 通过后使用 userId 建立 SecurityContext; - 不把 Token claim 直接作为权限真相; - 任何失败均不得建立部分认证; - 清理可能存在的 SecurityContext; - 日志使用固定安全文本,不使用异常 `getMessage()`; - 不回显 Token、subject、sid、jti。 真实 MockMvc 测试必须覆盖: - Access 访问 `/auth/me` 成功; - Refresh 访问 `/auth/me` 为 401; - Access/Refresh 交叉密钥失败; - 无 type、未知 type、非法 subject/sid/jti 为 401; - 撤销 session 为 401; - 用户禁用为 401; - 服务端校验抛异常时不放行、不返回 500 内部对象。 ## 十四、刷新和登出接口 新增白名单 DTO: ```text auth/dto/RefreshTokenRequest.java auth/dto/TokenPairResponse.java ``` ### `POST /auth/refresh` - `SecurityConfig` 中精确 permitAll; - 请求体只包含 `refreshToken`; - `@NotBlank` 且设置合理长度上限; - Token 不允许作为 query/path 参数; - 成功返回新 Token 对和 `expiresIn`; - Access Token 作为 refreshToken 提交必须 401; - 所有认证失败统一安全响应。 ### `POST /auth/logout` - 必须 authenticated; - 无 requestBody; - 从通过验证的 Access Token claims / SecurityContext 取得 userId 和 sid; - 不接收 userId、sessionId、refreshToken; - 撤销当前 session; - 保持幂等语义; - 成功响应不包含 Token。 `AuthController` 不得计算摘要、解析 JWT、访问 Repository 或重复实现会话逻辑。 ## 十五、注销与用户禁用 在不改变账号注销状态机的前提下: - `AccountDeletionCompletionWorker` 成功完成注销时撤销用户全部会话; - 状态更新和会话撤销必须具备一致事务语义; - 重复完成不重复产生不受控副作用; - 注销申请 PENDING 时不强制退出; - 取消注销不恢复旧 session; - Filter 每次认证必须拒绝 inactive 或 COMPLETED 用户; - 不接受客户端指定需要撤销的 userId。 补测试证明: - 注销完成后所有设备 Access/Refresh 均失效; - 其他用户会话不受影响; - 禁用用户不能继续认证; - candidate scoring、消息、投递等业务表不被修改。 ## 十六、审计隐私 允许的固定动作: ```text AUTH_SESSION_CREATED AUTH_TOKEN_REFRESHED AUTH_SESSION_REVOKED AUTH_REFRESH_REUSE_DETECTED ``` details 使用 ObjectMapper 序列化白名单 Map/record,禁止拼接不受控 JSON。 禁止进入日志、审计和响应: ```text accessToken refreshToken Authorization tokenHash refreshTokenHash JWT 原文 完整手机号 email password secret Claims 对象 异常对象 toString ``` 日志测试必须捕获真实 Logback 输出,不得只 Mockito verify logger。 ## 十七、单次 Clock 注入 `Clock`,使用 `ZoneId.of("Asia/Shanghai")` 的测试 Clock。 分别验证登录、刷新、登出、重放和注销完成: - 每个入口只读取 Clock 一次; - JWT iat/exp; - DB createdAt/expiredAt/consumedAt/revokedAt; - 审计和响应; 均使用首次捕获时间。 禁止使用 `Thread.sleep()`、系统默认时区或真实时间碰撞作为核心测试。 ## 十八、并发测试 新增真实 H2 集成并发测试: - `@SpringBootTest`; - integration-test profile; - 真实 Service、Repository、事务和 EntityManager; - 两个线程和独立事务; - 使用 CountDownLatch 同时开始; - 不 Mock 条件更新。 测试: 1. `concurrentRefreshOnlyOneRequestSucceeds`; 2. `refreshReplayRevokesOnlyCurrentSession`; 3. `differentSessionsRefreshIndependently`; 4. `logoutRacingWithRefreshCannotLeaveActiveToken`。 断言: - 同一 Token 最多一个刷新成功; - 不产生两个活动后继 Token; - 失败方不返回 500; - 当前 session 被安全撤销; - 其他 session 不受影响; - 无死锁和未处理乐观锁异常。 ## 十九、完整测试矩阵 建议新增: ```text JwtTokenTypeIsolationTest.java JwtAuthenticationTypeIntegrationTest.java AuthTokenHasherTest.java AuthSessionServiceTest.java AuthSessionPersistenceIntegrationTest.java AuthRefreshControllerTest.java AuthRefreshHttpIntegrationTest.java AuthRefreshConcurrencyIntegrationTest.java AuthSessionRevocationIntegrationTest.java AuthTokenClockConsistencyTest.java AuthTokenRedactionTest.java ``` 至少覆盖: ### 类型和 claim - access/refresh 类型; - 独立密钥; - issuer/audience; - subject/sid/jti; - 无 type 和未知 type; - 过期边界; - Token 不含手机号。 ### 生命周期 - 登录; - 刷新轮换; - 二次使用; - 并发刷新; - 当前设备登出; - 多设备隔离; - 注销完成; - 用户禁用。 ### HTTP - 匿名刷新携带有效 Refresh 成功; - Access 调刷新 401; - Refresh 调普通接口 401; - 无 Token 登出 401; - 有效 Access 登出成功; - 错误响应不泄漏。 ### 副作用 - 原始 Token 不落库; - 不产生 RabbitMQ、通知或短信; - 不修改评分、排名、推荐、邀约、消息和简历表; - 只产生预期 auth_tokens 和白名单审计变化。 ## 二十、OpenAPI 和文档 更新: ```text docs/openapi-rc.json docs/verify_openapi.py docs/api-v0.1.md docs/frontend-integration.md docs/backend-development-roadmap.md docs/phase1-scope-and-acceptance.md docs/s02-auth-token-isolation-design.md docs/s02-auth-token-isolation-implementation-instruction.md ``` 运行时生成 OpenAPI,不得手工伪造 schema。 `verify_openapi.py` 增加递归断言: 1. 总数为 `175 paths / 213 operations`; 2. 只新增 `/auth/refresh` 和 `/auth/logout`; 3. Refresh 请求体只含 `refreshToken`; 4. Logout 无 requestBody; 5. 普通安全方案说明只接受 Access Token; 6. schema 不包含 tokenHash、refreshTokenHash、session 数据库对象、phone、secret; 7. 登录原有字段不删除; 8. 不新增其他认证或管理接口。 前端文档必须明确: - 上线时旧 Token 全部失效; - 普通请求只使用 Access; - 401 时最多刷新一次; - 刷新请求必须串行; - 刷新失败清除 Token并重新登录; - 登出后无条件清除本地 Token; - 不记录或上报 Token。 ## 二十一、验证顺序 先运行新增定向测试,按实际类名调整: ```powershell cd C:\Users\15642\Desktop\zhilai\mydreamjob-server mvn -Dtest="JwtTokenTypeIsolationTest,JwtAuthenticationTypeIntegrationTest,AuthTokenHasherTest,AuthSessionServiceTest,AuthSessionPersistenceIntegrationTest,AuthRefreshControllerTest,AuthRefreshHttpIntegrationTest,AuthRefreshConcurrencyIntegrationTest,AuthSessionRevocationIntegrationTest,AuthTokenClockConsistencyTest,AuthTokenRedactionTest" test ``` 再运行认证和注销关联回归: ```powershell mvn -Dtest="*Auth*Test,*Jwt*Test,*Security*Test,*Deletion*Test,*Sms*Test" test ``` 最后执行: ```powershell mvn test mvn clean verify python ..\docs\verify_openapi.py cd C:\Users\15642\Desktop\zhilai git diff --check git status --short git diff --cached --name-only git diff --name-only -- mydreamjob-server/src/main/resources/db/migration ``` 要求: - Failures=0; - Errors=0; - Skipped=0; - `mvn clean verify` BUILD SUCCESS; - OpenAPI=`175/213`; - 暂存区为空; - 唯一新迁移为实际确认编号的 auth token migration; - 未修改 V1–V41; - 未 commit、未 push。 如果 clean verify 因正在运行的 JAR 锁定: - 查询实际 PID、端口和完整命令行; - 不结束未知进程; - 报告并等待负责人确认。 ## 二十二、禁止通过的绕过方式 - 只给 Refresh 增加 claim,却不让 Filter 检查; - 仍用同一签名密钥,只依赖字符串类型; - 继续接受无 type 旧 Token; - 把 Refresh Token 黑名单只放在 JVM 内存; - 数据库存原始 JWT; - 日志打印 Token 便于调试; - 刷新后旧 Token仍有效; - 并发刷新两次都成功; - 登出只删前端 Token,不做服务端吊销; - Access Token 不检查 session,导致登出后仍可用; - 数据库异常时跳过 session 检查; - 用 Mockito 模拟 Repository 代替全部并发和持久化证据; - 修改测试期望值掩盖旧行为; - 修改旧 Flyway; - 为完成 S02 重构全仓身份解析或异常映射。 ## 二十三、完成状态 编码和自动化全部通过后,将设计及路线图状态写为: ```text S02 已实现并完成自动化验证与干净构建,待独立代码复验及真实 MySQL/Docker 黑盒验收。 ``` 不得写“已冻结”“已完成生产验收”或“S03 已开始”。 ## 二十四、最终报告 必须包含: 1. 修复前 Refresh Token 访问普通接口的复现; 2. Access/Refresh 的 claim 和密钥隔离; 3. 无 type 旧 Token 结果; 4. 登录响应兼容结果; 5. Token 摘要算法和数据库脱敏结果; 6. `auth_tokens` 迁移和实体变化; 7. 正常刷新与轮换结果; 8. 二次使用和重放处置; 9. 并发刷新结果; 10. 当前设备登出和多设备隔离; 11. 注销完成、用户禁用后的结果; 12. 单次 capturedNow 证据; 13. 日志、审计和完整 JSON 脱敏; 14. 数据库与外围副作用; 15. 新增接口和 OpenAPI 实际计数; 16. 专项、关联、全量测试数; 17. Failures、Errors、Skipped; 18. clean verify 结果; 19. 精确修改文件; 20. Git 状态; 21. 是否修改 V1–V41、ErrorCode 或其他业务接口; 22. 明确未执行 `git add -A`、commit、push; 23. 明确真实 MySQL/Docker 黑盒仍待独立验收。 任何失败或 SKIP 必须如实报告。完成后停止,不进入 S03。