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。
二、硬性边界
允许:
- 修改认证 Token 生成、解析、过滤、登录、刷新和登出链路;
- 扩展现有
auth_tokens表; - 新增一个 Flyway,建议
V42__auth_token_sessions.sql; - 新增
/auth/refresh和/auth/logout; - 将账号注销完成、用户禁用接入会话吊销;
- 更新认证文档、前端联调文档和 OpenAPI;
- 新增自动化测试。
禁止:
- 修改 V1–V41;
- 删除或重建
auth_tokens表; - 在数据库、日志、审计、异常或 URL 中保存/输出原始 Token;
- 永久兼容无
type旧 Token; - 把 Refresh Token 当 Access Token;
- 使用同一密钥签发 Access 和 Refresh;
- 使用 JVM 本地锁代替数据库并发控制;
- 修改短信验证码、登录自动注册、角色和资源归属语义;
- 修改评分、排名、推荐、人才发现、邀约、Offer、申诉、消息撤回或简历上传;
- 新增设备列表、全部设备登出、Token introspection 等接口;
- 删除、禁用或放宽既有测试;
git reset、git restore、git checkout、git clean;git add -A、commit、push;- 删除、移动或覆盖历史未跟踪脚本、日志、JSON、SQL、xlsx、
.env; - 操作 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
五、先补失败测试
编码前先增加失败测试,证明当前缺陷存在。至少包括:
refreshTokenCannotAuthenticateOrdinaryEndpoint:当前 Refresh Token 访问/auth/me不应成功;修复前应失败。accessTokenMustContainAccessType:Access Token 必须有type=access。accessTokenCannotBeUsedAtRefreshEndpoint。legacyTokenWithoutTypeIsRejected。accessAndRefreshTokensCannotCrossVerify。revokedSessionCannotAuthenticate。sameRefreshTokenCannotBeConsumedTwice。
保留修复前失败输出。不得通过修改期望状态码掩盖问题。
六、配置整改
修改 application.yml:
- 将错误位于
app.resume.refresh-expiration-ms的配置移回app.jwt; - 增加:
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 作为并发消费保证。
至少实现:
- 按
tokenHash查询候选 Refresh 记录; - 按
userId + sessionId判断活动会话; - 原子消费:条件包含
id、expectedVersion、revoked=false、consumedAt IS NULL、expiredAt > capturedNow; - 按
userId + sessionId撤销当前会话; - 按
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 条件更新。
测试:
concurrentRefreshOnlyOneRequestSucceeds;refreshReplayRevokesOnlyCurrentSession;differentSessionsRefreshIndependently;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 增加递归断言:
- 总数为
175 paths / 213 operations; - 只新增
/auth/refresh和/auth/logout; - Refresh 请求体只含
refreshToken; - Logout 无 requestBody;
- 普通安全方案说明只接受 Access Token;
- schema 不包含 tokenHash、refreshTokenHash、session 数据库对象、phone、secret;
- 登录原有字段不删除;
- 不新增其他认证或管理接口。
前端文档必须明确:
- 上线时旧 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 verifyBUILD 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 已开始”。
二十四、最终报告
必须包含:
- 修复前 Refresh Token 访问普通接口的复现;
- Access/Refresh 的 claim 和密钥隔离;
- 无 type 旧 Token 结果;
- 登录响应兼容结果;
- Token 摘要算法和数据库脱敏结果;
auth_tokens迁移和实体变化;- 正常刷新与轮换结果;
- 二次使用和重放处置;
- 并发刷新结果;
- 当前设备登出和多设备隔离;
- 注销完成、用户禁用后的结果;
- 单次 capturedNow 证据;
- 日志、审计和完整 JSON 脱敏;
- 数据库与外围副作用;
- 新增接口和 OpenAPI 实际计数;
- 专项、关联、全量测试数;
- Failures、Errors、Skipped;
- clean verify 结果;
- 精确修改文件;
- Git 状态;
- 是否修改 V1–V41、ErrorCode 或其他业务接口;
- 明确未执行
git add -A、commit、push; - 明确真实 MySQL/Docker 黑盒仍待独立验收。
任何失败或 SKIP 必须如实报告。完成后停止,不进入 S03。