# 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. 剩余风险 1. **重放撤销重试可能重复写审计**:`revokeReplayedSession` 在需要重试时会再写一对 `AUTH_REFRESH_REUSE_DETECTED` / `AUTH_SESSION_REVOKED`。本轮所有场景(含 10 轮并发)**重试次数为 0**,未观测到重复;属潜在项,建议后续把审计移到循环外。 2. **`AppJwtProperties` 无专门单元测试**(见第 2 节观察项)。 3. **旧 `JWT_SECRET` 仍被 `talentdiscovery.CandidateKeyGenerator` 使用**:本轮已确认空密钥时**安全失败**(抛异常、不产生可预测 key),但该配置项仍存在;建议后续为其单独引入专用密钥。 4. 本轮**未执行**:真实短信供应商、Redis 会话缓存、设备指纹、Access 有效期缩短等 S02 明确非目标项。 5. 验收数据保留在库(见第 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。 --- # S02 独立代码复验和真实MySQL/Docker黑盒全部通过,可以进入精确暂存检查点。