# S01 认证安全上线阻断整改 · 独立代码复验与真实 MySQL/Docker 黑盒验收报告 - **验收日期**:2026-09-11 - **验收性质**:独立验收(非功能开发);使用真实 MySQL 8、最新 JAR、真实 HTTP 请求 - **验收结论**:**FAIL**(已复现具体缺陷,S01 不可冻结) - **报告文件**:`docs/s01-independent-mysql-acceptance-20260911.md` - **验收产物目录**:`docs/acceptance/s01-20260911/`(脚本、请求体、日志、结果快照) > 本报告不包含数据库密码、.env 内容、JWT、验证码、完整手机号、明文测试口令与非法 principal 敏感标记原文。 --- ## 1. Git 基线 | 项 | 实测值 | |---|---| | 分支 | `fix/resume-upload-safety` | | HEAD | `cdafbf8ac93544a3ef89d0f3014f822a0d575f8f`(docs: update README for latest delivery baseline) | | 最近 5 次提交 | cdafbf8 / 277b3c0 / dbd27e6 / cf39737 / 498512d | | 暂存区 | **空**(未执行 `git add`) | | `git diff --check` | 无空白错误(仅 CRLF 提示) | | Flyway 迁移改动 | **无**(V1–V41 原样,共 41 个迁移文件,最大版本 41) | | 新增迁移 | **无** | | 工作区修改 | 20 个(S01 8 个 + 简历上传安全 12 个,均未提交) | | 未跟踪文件 | 基线 106 个,全部保留;本轮新增验收产物 | | AGENTS.md | **未发现**(仓库根、上级目录、`.claude/` 均无 AGENTS.md/CLAUDE.md/GEMINI.md/.cursorrules) | | `.env` | 保留(365B) | **与 S01 无关的未提交修改(简历上传安全)已完整保护**:`profile/ProfileController.java`、`profile/ProfileService.java`、`profile/storage/LocalResumeStorageService.java`、`resume/ResumeVersionController.java`、`resume/ResumeVersionService.java`、`docs/frontend-integration.md`、`docs/resume-api.md` 及 5 个测试文件——本轮**未读取改写、未覆盖**,时间戳仍为 2026-09-10。 --- ## 2. 独立代码审查结果(阶段 A) ### A1 验证码/手机号日志 —— **PASS** | 检查项 | 结果 | 证据 | |---|---|---| | INFO/WARN/ERROR 不输出验证码 | ✅ | `SmsVerificationService` 仅 2 处 `log.warn`,参数为 `maskPhone(phone)`;`MockSmsCodeSender` 仅 1 条无参固定文本 | | 不输出完整手机号 | ✅ | 实测 7 个验收手机号在全部日志中命中 **0** 次 | | Mock 实现仅固定安全文本 | ✅ | `log.info("SMS verification code generated (mock sender)")` | | Service 不把验证码传入日志参数 | ✅ | 全量检索无 `log.*code` 参数传递 | | AuthService 不输出完整手机号 | ✅ | 仅 `log.info("Auto-created user: id={}")` | | JWT 过滤器不记录 Token/subject/异常原文 | ✅ | 3 条固定文本,无 `e.getMessage()`、无 subject | | 审计 details 不含完整手机号/验证码 | ✅ | 实测唯一 SMS_SEND 明细为 `phone=199****9999`(脱敏) | | 异常消息不含敏感值 | ✅ | `SERVICE_UNAVAILABLE` 固定文本 `sms sender unavailable` | | 不通过 toString/getMessage 间接泄露 | ✅ | 捕获的异常与堆栈均不含非法 principal 标记 | **唯一敏感标记复验**(真实 Logback 捕获 + 真实运行日志双通道):标记手机号、真实验证码(3 个 DB 实际值)、JWT 前缀 `eyJ`、`Bearer ey`、mock 正文、非法 principal 标记、`NumberFormatException` —— 命中数**全部为 0**。 ### A2 短信发送器装配 —— **PASS(3 种配置)+ 发现 1 处边界缺陷(见 F3)** | 检查项 | 结果 | |---|---| | Service 只依赖 `SmsCodeSender` 接口 | ✅ 单构造参数注入,无 if 分支复制双流程 | | `true` → 仅 `MockSmsCodeSender` | ✅ 实测(`SmsSenderConfigurationTest` 4/4 + 运行期行为) | | `false` → 仅 `UnavailableSmsCodeSender` | ✅ 实测 | | 缺失 → 默认 `UnavailableSmsCodeSender` | ✅ 实测(D3a) | | 单一 Bean / 无装配歧义 | ✅ 三次启动均成功(缺/多 Bean 会直接启动失败) | | mock=false 不回退 Mock | ✅ 实测无 mock 事件 | | 生产不意外启用 Mock | ✅ `application.yml` 默认 `SMS_MOCK_ENABLED:false` | | `@ConditionalOnProperty` 精确性 | ✅ Mock=`havingValue="true"`(**无** matchIfMissing);Unavailable=`havingValue="false", matchIfMissing=true`;name 与配置项 `app.sms.mock.enabled` 完全一致 | ### A3 mock=false 失败事务 —— **PASS(MySQL 回滚)+ 1 项非阻断观察(F4)** - `@Transactional` 位于 `sendCode`;顺序:校验 → 锁定/限流 → 生成 → **`codeRepository.save`** → Redis 限流 → **`smsCodeSender.sendVerificationCode`** → 审计 - `BusinessException extends RuntimeException` → 默认回滚;发送异常**未被捕获**,不转换为空成功 - 真实 MySQL 实测:失败后 `sms_verification_codes` 完整字段前后**完全一致**(见第 6 节) - 观察项:限流键在发送**之前**写入,失败后残留 60s(既有排序,非 S01 引入)→ F4 ### A4 `CurrentUserAccessor` —— **PASS** 拒绝:null / `!isAuthenticated` / `AnonymousAuthenticationToken` / 空或空白 name / `anonymousUser` / 非 ASCII 纯数字(含 `+42`、`" 42"`、`"42 "`、Unicode 数字)/ Long 溢出 / `userId<=0`。 固定抛 `InsufficientAuthenticationException`(既有 `@ExceptionHandler(AuthenticationException)` → **HTTP 401**),**不回显、不记录**非法 principal,不新增 ErrorCode,不承担角色/归属判断,身份仅来自 SecurityContext。 ### A5 `JwtAuthenticationFilter` —— **PASS** 合法数字 subject 正常认证;缺失 Authorization → 匿名;无效签名/过期 → 既有 401;非数字 subject → **401 而非 500**;日志无 Token/subject/异常原文;`JwtTokenProvider` 未改动(Token 兼容语义不变);**未声称修复 Refresh Token**。 ### A6 `/jobs` 安全规则 —— **PASS** `SecurityConfig:119` 由 `.requestMatchers("/jobs").permitAll()` 改为 `.requestMatchers(HttpMethod.GET, "/jobs").permitAll()`,且该规则**先于** `.requestMatchers("/jobs/**").authenticated()` 求值,写请求确实命中 authenticated。 **全部 /jobs 映射与最终权限(21 个 operation,真实 HTTP 实测)** | 方法 | 路径 | 匿名 | STUDENT | COMPANY | ADMIN | 归属要求 | |---|---|---|---|---|---|---| | GET | `/jobs` | **200** | 200 | 200 | 200 | 无 | | GET | `/jobs/search`、`/jobs/filters` | **200** | 200 | 200 | 200 | 无 | | GET | `/jobs/public/{id}` | **200** | 200 | 200 | 200 | 无 | | GET | `/jobs/{id}/reviews`、`/summary`、`/{reviewId}`、`/jobs/compare` | **200** | 200 | 200 | 200 | 无 | | POST | `/jobs` | **401** | **403/2002** | **200** | **403/2002** | 需绑企业 | | PUT | `/jobs/{id}` | **401** | 403/2002 | 200 | 403/2002 | **本企业**(他企业 400/7002) | | PATCH | `/jobs/{id}/status` | **401** | 403/2002 | 200 | 403/2002 | **本企业**(他企业 400/7002) | | DELETE | `/jobs/{id}` | **401** | — | — | — | 无该端点 | | GET | `/jobs/{id}` | **401** | 403/2002 | 200 | 403/2002 | 本企业 | | GET | `/jobs/{id}/match` | **401** | — | — | — | 登录 | | GET | `/jobs/{id}/competition-ranking` | **401** | — | — | — | 登录 | | POST/DELETE | `/jobs/{id}/favorite`、GET `/jobs/favorites` | **401** | — | — | — | 登录 | | GET | `/jobs/{id}/personalized-review-score` | **401** | — | — | — | 登录 | **不依赖 Controller 解析 anonymousUser 失败来保护写接口**:匿名请求在安全层即被拒(响应无 `requestId` 字段,符合既有 `AuthenticationEntryPoint` 固定载荷)。 ### A7 剩余直接身份解析 —— **PASS(无 permitAll 可达)= 但存量 43 处** - 全量检索(任意变量名):`Long.(valueOf|parseLong)(*.getName())` 共 **43 处**,分布于 43 个文件 - `.isAuthenticated()` 共 44 处;`anonymousUser` 字面量 5 处(`PersonalDataExportController`、`DeletionGuardInterceptor`×2、`AdminReviewReplyController`、`CompanyReviewManagementController`) - **逐端点核验 permitAll 可达性**(按 SecurityConfig 匹配顺序,而非"路径要求认证"默认安全): - permitAll 路径共 19 条规则,涉及的控制器为 `AuthController`、`CompanyController`、`JobReviewController`、`JobController`、`MatchController`、`JobRecommendationController` - **匿名可达的 8 个 GET 方法**(`listJobs`、`searchJobs`、`getFilters`、`getPublicJob`、`list`、`get`、`summary`、`compare`)**均不调用身份解析** - `CompanyController` 的 `resolveUserId` 仅被 `/companies/me`(POST/GET/PUT)使用 → authenticated;`JobReviewController` 的 `resolveUserId` 仅被 create/update/delete 使用 → authenticated - **结论:43 处中 0 处处于 permitAll 可达路径**,不构成 S01 发布阻断。`JobController` 已改用 `CurrentUserAccessor`(裸解析 0 处) - 存量风险提示:`DeletionGuardInterceptor:51` 使用 `chars().allMatch(isDigit)` 预检,可挡 `anonymousUser`,但对 **20 位纯数字溢出**仍会抛 `NumberFormatException`(需合法签名 Token 才能到达)→ 列为后续轮次加固项 ### A8 OpenAPI 与响应兼容 —— **PASS** - 路径/operation 数量:静态 173/211;**运行时 173/211**,一致 - 唯一变更:`docs/openapi-rc.json` 2 行描述文本(移除"验证码会打印到日志"表述),无结构变更 - 登录、发码、岗位请求/响应 Schema 未变(S01 未改任何 DTO) - 无新增短信调试接口、无新增获取验证码接口 --- ## 3. 自动化独立复验(阶段 B,实测重新执行) | 命令 | Tests run | Failures | Errors | Skipped | 结果 | |---|---|---|---|---|---| | `-Dtest="SmsVerificationSecurityTest,SmsSenderConfigurationTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest"` | **40** | 0 | 0 | 0 | BUILD SUCCESS | | `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **214** | 0 | 0 | 0 | BUILD SUCCESS | | `mvn test` | **1658** | 0 | 0 | 0 | BUILD SUCCESS(3:45) | | `mvn clean verify` | **1658** | 0 | 0 | 0 | **BUILD SUCCESS**(4:31,产出 JAR 79,965,063B) | | `python ../docs/verify_openapi.py` | — | — | — | — | paths 173 / operations 211 / **All checks passed!** | --- ## 4. Docker / MySQL 环境(阶段 C) **启动前发现**:`docker ps` 为空,三个业务容器处于 **Exited**(约 2 小时前,139/0/137 退出码),**volume 数据完好**。容器标签显示由 `Desktop\qinghe\backend-infra\compose.yaml` 创建(项目名 `backend-infra`)。 **本轮处置(非破坏性,已记录)**:仅执行 `docker start mydreamjob-mysql mydreamjob-redis mydreamjob-rabbitmq`——**未重建容器、未删除 volume、未清空数据库、未触碰其余 3 个无关孤立容器、未停止 Docker**。 | 项 | 实测值 | |---|---| | Docker Server | 29.6.2 | | MySQL | **8.4.11**(`127.0.0.1:3307`,healthy) | | Redis | 7.4-alpine(`6379`,healthy) | | RabbitMQ | 3.13-management-alpine(`5672/15672`,healthy) | | 验收数据库 | `mydreamjob` | | Flyway | 41 条,**success=1 全部成功**,0 失败,最大版本 **41**(`41 message recall`) | | S01 新增迁移 | **无** | **已修复的验收环境问题(脚本层,非项目缺陷)**:Git Bash(MSYS) 会把裸 `/` 的 `RABBITMQ_VHOST` 自动改写为 Git 安装根路径,导致 `530 NOT_ALLOWED vhost not found`。经在验收脚本中清除该变量(交由 `application.yml` 默认值 `/` 承接)后,RabbitMQ 错误数 **0**。 --- ## 5. 三种配置启动验证(阶段 D,均为最新 `mvn clean verify` JAR) | 场景 | 启动 | SmsCodeSender 实际实现 | 发码行为 | 日志敏感值 | 判定 | |---|---|---|---|---|---| | **D1** `mock=true` | ✅ Started 26.5s,health 200,8080 监听 | **MockSmsCodeSender**(固定事件文本命中 1 次) | HTTP **200**(非 500) | 0 | **PASS** | | **D2** `mock=false` | ✅ Started 26.4s | **UnavailableSmsCodeSender**(无 mock 事件) | HTTP **400** `{"code":400,"message":"sms sender unavailable"}` | 0 | **PASS** | | **D3a** 属性完全缺失 | ✅ Started 27.5s | **UnavailableSmsCodeSender**(未回退 Mock) | HTTP **400** 同上 | 0 | **PASS** | | **D3b** 属性存在但为空值 | ❌ **APPLICATION FAILED TO START** | **无 Bean** | — | — | **FAIL(F3)** | 每次启动均记录 profile(无 profile,基础 `application.yml`)、PID、端口、实现、Started 日志、health 状态;**仅停止本轮启动并记录 PID 的 JAR**(每次均以 `Get-CimInstance Win32_Process` 校验命令行确为 `zhilai/mydreamjob-server/target/*.jar`)。 --- ## 6. 真实 MySQL 验证码事务回滚(阶段 E) **mock=false(发送失败回滚)**——完整字段快照对比,非 COUNT(*): | 指标 | BEFORE | AFTER | 结论 | |---|---|---|---| | 该手机号记录数 | 0 | 0 | ✅ | | 全局 MAX(id) | 990 | 990 | ✅ 无新增 | | 全局总行数 | 917 | 917 | ✅ | | 未使用(used=0)记录数 | 0 | 0 | ✅ 无可用残留 | | SMS_SEND 审计总数 | 819 | 819 | ✅ 无虚假成功审计 | | 完整字段差异 | — | — | ✅ **前后完全一致** | 无半提交、无 attempts 异常、无错误锁定状态更新。HTTP **400**(非虚假成功)。 **mock=true(完整链路)**: 1. 发送 → HTTP 200,DB 生成 1 条记录(验证码值仅存于库内,报告不复制) 2. 从 DB 取码 → `POST /auth/login` → HTTP **200**,响应含 `accessToken/refreshToken/expiresIn/nickname/phone/role/userId`,响应内**不含验证码明文** 3. 登录后该记录 `used=1`、`attempts=0`(一次性消费语义不变) 4. **重放同一验证码 → HTTP 400**(拒绝) 5. 日志与审计仍不含验证码/完整手机号 6. 长度/有效期/最大尝试次数/锁定时间语义未变(`SmsVerificationSecurityTest` 10/10 覆盖) --- ## 7. 真实 HTTP 权限矩阵(阶段 F) 见第 2 节 A6 表格。关键断言全部成立: - **匿名 POST /jobs = HTTP 401(`code=401`),不是 500** ✅ - STUDENT / ADMIN POST /jobs = **403 / 2002** ✅ - COMPANY POST /jobs = **200** ✅(生成 id=43) - 他企业 PUT/PATCH = **400 / 7002**(既有语义,未擅改)✅ - 公开 GET(`/jobs`、`/search`、`/filters`、`/public/{id}`、`/{id}/reviews`)语义不变 = 200 ✅ - 每条请求均记录 method / path / HTTP / business code / requestId **jobs 表副作用(完整字段对比,非 COUNT)**: ``` 差异仅:新增 JOB:43|company_id=22|S01_ACC_*_job|PUBLISHED|...|requirements=S01_ACC_ok TOTAL_JOBS 27 → 28 TAMPERED_TITLE=0 ← 他企业篡改尝试零落库 其余全部 JOB 行逐字段未变 ``` --- ## 8. 非法 Token / principal 黑盒(阶段 G) 对受保护端点 `POST /jobs` 的真实 HTTP 请求(含合法请求体): | 用例 | HTTP | business code | 响应回显敏感标记 | 判定 | |---|---|---|---|---| | 无 Authorization | 401 | 401 | no | ✅ | | 合法数字 subject(学生 7936) | **403** | **2002** | no | ✅ 正常进入既有权限判断 | | `subject=anonymousUser` | **401** | 401 | no | ✅ | | 受控非法 principal 标记 | **401** | 401 | no | ✅ 非 500 | | subject 缺失(空) | 401 | 401 | no | ✅ | | `subject=0` | 401 | 401 | no | ✅ | | `subject=-1` | 401 | 401 | no | ✅ | | subject 超出 Long 范围 | 401 | 401 | no | ✅ | | 过期 Token | 401 | 401 | no | ✅ | | 签名被篡改 | 401 | 401 | no | ✅ | **无任何用例返回 500;响应与日志均未回显 subject 或 Token;jobs 表完整内容零变化(无业务写入)** ✅ Token 生成方式:验收专用 HS256 离线生成(复用项目 `JWT_SECRET` 环境变量,**未落盘、未输出**);外部 HTTP 无需伪造即可安全覆盖全部用例,故未降级为纯集成测试。 --- ## 9. 日志与审计全量脱敏(阶段 H) 仅扫描**本轮**日志(D1/D1b/D2/D3a/D3b 的 app + stdout): | 禁止项 | 命中 | 说明 | |---|---|---| | 验证码明文(3 个 DB 实际值) | **0** | | | 完整手机号(7 个验收号) | **0** | | | JWT(`eyJhbGci`、`Bearer ey`) | **0** | | | Authorization 头值 | **0** | 16 处 `authorization` 均来自 D3b 启动失败堆栈的 `AuthorizationFilter.doFilter` | | secret | **0** | | | 非法 principal 标记 | **0** | | | `NumberFormatException` 堆栈 | **0** | | | Mock 短信正文 | **0** | `MOCK SMS CODE` 命中 0 | | `password` | ⚠️ 16 | 全部为 Spring Boot 默认 `Using generated security password: ` → **F5(既有行为,见第 12 节)** | **审计表(audit_logs)**:完整手机号命中 **0**、非法 principal 命中 **0**、Token 命中 **0**、脱敏手机号命中 **1**(唯一 SMS_SEND 明细 `phone=199****9999`)✅ **允许项**:脱敏手机号、固定事件名、requestId、固定 purpose 均正常。 --- ## 10. 运行时 OpenAPI(阶段 I) | 项 | 值 | |---|---| | 访问地址 | `GET /api/v1/v3/api-docs`(应用 `context-path=/api/v1`;裸 `/v3/api-docs` 返回 404 属预期) | | HTTP | 200 | | 运行时 paths / operations | **173 / 211** | | 静态 paths / operations | 173 / 211 | | 一致性 | **PASS** | | 短信相关路径 | 仅 `/auth/sms/send`(无新增调试/取码接口) | | `/jobs` 路径数 | 15 | | `/auth/sms/send` 描述 | 已不再声称验证码输出到日志/控制台 ✅ | --- ## 11. 文档一致性核查(阶段 13)—— **FAIL** **仍指导从日志读取验证码的文档(精确文件:行号)**: | 文件 | 行号 | 内容(已脱敏) | 性质 | |---|---|---|---| | `docs/acceptance-week1.md` | 94 | `# Mock 登录(从日志获取验证码)` | 历史验收记录 | | `docs/acceptance-week1.md` | 148 | 注: 验证码从应用日志中获取 | 历史验收记录 | | `docs/acceptance-week1.md` | 152 | 验证码打印到日志,不发送真实短信 | 历史验收记录 | | `docs/api-v0.1.md` | 27 | Mock 环境验证码打印到日志 | 历史 API 文档 | | `docs/api-v0.1.md` | 174 | 短信验证码为 Mock 实现,验证码打印到日志 | 历史 API 文档 | | `docs/frontend-integration.md` | 37,39,42,144 | "Mock 验证码日志位置"、"打印在服务控制台/stdout 日志中"、旧示例行 `MOCK SMS CODE: phone=138****0002, … code=******` | **前端接入文档(在途修改文件,本轮不可改)** | | `docs/offer-api.md` | 262 | 测试账号(Mock 验证码,见日志) | 活文档 | | `docs/resume-api.md` | 189 | 登录学生账号(Mock 验证码,见日志) | **在途修改文件** | | **`docs/smoke-test.sh`** | **64-71** | **`sms_code()`:`grep -a "MOCK SMS CODE.*phone=$PHONE" $log \| … \| grep -oE 'code=[0-9]{6}'`** | **git 跟踪的既有可执行脚本(见 F1)** | `README.md:167` 表述正确(禁止写入敏感信息),**不计入违规**;`docs/openapi-rc.json:1449` 已修正 ✅。 --- ## 12. RabbitMQ 与外围终态(阶段 14) | 项 | 实测值 | |---|---| | 队列 | `queue.sms.verification`、`queue.notification`、`queue.audit`、`queue.notification.dlq` | | ready / unacked | **全部 0 / 0** | | mock=false 失败后 notifications 增量 | **0** | | mock=false 失败后 SMS_SEND 审计增量 | **0**(无虚假发送成功审计) | | 匿名岗位写请求副作用 | jobs/notifications/audit 均无新增 | | 无与 S01 无关的表变化 | ✅(仅本轮验收夹具,已精确清理) | | 容器终态 | 3 容器仍 Up(healthy);**未停止 Docker** | --- ## 13. PASS / FAIL / SKIP 清单 | # | 验收项 | 结果 | |---|---|---| | A1 | 验证码/手机号不进入日志与审计 | **PASS** | | A2 | SMS 发送器装配(true/false/缺失) | **PASS** | | A2′ | 空值边界下的 Bean 唯一性 | **FAIL**(F3) | | A3 | mock=false 失败事务回滚(真实 MySQL) | **PASS**(F4 为非阻断观察) | | A4 | CurrentUserAccessor 边界 | **PASS** | | A5 | JwtAuthenticationFilter 边界 | **PASS** | | A6 | /jobs 匹配顺序与权限矩阵 | **PASS** | | A7 | 无 permitAll 可达的直接身份解析 | **PASS**(存量 43 处均不可匿名到达) | | A8 | OpenAPI 与响应兼容 | **PASS** | | B | 自动化复验(40/214/1658/clean verify) | **PASS** | | C | Docker/MySQL/Flyway | **PASS** | | D | 三种配置启动 | **PASS**(D1/D2/D3a)| | E | 真实 MySQL 回滚 + mock=true 链路 | **PASS** | | F | 真实 HTTP 权限矩阵 | **PASS** | | G | 非法 Token/principal 黑盒 | **PASS** | | H | 日志与审计脱敏(`password` 项除外) | **PASS** | | I | 运行时 OpenAPI | **PASS** | | 13 | 文档不再指导从日志读取验证码 | **FAIL**(F1、F2) | | 14 | RabbitMQ 与外围绕 | **PASS** | | — | SKIP | **0**(核心验收项无 SKIP) | **FAIL 计数:3**(F1、F2、F3) --- ## 14. 缺陷详情(可复现) ### F1(阻断)项目自带冒烟脚本因 S01 改动而失效 - **文件/方法**:`docs/smoke-test.sh` → `sms_code()`(第 64-71 行,git 跟踪,提交 `b01616d`) - **缺陷**:该函数依赖被 S01 删除的日志行 `MOCK SMS CODE: phone=…, code=…` 提取验证码 - **复现**: ```bash LOG=<本轮 mock=true 应用日志> grep -a "MOCK SMS CODE.*phone=<验收手机号>" "$LOG" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2 ``` - **实际结果**:输出为空 → `SMS=""` → 后续登录步骤拿不到验证码,冒烟流程中断 - **期望结果**:脚本应从数据库/受控夹具取得验证码(与 S01 新语义一致) - **影响**:既有可执行验收工具**功能性回归**;S01 整改报告将其列为"文档遗留"但未识别此处为**可执行依赖** ### F2(阻断,对应验收标准第 17 项)文档仍指导从日志读取验证码 - **文件/行号**:见第 11 节表格(`acceptance-week1.md:94,148,152`、`api-v0.1.md:27,174`、`frontend-integration.md:37,39,42,144`、`offer-api.md:262`、`resume-api.md:189`) - **实际结果**:文档仍声称"验证码打印到日志/控制台"并指导据此取码 - **期望结果**:明确"日志不再输出验证码";dev/test 通过**受控测试夹具或数据库内部验收方式**取码;禁止生产查询验证码表完成登录;说明 `mock=false` 且无真实供应商时返回服务不可用;真实短信供应商待单独接入 - **约束**:`docs/frontend-integration.md`、`docs/resume-api.md` 属**在途修改文件**,本轮不得改动 ### F3(非阻断但违反 A2 明确要求)空值配置导致启动失败 - **文件**:`auth/sms/MockSmsCodeSender.java`(`@ConditionalOnProperty(name="app.sms.mock.enabled", havingValue="true")`)与 `auth/sms/UnavailableSmsCodeSender.java`(`havingValue="false", matchIfMissing=true`) - **缺陷**:属性**存在但为空值**(如 env 中 `SMS_MOCK_ENABLED=`)时,两个条件都不匹配 → **0 个 `SmsCodeSender` Bean** - **复现**:`java -jar target/mydreamjob-server-*.jar --app.sms.mock.enabled=` (其余同 D2) - **实际结果**: ``` APPLICATION FAILED TO START Parameter 3 of constructor in com.mydreamjob.auth.SmsVerificationService required a bean of type 'com.mydreamjob.auth.sms.SmsCodeSender' that could not be found. ``` - **期望结果**:任何取值(含空值/空白)都应至少装配一个实现,绝不因缺少 Bean 以模糊 Spring Bean 错误启动失败(原工作令第 11 条) - **影响**:部署可用性风险(空 env 变量在生产会致全站不可启动);未造成数据泄露 ### F4(观察,非阻断)失败后残留限流键 - 现象:mock=false 发送失败后 `sms:rate:` 残留(TTL 60s),60s 内重试返回 `code=400 sms rate limited` 而非 `sms sender unavailable` - 定性:**既有排序**(限流键在发送前写入),S01 未改变该顺序;MySQL 无残留有效验证码;不属冻结阻断项 - 建议:后续轮次评估失败时释放限流键或调整写入顺序 ### F5(观察,非阻断,既有行为)Spring Boot 默认安全口令入日志 - 现象:日志出现 `Using generated security password: ` - 定性:Spring Boot `UserDetailsServiceAutoConfiguration` 默认行为;**经历史日志比对确认 S01 之前即存在**(`mydreamjob-server/logs/backend-startup.log` 等) - 可利用性:应用未启用 httpBasic/formLogin,该口令无 HTTP 认证入口消费 → 不可用于登录 - 建议:后续轮次排除 `UserDetailsServiceAutoConfiguration` 或显式声明 `AuthenticationManager` --- ## 15. 剩余风险 1. **F1/F2 未修复前,开发/测试人员仍会按文档从日志找验证码 → 必然失败并误判"系统故障"** 2. 存量 43 处直接身份解析(均不可匿名到达,但缺乏统一 `CurrentUserAccessor` 收口);`DeletionGuardInterceptor` 对 20 位数字溢出仍可能 500 3. 验证码仍以明文存储于 `sms_verification_codes.code`(本轮未改表结构,属已知遗留) 4. 真实短信供应商未接入;生产 `mock=false` 下所有发码请求均安全失败(设计如此,但需产品确认上线策略) 5. 安全层 401 响应不含 `requestId`(既有 `AuthenticationEntryPoint` 固定载荷),与业务层错误响应格式不一致,不利于链路追踪 6. 本轮验收脚本依赖 Git Bash 环境变量语义(vhost 转换问题已规避并记录) --- ## 16. 是否允许冻结 S01 **不允许冻结。** 自动化指标(1658/1658、clean verify、静态与运行时 OpenAPI 173/211)全部达成,核心安全目标(日志脱敏、`SMS_MOCK_ENABLED` 生效、匿名 POST /jobs 401、非法 subject 401 不回显、真实 MySQL 回滚)**均已独立复验通过**;但存在 3 项 FAIL(F1 可执行脚本回归、F2 文档违规指导、F3 空值启动失败),其中 F2 直接命中验收标准第 17 项,F1 为可执行功能回归。 --- ## 17. S01 下一轮定向整改指令 ### R1 —— 修复冒烟脚本取码方式(对应 F1) - **文件**:`docs/smoke-test.sh` - **方法**:`sms_code()` - **错误数据/逻辑**:`grep -a "MOCK SMS CODE.*phone=$PHONE" "$log_path" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2` - **复现请求**:`bash docs/smoke-test.sh`(mock=true 环境,日志中已无该行) - **实际结果**:返回空字符串,登录步骤失败 - **期望结果**:改为受控来源取码,二选一—— 1. 数据库内部查询(推荐,仅限本地/测试): `SELECT code FROM sms_verification_codes WHERE phone=? AND purpose='login' AND used=0 ORDER BY id DESC LIMIT 1;` 2. 或由测试夹具直接注入固定验证码记录后读取 - **必须新增的失败测试**:新增独立脚本断言 `sms_code()` 在"日志不含 `MOCK SMS CODE`"时仍能返回 6 位码(当前实现必然失败) - **禁止的绕过**:不得恢复 `MOCK SMS CODE` 日志行;不得让脚本读取生产日志取码 ### R2 —— 修正文档中的取码指引(对应 F2,命中验收标准第 17 项) - **文件与行号**:`docs/acceptance-week1.md:94,148,152`;`docs/api-v0.1.md:27,174`;`docs/offer-api.md:262`;`docs/frontend-integration.md:37,39,42,144`;`docs/resume-api.md:189` - **错误内容**:声称"验证码打印到日志/控制台"并指导据此取码(含旧示例行) - **复现**:`rg -n "控制台|日志.*验证码|MOCK SMS|验证码.*日志|查看验证码" docs README.md` - **实际结果**:上述 10 处仍存在 - **期望结果**:统一替换为—— - 日志不再输出验证码; - dev/test 环境通过**受控测试夹具或数据库内部验收方式**取得验证码; - **禁止生产环境查询验证码表完成登录**; - `mock=false` 且未接入真实供应商时返回"服务不可用",绝不返回虚假成功; - 真实短信供应商仍待单独接入。 - **协调要求**:`docs/frontend-integration.md`、`docs/resume-api.md` 属**简历上传安全**在途修改文件,需与该项变更方**合并处理**,避免互相覆盖 - **必须新增的失败测试**:新增文档门禁测试(或 CI 检查)断言 `docs/**` 与 `README.md` 不再匹配 `MOCK SMS CODE|验证码.*打印.*日志|控制台.*验证码|见日志.*验证码` - **禁止的绕过**:不得删除或改写为同义表述;不得仅在 README 声明而保留违规正文 ### R3 —— 空值配置不再导致启动失败(对应 F3) - **文件**:`auth/sms/MockSmsCodeSender.java`、`auth/sms/UnavailableSmsCodeSender.java`(必要时新增无条件兜底实现) - **错误配置**:`app.sms.mock.enabled=`(存在但空) - **复现请求**:`java -jar target/mydreamjob-server-0.0.1-SNAPSHOT.jar --app.sms.mock.enabled=`(DB/Redis/RabbitMQ 同 D2) - **实际结果**:`APPLICATION FAILED TO START … required a bean of type 'SmsCodeSender' that could not be found` - **期望结果**:启动成功并装配 `UnavailableSmsCodeSender`(空值、空白值、缺失、`false` 四者行为一致;仅显式 `true` 才装配 Mock) - **必须新增的失败测试**(`SmsSenderConfigurationTest` 内新增,不改动既有断言): - `emptyPropertyStillProvidesUnavailableSender` - `blankPropertyStillProvidesUnavailableSender` - `exactlyOneSenderBeanForEveryValueIncludingEmpty` - **禁止的绕过**:不得让 Mock 成为 `matchIfMissing` 默认;不得用 `@Primary` 掩盖歧义;不得让应用以空 Bean 启动(必须真实拒绝发码,不得伪造成功) ### R4 —— 非阻断建议(可与上轮合并,不单独阻断冻结) 1. `DeletionGuardInterceptor:60` 增加溢出安全解析(复用 `CurrentUserAccessor` 语义) 2. 评估失败时释放 `sms:rate:` 限流键(F4) 3. 排除 `UserDetailsServiceAutoConfiguration` 或显式声明 `AuthenticationManager`(F5) 4. 统一为安全层 401 响应补充 `requestId`(便于链路追踪) --- ## 18. 验收产物清单(`docs/acceptance/s01-20260911/`) | 文件 | 用途 | |---|---| | `launch.sh` | 三种配置启动器(true/false/MISSING/EMPTY) | | `gen-token.js` | 验收专用 HS256 Token 离线生成(密钥仅取自环境变量,不落盘) | | `matrix.sh` / `matrix-results.txt` | 阶段 F 权限矩阵脚本与结果 | | `phase-g.sh` / `phase-g-results.txt` | 阶段 G 非法 Token 边界脚本与结果 | | `snapshot-jobs.sql` / `jobs-before.txt` / `jobs-after.txt` / `jobs-before-g.txt` / `jobs-after-g.txt` | jobs 表完整字段快照与对比 | | `snapshot-sms.sql` / `before-mockfalse.txt` / `after-mockfalse.txt` | 阶段 E 验证码表回滚对比 | | `job-body.json` / `tamper-body.json` / `status-body.json` / `ok-body.json` | 请求体夹具(ASCII,避免 shell 编码问题) | | `d1*/d2*/d3*` 日志 | 三次配置启动的应用日志与 stdout | | `runtime-openapi.json` | 运行时 OpenAPI 快照 | | `acc-fixture.txt` / `acc-prefix.txt` | 验收夹具 ID 记录(已清理) | | `sensitive-markers.txt` | 敏感标记清单(内容未写入报告) | **验收数据清理**:按精确 ID 与前缀删除 users(7933-7937)/companies(22,23)/jobs(42,43)/sms_verification_codes(1),Redis 验收键已清理;**保留**本轮窗口内 2 条 audit_logs(append-only 合规记录,不做删除);`jobs` 总数回到基线 26。清理前后均确认目标归属,无模糊 DELETE。 --- **已复现具体缺陷,S01不可冻结;验收报告已包含下一轮定向整改指令。**