12 KiB
S02 独立代码复验与真实 MySQL/Docker 黑盒验收报告(脱敏版)
- 验收日期:2026-09-14
- 分支:
feat/s02-token-isolation - 验收对象:S02「Access/Refresh Token 隔离、轮换与吊销」
- 结论:PASS —— S02独立代码复验和真实MySQL/Docker黑盒全部通过,可以进入精确暂存检查点。
- 本报告不含:JWT 原文、Token 摘要全文、验证码、完整手机号、数据库口令、JWT 密钥、
.env内容、Authorization请求头
1. Git 基线与工作区状态
| 项 | 实测 |
|---|---|
| 分支 | feat/s02-token-isolation |
| HEAD | 96a920b fix: harden resume upload safety |
| 暂存区 | 空 |
git diff --check |
无空白错误 |
| 工作区 | 63 个已修改 + 116 个未跟踪 |
| 唯一新增迁移 | V42__auth_token_sessions.sql(V1–V41 未修改) |
| 删除文件 | 0 |
| AGENTS.md | 未发现 |
| 静态 OpenAPI | 175 paths / 213 operations(All checks passed!) |
2. 独立代码审查逐项结论
| 项 | 结论 | 关键证据 |
|---|---|---|
| A1 Token 类型隔离 | PASS | Access 用 accessKey、带 type=access;Refresh 用 refreshKey、带 type=refresh、无 role;摘要用独立 tokenHashSecret;三项密钥经 requireDistinct 强制互异;两类 Token 均不含 phone/email/nickname/resumeUrl |
| A2 JWT claims | PASS | JwtTokenProvider.parse() 统一校验 type/iss/aud/exp/iat,requirePositiveSubject 与 requireCanonicalUuid 校验 sub/sid/jti;时间契约 iat <= capturedNow < exp;异常统一 InvalidTokenException(固定文本,不含 Token/claim) |
| A3 服务端会话状态 | PASS | JwtAuthenticationFilter 验签后调用 isSessionActive:用户存在 + active + session 下存在未消费/未撤销/未过期记录;session_id IS NULL 永不命中;异常失败关闭;createSession 对 inactive 用户先抛异常(不先发 Token) |
| A4 刷新轮换事务 | PASS | refresh() 无 @Transactional(编排入口);executeRefreshAttempt 在独立事务;消费与写后继同事务;条件更新含 id/version/revoked/consumedAt/expiredAt;consumed != 1 即判定重放;重放信息经 RefreshAttempt.Replayed 返回而非抛异常;重放撤销在尝试事务之后执行;无 JVM 锁、无 Thread.sleep |
| A5 并发重放 | PASS | revokeReplayedSession 用 REQUIRES_NEW,逐轮 revokeSession 后校验活动 Token 数,最多重试 3 次(无 sleep),仍失败则抛出(不静默)。日志中已无 could not take locks 分支 |
| A6 logout | PASS | 只撤销当前 session;幂等;其他设备不受影响;测试未使用 catch (Throwable),未在最终断言前补 logout |
| A7 Token 摘要与脱敏 | PASS | HMAC-SHA256 + 独立密钥;64 位小写十六进制;MessageDigest.isEqual 常量时间比较;hash(null) 与 hash("") 同值但生产路径在校验后才调用 |
| A8 capturedNow | PASS | 登录/刷新/登出每个入口只读一次 Clock;created_at/expired_at/consumed_at/revoked_at/审计均来自该次捕获;固定时区 Asia/Shanghai |
| A9 用户禁用与注销 | PASS | createSession 拒绝 inactive;注销完成在同一业务事务内 desensitize()(active=false)+ revokeAllUserSessions;回退状态不恢复已撤销 Token;其他用户不受影响 |
| A10 配置安全 | PASS(代码层) | 三项密钥缺失/过短/相同/issuer 空/audience 空均启动失败,错误只含密钥名不含值;旧 JWT_SECRET 不再强制解析;CandidateKeyGenerator 在空密钥时先抛异常,不产生可预测 key |
观察项(非阻断):AppJwtProperties 的校验规则没有专门的单元测试(设计 §六 曾要求)。本轮改用阶段 O 的真实 JAR 启动矩阵实证,覆盖更强;仍建议后续补一个配置校验单测。
3. Docker / MySQL / Redis / RabbitMQ
| 组件 | 版本 | 状态 |
|---|---|---|
| Docker Engine | 29.6.2(linux/amd64) | 可用 |
mydreamjob-mysql |
MySQL 8.4.11 | healthy(127.0.0.1:3307) |
mydreamjob-redis |
redis:7.4-alpine | healthy |
mydreamjob-rabbitmq |
rabbitmq:3.13-management-alpine | healthy |
验收数据库名:mydreamjob。未重建容器、未删除 volume、未打印 .env 或口令。
4–6. Flyway / 表结构 / 启动
Flyway:接受验收前最新版本为 V41;启动最新 JAR 后 V42 auth token sessions success=1,失败迁移 0。
auth_tokens 真实表结构(新增 7 列 + 3 索引,旧索引保留):
session_id VARCHAR(36) NULL token_jti VARCHAR(36) NULL
parent_token_id BIGINT NULL consumed_at / revoked_at DATETIME NULL
revocation_reason VARCHAR(40) version BIGINT NOT NULL DEFAULT 0
UNIQUE uk_auth_tokens_token_jti (token_jti)
KEY idx_auth_tokens_session_active (session_id, revoked, consumed_at, expired_at)
KEY idx_auth_tokens_user_session (user_id, session_id)
KEY idx_auth_tokens_user / idx_auth_tokens_hash (既有,保留)
无任何保存 Token 原文的列。
启动:Started MyDreamjobApplication(16.3 s),无 Flyway 错误、无 schema validation 错误;GET /api/v1/health → HTTP 200 / status=UP;运行时 OpenAPI 175/213。
7. 自动化实际测试数
| 命令 | Tests run | Failures | Errors | Skipped | BUILD |
|---|---|---|---|---|---|
| 原独立失败测试(2 类) | 3 | 0 | 0 | 0 | SUCCESS |
| S02 专项(13 类) | 84 | 0 | 0 | 0 | SUCCESS |
| 认证关联回归 | 208 | 0 | 0 | 0 | SUCCESS |
mvn test |
1758 | 0 | 0 | 0 | SUCCESS |
mvn clean verify |
1758 | 0 | 0 | 0 | SUCCESS(2:21) |
(候选基线称"扩展专项 87/87" = 专项 84 + 独立 3,与实测一致。)
8–9. Token claims 与类型矩阵(真实 HTTP)
claims:登录响应字段兼容、expiresIn=86400、三段 JWT、两 Token 原文不同、jti 不同、sid 相同、type 正确、iss/aud 正确、Access 有 role、Refresh 无 role、均不含个人信息、有效期 24h/7d、均含 iat —— 全部 PASS。
类型矩阵(每格记录 method / path / HTTP status / business code / requestId):
| Token | /auth/me |
/auth/refresh |
|---|---|---|
| Access | 200 | 401 |
| Refresh | 401 | 200(并轮换) |
| 无 type 旧 Token | 401 | 401 |
| Access 用 Refresh 密钥伪造 | 401 | 401 |
| Refresh 用 Access 密钥伪造 | 401 | 401 |
| 过期 Token | 401 | 401 |
| 缺 iat | 401 | 401 |
| 未来 iat | 401 | 401 |
| 非法 sid | 401 | 401 |
| 非法 sub | 401 | 401 |
错误响应均未泄漏 JWT 原文、Bearer、token_hash、sid、jti、手机号、密钥、异常类名或堆栈。
10–11. 正常轮换与串行重放
轮换:sid 不变、jti 变化、旧记录 consumed_at 非空、version 只 +1、新记录 parent_token_id 指向旧记录、同一时刻仅 1 个活动 Refresh、A2 可访问、R2 可再次刷新 —— 全部 PASS。
串行重放:R1 重放 → 401;当前 session 活动 Refresh 数 0;A1 与 A2 访问 /auth/me 均 401;R2 刷新 401;其他设备 session 200;写入一次重放安全审计。
12–13. 真实并发(10 轮)与胜出方 Access
每轮两个独立 HTTP 客户端 + threading.Barrier 同时发起,10/10 轮全部满足:
| 断言 | 实测 |
|---|---|
| 无 HTTP 500 | 10/10 |
| 最多一个刷新成功 | 10/10 |
| 失败方为 401 | 10/10 |
| 重放后活动 Refresh 严格为 0 | 10/10 |
| 原 Access 与胜出方新 Access 均失效 | 20 次断言全部满足 |
| 其他设备 session 始终 200 | 10/10 |
| 无锁异常 / 堆栈泄漏 | 10/10 |
日志无 refresh reuse revocation could not take locks |
✅ |
| 无同一 session 两个活动后继 | ✅ |
14–16. 多设备 / 竞争 / inactive 与注销
- logout:第一次成功,Access 与 Refresh 随即 401;重复 logout 按既有幂等语义;其他设备不受影响。
- 多设备隔离:两设备 sessionId 不同;A 登出后 A 失效、B 的 Access 与 Refresh 均保持有效。
- logout 与 refresh 并发:无 500;未补 logout 的情况下活动 Refresh 数为 0;竞争可能返回的新 Access 最终 401。
- inactive:登录不返回 Token、不新增
auth_tokens;置为 inactive 后旧 Access/Refresh 均 401。 - 注销完成(走正式懒转换完成路径):状态 COMPLETED、用户
active=false、全部会话撤销、两台设备 Access/Refresh 均 401、其他用户完全不受影响。
17. Clock 与真实数据库时间
| 断言 | 实测 |
|---|---|
created_at 与 Access iat 同一时刻 |
±1s 内一致 |
expired_at 与 Refresh exp 同一时刻 |
±1s 内一致 |
consumed_at 与刷新时刻 |
±1s 内一致 |
revoked_at 与登出时刻 |
±5s 内一致 |
AUTH 审计 created_at 与请求时刻 |
±120s 内一致 |
18–19. 明文扫描 / 审计与日志脱敏
token_hash为 64 位小写十六进制,不等于 Refresh 原文;库中搜索两类 Token 原文命中 0。- 应用日志与 stdout 扫描:Token / 验证码 / 密钥命中 0;扫描器经注入探针验证有效。
- 审计动作仅限 4 个白名单值;details 不含 Token、摘要、
Bearer、手机号、jti、Authorization、密钥;不含原始 sid(用不可逆sessionRef)。
20–22. 副作用 / RabbitMQ / OpenAPI
- 数据库副作用:登录+刷新+串行重放前后,
candidate_dimension_scores、messages、notifications、applications、talent_invitations内容摘要零变化(按 id 摘要比对,非仅 COUNT)。 - RabbitMQ:4 个项目队列全部存在,
ready=0、unacked=0;S02 运行期间无新增消息。 - OpenAPI:静态与运行时均 175 / 213;
/auth/refresh请求体仅refreshToken;/auth/logout无请求体;公共 schema 不含 tokenHash/sessionId/phone/secret 等内部字段。
23. PASS / FAIL / SKIP 清单
| 阶段 | PASS | FAIL | SKIP |
|---|---|---|---|
| A 代码审查(10 项) | 10 | 0 | 0 |
| B 自动化(5 组) | 5 | 0 | 0 |
| C–E 环境/迁移/启动 | 8 | 0 | 0 |
| F–M 黑盒(含 10 轮并发) | 117 | 0 | 0 |
| N–S 时间/脱敏/副作用/外围/OpenAPI | 30 | 0 | 0 |
| O 配置启动矩阵(9 配置) | 18 | 0 | 0 |
| 合计 | 165 | 0 | 0 |
24. 剩余风险
- 重放撤销重试可能重复写审计:
revokeReplayedSession在需要重试时会再写一对AUTH_REFRESH_REUSE_DETECTED/AUTH_SESSION_REVOKED。本轮所有场景(含 10 轮并发)重试次数为 0,未观测到重复;属潜在项,建议后续把审计移到循环外。 AppJwtProperties无专门单元测试(见第 2 节观察项)。- 旧
JWT_SECRET仍被talentdiscovery.CandidateKeyGenerator使用:本轮已确认空密钥时安全失败(抛异常、不产生可预测 key),但该配置项仍存在;建议后续为其单独引入专用密钥。 - 本轮未执行:真实短信供应商、Redis 会话缓存、设备指纹、Access 有效期缩短等 S02 明确非目标项。
- 验收数据保留在库(见第 26 节)。
25. 是否允许冻结 S02
允许。 24 项验收判据全部满足,无 FAIL、无核心项 SKIP,24 条"不得冻结"清单全部未触发。
26. 验收结束处理
- 本轮启动的 JAR(PID 21420,命令行含
s02-acc-main-app.log)已停止;8080 恢复空闲;未停止任何未知 Java 进程;未停止 Docker。 - 验收数据保留,未执行删除。理由:
audit_logs有 143 条AUTH_*行引用本轮验收用户且该表无外键,删除用户会产生孤儿审计行;而本任务边界禁止删除audit_logs。精确足迹如下(供后续人工处置):
| 范围 | 行数 |
|---|---|
users(本轮 15:25 之后创建,199 前缀) |
35 |
auth_tokens(这些用户) |
93 |
audit_logs AUTH_*(这些用户) |
143 |
account_deletions(这些用户) |
1 |
注:库中另有 3 个 199 前缀用户属于 S01 验收(2026-09-14 09:48 与 2026-08-13),及其 2 条投递记录,非本轮产生,未触碰。
- 最终 Git:暂存区空、
git diff --check无错误、V1–V41 未修改、V42 为唯一新增迁移、未删除文件、未git add -A/ commit / push、未进入 S03。