轻松聘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 隔离与吊销:编码实施指令

本文可原样交给编码执行者。权威设计为 docs/s02-auth-token-isolation-design.md。任何与设计冲突、会改变其他业务语义或需要扩大公共接口的情况,必须停止并报告,不得自行改设计。

一、任务目标

执行 S02「Access/Refresh Token 隔离、轮换与吊销」。

项目根目录:

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 黑盒,除非负责人另行授权。

三、开始前检查

执行并记录:

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,但不得覆盖用户现有分支或工作树。

四、必须完整阅读

权威文档:

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

生产代码:

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

测试:

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

搜索:

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. 增加:
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:

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,例如:

AccessTokenClaims
RefreshTokenClaims
IssuedToken

不要让 Controller 直接依赖 JJWT 类型。

八、Token 摘要

新增内部服务,例如:

mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthTokenHasher.java

要求:

  • HmacSHA256;
  • 密钥为 app.jwt.token-hash-secret;
  • 输出固定长度十六进制或 Base64URL;
  • 不存原文;
  • 不记录输入、输出或异常原值;
  • 对固定输入输出稳定;
  • 比较使用 MessageDigest.isEqual;
  • 单元测试使用公开测试向量或确定性断言。

九、Flyway V42

编码前执行:

Get-ChildItem mydreamjob-server/src/main/resources/db/migration | Sort-Object Name | Select-Object -Last 10

只有 V42 未被占用时才新增:

mydreamjob-server/src/main/resources/db/migration/V42__auth_token_sessions.sql

扩展 auth_tokens:

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

新增索引:

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

新增:

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:

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、消息、投递等业务表不被修改。

十六、审计隐私

允许的固定动作:

AUTH_SESSION_CREATED
AUTH_TOKEN_REFRESHED
AUTH_SESSION_REVOKED
AUTH_REFRESH_REUSE_DETECTED

details 使用 ObjectMapper 序列化白名单 Map/record,禁止拼接不受控 JSON。

禁止进入日志、审计和响应:

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 不受影响;
  • 无死锁和未处理乐观锁异常。

十九、完整测试矩阵

建议新增:

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 和文档

更新:

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。

二十一、验证顺序

先运行新增定向测试,按实际类名调整:

cd C:\Users\15642\Desktop\zhilai\mydreamjob-server
mvn -Dtest="JwtTokenTypeIsolationTest,JwtAuthenticationTypeIntegrationTest,AuthTokenHasherTest,AuthSessionServiceTest,AuthSessionPersistenceIntegrationTest,AuthRefreshControllerTest,AuthRefreshHttpIntegrationTest,AuthRefreshConcurrencyIntegrationTest,AuthSessionRevocationIntegrationTest,AuthTokenClockConsistencyTest,AuthTokenRedactionTest" test

再运行认证和注销关联回归:

mvn -Dtest="*Auth*Test,*Jwt*Test,*Security*Test,*Deletion*Test,*Sms*Test" test

最后执行:

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 重构全仓身份解析或异常映射。

二十三、完成状态

编码和自动化全部通过后,将设计及路线图状态写为:

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。