30 changed files with 4054 additions and 83 deletions
@ -0,0 +1,483 @@ |
|||
# 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: <uuid>` → **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:<phone>` 残留(TTL 60s),60s 内重试返回 `code=400 sms rate limited` 而非 `sms sender unavailable` |
|||
- 定性:**既有排序**(限流键在发送前写入),S01 未改变该顺序;MySQL 无残留有效验证码;不属冻结阻断项 |
|||
- 建议:后续轮次评估失败时释放限流键或调整写入顺序 |
|||
|
|||
### F5(观察,非阻断,既有行为)Spring Boot 默认安全口令入日志 |
|||
|
|||
- 现象:日志出现 `Using generated security password: <uuid>` |
|||
- 定性: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:<phone>` 限流键(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不可冻结;验收报告已包含下一轮定向整改指令。** |
|||
@ -0,0 +1,288 @@ |
|||
# S01 最终真实 MySQL / Docker 增量复验报告(脱敏版) |
|||
|
|||
- **验收日期**:2026-09-14 |
|||
- **分支**:`fix/resume-upload-safety` |
|||
- **范围**:只验证 S01 第四轮整改(F6:`expires_at` → `expired_at`)后的**真实运行路径**;本轮**未修改任何代码** |
|||
- **结论**:**PASS** |
|||
- **本报告不含**:验证码值、完整手机号、JWT、`Authorization` 头、数据库口令、`.env` 内容 |
|||
|
|||
> 本轮为此前因 Docker Linux Engine 未运行而 BLOCKED 的复验。本次 Docker Engine 可用,全部真实黑盒已执行完成。 |
|||
|
|||
--- |
|||
|
|||
## 1. 基线与边界 |
|||
|
|||
| 项 | 实测 | |
|||
|---|---| |
|||
| 分支 | `fix/resume-upload-safety` | |
|||
| 暂存区 | **空**(`git diff --cached --name-only` 无输出) | |
|||
| `git diff --check` | 无空白错误(仅 CRLF 提示) | |
|||
| 迁移改动 | **无**(`git diff --name-only -- db/migration` 无输出;41 个文件,最大 `V41__message_recall.sql`) | |
|||
| 已跟踪改动 | 24 个(**全部来自此前几轮**:7 个 docs + 11 个 java main + 6 个 java test),本轮 **0 新增** | |
|||
| 删除文件 | **0** | |
|||
| 历史未跟踪文件 | 未删除、未移动、未覆盖 | |
|||
|
|||
本轮**未修改**:生产代码、现有测试、`docs/smoke-test.sh`、`V1–V41`;**未新增** Flyway/表/索引/`ErrorCode`/接口;**未恢复**验证码日志。 |
|||
|
|||
## 2. Docker 环境 |
|||
|
|||
| 项 | 值 | |
|||
|---|---| |
|||
| Docker Engine | 29.6.2(Docker Desktop 4.84.0,linux/amd64) | |
|||
| `mydreamjob-mysql` | **healthy**(mysql:8.4,127.0.0.1:3307→3306) | |
|||
| `mydreamjob-redis` | **healthy**(redis:7.4-alpine) | |
|||
| `mydreamjob-rabbitmq` | **healthy**(rabbitmq:3.13-management-alpine) | |
|||
|
|||
进入本轮时三个容器均为 `Exited`(2 天前停止)。经负责人明确授权后,对**已存在的具名容器**执行 `docker start`:**未重建容器、未改动配置、未触碰 volume(`mysql_data`/`redis_data`/`rabbitmq_data` 均保留)**。验收结束后容器保持运行,由负责人决定是否停止。 |
|||
|
|||
## 3. 真实 Schema 对账 |
|||
|
|||
真实 MySQL 执行 `SHOW COLUMNS FROM sms_verification_codes`(**应用账号 + `MYSQL_PWD`,非 root**): |
|||
|
|||
``` |
|||
id phone code purpose attempts max_attempts expired_at used created_at |
|||
``` |
|||
|
|||
三方对账**完全一致**: |
|||
|
|||
| 来源 | 列集合 | |
|||
|---|---| |
|||
| 真实 MySQL | `attempts code created_at expired_at id max_attempts phone purpose used` | |
|||
| 实体 `@Column` | 同上 | |
|||
| `V1__base_schema.sql` | 同上 | |
|||
|
|||
- **包含 `expired_at`** ✅ |
|||
- **不包含 `expires_at`** ✅ |
|||
|
|||
**反证**:`SELECT expires_at FROM sms_verification_codes LIMIT 1;` → `ERROR 1054 (42S22) Unknown column 'expires_at' in 'field list'`(退出码 1)。 |
|||
|
|||
## 4. 真实取码 SQL |
|||
|
|||
修复后的等价只读查询在真实 MySQL 执行: |
|||
|
|||
```sql |
|||
SELECT code FROM sms_verification_codes |
|||
WHERE phone = ? AND purpose = ? AND used = 0 |
|||
AND expired_at > NOW() |
|||
ORDER BY created_at DESC, id DESC LIMIT 1; |
|||
``` |
|||
|
|||
**退出码 = 0,无 `ERROR 1054`**。(查询返回行数按参数而定,本报告不展示任何取回的 `code`。) |
|||
|
|||
## 5. 最新 JAR 启动 |
|||
|
|||
| 项 | 值 | |
|||
|---|---| |
|||
| JAR | `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`(`mvn clean verify` 产物) | |
|||
| 启动参数 | `--spring.jpa.hibernate.ddl-auto=validate`、`--app.sms.mock.enabled=true`、`--server.port=8080` | |
|||
| 启动结果 | **`Started MyDreamjobApplication`(14.9 s)** | |
|||
| Flyway | 无错误(仅一条 MySQL 8.4 版本提示 WARN) | |
|||
| Schema validation | **无错误** | |
|||
| `GET /api/v1/health` | **HTTP 200,`status=UP`** | |
|||
| 运行时 OpenAPI | **173 paths / 211 operations** | |
|||
|
|||
端口 8080 在启动前空闲,无未知进程被停止。日志写入本轮新目录 `docs/acceptance/s01-20260914/`,未覆盖历史日志。 |
|||
|
|||
## 6. 真实完整冒烟链 |
|||
|
|||
以唯一虚构前缀 `1990914xxxx` 与隔离 requestId 执行: |
|||
|
|||
```bash |
|||
SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true bash docs/smoke-test.sh |
|||
``` |
|||
|
|||
结果:**PASS=10 FAIL=0 SKIP=1,exit=0** |
|||
|
|||
| 步骤 | 结果 | |
|||
|---|---| |
|||
| `GET /health` | PASS(200) | |
|||
| `POST /auth/sms/send` + `POST /auth/login` | **PASS**(200 / 200) | |
|||
| `GET /jobs?size=5` | PASS | |
|||
| `GET /jobs/public/{id}` | PASS | |
|||
| `GET /jobs/{id}/match` | PASS | |
|||
| `POST /applications` | PASS | |
|||
| `PATCH /applications/{id}/status` | **SKIP**(未提供 `COMPANY_TOKEN`,属脚本既有语义) | |
|||
| `GET /notifications` | PASS | |
|||
| `GET /notifications/unread-count` | PASS | |
|||
| `POST /notifications/{id}/read` | PASS | |
|||
| `POST /applications/{id}/withdraw` | PASS | |
|||
|
|||
逐项确认: |
|||
|
|||
1. 发码成功 ✅ |
|||
2. `smoke-test.sh` 经**真实 `docker exec`** 连真实 MySQL ✅ |
|||
3. 使用应用账号 `MYSQL_USER`,**未使用 root** ✅ |
|||
4. mysql argv **不含密码** ✅(见 §7) |
|||
5. SQL 使用 `expired_at` ✅ |
|||
6. 成功取得可用验证码,**未显示其值** ✅ |
|||
7. 真实 `POST /auth/login` 成功 ✅ |
|||
8. 获得 Access Token,**未输出** ✅ |
|||
9. 后续岗位/投递/通知步骤按夹具条件执行 ✅ |
|||
10. **未出现 `ERROR 1054`** ✅ |
|||
11. 未出现空验证码 ✅ |
|||
12. 未从日志读取验证码 ✅ |
|||
13. stdout/stderr 不含验证码、完整手机号、JWT、`Authorization`、数据库口令 ✅ |
|||
|
|||
**核心链路无 SKIP**:唯一 SKIP 是企业状态步骤,取码与登录核心链路均实际执行并通过。 |
|||
|
|||
**真实库侧证据**:该手机号在 `sms_verification_codes` 中产生 **1 行、`used=1`、`code` 长度 6** —— 真实 MySQL 中确实生成了 6 位验证码并被真实登录消费。 |
|||
|
|||
> **关键推论**:登录成功即证明真实执行的 SQL 使用了**有效列**。若仍是 `expires_at`,查询会报 `ERROR 1054`、取不到码,登录必然失败。 |
|||
|
|||
### `bash -x` 复验 |
|||
|
|||
以 `bash -x` 执行(新手机号):**exit=0,PASS=10 FAIL=0 SKIP=1**,捕获 stdout+stderr 后扫描,敏感命中数: |
|||
|
|||
| 标记 | 命中 | |
|||
|---|---| |
|||
| 实际验证码值 | **0** | |
|||
| 完整手机号 | **0** | |
|||
| `Authorization: Bearer` | **0** | |
|||
| `LOGIN_BODY=` | **0** | |
|||
| JWT 形态(`eyJ…`) | **0** | |
|||
| `ERROR 1054` | **0** | |
|||
|
|||
## 7. F5 真实环境 argv 证据 |
|||
|
|||
在真实查询执行期间,读取**容器内 mysql 进程的实际 argv**: |
|||
|
|||
``` |
|||
mysql -umydreamjob -N -B mydreamjob |
|||
``` |
|||
|
|||
- 使用**应用账号**(非 root)✅ |
|||
- **不含 `-p<密码>`** ✅ |
|||
- **不含 `--password`** ✅ |
|||
- 密码经 `MYSQL_PWD` 环境变量传递 ✅ |
|||
|
|||
## 8. mock=false 真实 MySQL 回滚 |
|||
|
|||
以 `--app.sms.mock.enabled=false` 重启后调用 `POST /auth/sms/send`: |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| 响应 | `HTTP 400` + `{"code":400,"message":"sms sender unavailable"}` | |
|||
| 是否虚假成功 | **否** ✅ | |
|||
| 验证码表前后状态 | **完全一致**(该手机号 0 行;全局 `max_id` 994→994;总数 918→918) ✅ | |
|||
| 残留 `used=0` 验证码 | **无** ✅ | |
|||
| 新增虚假发送成功审计 | **0** 行 ✅ | |
|||
| 日志含手机号/验证码 | **0** 命中 ✅ | |
|||
|
|||
响应与上一轮(`docs/acceptance/s01-20260911/d2-sms-response.txt`)逐字一致。 |
|||
|
|||
## 9. 配置缺失 / 空值 / 非法值启动 |
|||
|
|||
| 用例 | 启动 | Bean 缺失错误 | 发码响应 | 是否落库 | |
|||
|---|---|---|---|---| |
|||
| 配置**缺失** | `Started` ×1 | **0** | 400 `sms sender unavailable` | 未落库(994→994) ✅ | |
|||
| `SMS_MOCK_ENABLED=`(**空值**) | `Started` ×1 | **0** | 400 `sms sender unavailable` | 未落库(994→994) ✅ | |
|||
| `SMS_MOCK_ENABLED=invalid-value`(**非法值**) | `Started` ×1 | **0** | 400 `sms sender unavailable` | 未落库(994→994) ✅ | |
|||
|
|||
**「恰好一个 `SmsCodeSender` 且为 `UnavailableSmsCodeSender`」在真实环境按排除法成立**: |
|||
|
|||
- 0 个 Bean → 启动会以 `NoSuchBeanDefinition` 失败;≥2 个 → 会以 `NoUniqueBeanDefinition` 失败;Mock 生效 → 发码成功且落库。 |
|||
- 实测:**启动成功** + **发码不可用** + **未落库**,三者互斥地锁定「恰好一个 `UnavailableSmsCodeSender`」。 |
|||
|
|||
(Bean 数量与类型的严格断言另由 `SmsSenderConfigurationTest`(真实 `ApplicationContextRunner`)在 §10 覆盖。) |
|||
|
|||
每次只停止本轮明确记录 PID 的 JAR,未触碰任何未知进程。 |
|||
|
|||
## 10. 日志脱敏扫描 |
|||
|
|||
扫描对象:**本轮全部新日志**(6 个应用日志 + stdout)**+ 冒烟 stdout/stderr**。 |
|||
|
|||
| 禁止项 | 命中 | |
|||
|---|---| |
|||
| 实际验证码(本轮真实产生的 2 个,逐个比对) | **0 / 0** | |
|||
| 完整手机号(6 个测试号逐个比对) | **0** | |
|||
| `Authorization: Bearer` | **0** | |
|||
| JWT 形态(`eyJ…`) | **0** | |
|||
| `MYSQL_PASSWORD` 实际值 / 数据库口令 | **0** | |
|||
| `LOGIN_BODY` | **0** | |
|||
| `MOCK SMS CODE` | **0** | |
|||
| `NumberFormatException` | **0** | |
|||
| `ERROR 1054` | **0** | |
|||
|
|||
**敏感标记命中合计 = 0。** |
|||
|
|||
扫描器有效性对照:注入探针(`MyDreamJob backend smoke test`)命中 **2**,证明扫描器在工作,而非空转的 0。 |
|||
|
|||
## 11. OpenAPI 与回归 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| `python docs/verify_openapi.py` | **All checks passed!** | |
|||
| 运行时 `/v3/api-docs` | **173 paths / 211 operations** | |
|||
| 静态 `docs/openapi-rc.json` | **173 paths / 211 operations** | |
|||
| 静/运行时一致 | **YES**(路径差集:静态独有 = 无,运行独有 = 无) | |
|||
| 是否新增取码接口 | **无**(含 "code" 的唯一路径是 `/admin/scoring/risk/rules/{ruleCode}`,为风控规则码,与验证码无关) | |
|||
| 接口结构变化 | **无** | |
|||
|
|||
Maven 安全专项: |
|||
|
|||
``` |
|||
mvn -Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest,SmsEmptyPropertyStartupIntegrationTest,SmsUnavailableSenderRollbackIntegrationTest" test |
|||
``` |
|||
|
|||
→ **Tests run: 61, Failures: 0, Errors: 0, Skipped: 0 — BUILD SUCCESS** |
|||
|
|||
## 12. 数据清理 |
|||
|
|||
本轮产生的数据可按唯一虚构前缀 `1990914` 精确圈定(时间戳均为 2026-09-14): |
|||
|
|||
| 表 | 行数 | |
|||
|---|---| |
|||
| `users` | 2(验收虚构号码:`199****0101`、`199****0102`) | |
|||
| `sms_verification_codes` | 2 | |
|||
| `applications` | 2(均为 `WITHDRAWN`) | |
|||
| `notifications` | 4 | |
|||
| `user_roles` | 2 | |
|||
| `audit_logs` | 8 | |
|||
|
|||
**决策:保留本轮数据,不执行删除。** 理由: |
|||
|
|||
1. `audit_logs` 有 **8 行** `user_id` 指向这两个用户,且该表**无外键约束**;删除用户会使审计行成为孤儿。 |
|||
2. 本轮任务边界明确**禁止删除 `audit_logs` 行**。 |
|||
3. 保留可维持审计链一致;边界亦明确允许「无法安全确认时保留数据并记录」。 |
|||
|
|||
未执行任何模糊 `DELETE`、未清空表、未重置自增、未修改真实账号。上述足迹为精确记录,供后续人工处置。 |
|||
|
|||
## 13. 最终 Git 检查 |
|||
|
|||
| 项 | 结果 | |
|||
|---|---| |
|||
| `git status --short` | 24 个已跟踪改动(**与本轮开始时同一批**)+ 98 个未跟踪(既有残骸 + 本轮验收产物) | |
|||
| `git diff --cached --name-only` | **空**(未暂存) | |
|||
| `git diff --check` | 无空白错误 | |
|||
| `git diff --name-only -- db/migration` | **空**(V1–V41 未修改) | |
|||
| 删除文件 | **0** | |
|||
| commit / push | **未执行** | |
|||
| S02 | **未进入** | |
|||
|
|||
## 14. 验收结论 |
|||
|
|||
| # | 判据 | 结果 | |
|||
|---|---|---| |
|||
| 1 | Docker/MySQL 可用 | ✅ | |
|||
| 2 | 真实表包含 `expired_at` | ✅ | |
|||
| 3 | 真实取码 SQL 执行成功 | ✅ | |
|||
| 4 | 完整学生登录链成功 | ✅ | |
|||
| 5 | `bash -x` 无敏感泄露 | ✅ | |
|||
| 6 | mock=false 真实 MySQL 完整回滚 | ✅ | |
|||
| 7 | 缺失/空/非法配置安全回退 | ✅ | |
|||
| 8 | 运行时 OpenAPI 为 173/211 | ✅ | |
|||
| 9 | 无日志泄露 | ✅ | |
|||
| 10 | 核心项无 SKIP | ✅ | |
|||
| 11 | FAIL = 0 | ✅ | |
|||
|
|||
**S01独立代码复验和真实MySQL/Docker黑盒全部通过,可以进入精确暂存检查点。** |
|||
|
|||
--- |
|||
|
|||
## 遗留与观察(不阻断本轮结论) |
|||
|
|||
1. **验证码仍为数据库明文存储**(未改表结构,属已知遗留)。 |
|||
2. **真实短信供应商未接入**;生产 `mock=false` 下发码一律安全失败,需产品确认上线策略。 |
|||
3. **本轮测试账号保留在库**(见 §12),与其审计记录一致;如需清理应由人工按精确 ID 处置。 |
|||
4. `/api/v1/api-docs` 返回 500(`/api/v1/v3/api-docs` 正常 200)。该行为与 S01 无关,非本轮引入,记录待查。 |
|||
5. 三个容器在验收结束后**保持运行**(本轮仅 `docker start`,未重建、未删除 volume),是否停止由负责人决定。 |
|||
@ -0,0 +1,786 @@ |
|||
# S01 定向整改报告(F1 / F2 / F3) |
|||
|
|||
- **整改日期**:2026-09-11 |
|||
- **分支**:`fix/resume-upload-safety` |
|||
- **范围**:仅修复独立验收确认的 F1、F2、F3,并完成对应自动化与干净构建 |
|||
- **本报告不包含**:数据库口令、.env 内容、JWT、验证码值、完整手机号、明文测试口令、非法 principal 敏感标记原文 |
|||
- **整改期间状态**:`S01独立验收未通过,定向整改中。` |
|||
|
|||
--- |
|||
|
|||
## 1. F1 的修复方式 |
|||
|
|||
`docs/smoke-test.sh` 的 `sms_code()` **彻底删除**了对应用日志的依赖(grep `MOCK SMS CODE`),改为**双重保护 + 容器内部只读 SQL 查询**。 |
|||
|
|||
同时删除:`SERVER_LOG` 变量、`resolve_log()` 函数、全部日志 grep。未恢复任何验证码日志。 |
|||
|
|||
| 项 | 整改前 | 整改后 | |
|||
|---|---|---| |
|||
| 取码来源 | `grep -a "MOCK SMS CODE.*phone=$PHONE" <日志>` | 容器内 `SELECT code FROM sms_verification_codes …` | |
|||
| 前置授权 | 无 | `SMS_MOCK_ENABLED=true` **且** `ALLOW_TEST_OTP_DB_READ=true` | |
|||
| 取码返回值 | stdout(进入 command substitution) | `REPLY` 变量约定(不打印) | |
|||
| 请求体传参 | `-d "<含验证码>"`(进入进程参数) | `--data-binary @-`(经 stdin) | |
|||
| 凭据处理 | 需 `SERVER_LOG` | 容器自身 `MYSQL_ROOT_PASSWORD` / `MYSQL_DATABASE`(不在本机命令行拼写口令) | |
|||
| 清理 | 无 | `unset REPLY/SMS/LOGIN_BODY` + `trap cleanup_secrets EXIT` | |
|||
|
|||
脚本仍为 Bash,兼容当前 Git Bash;`bash -n` 语法校验通过。 |
|||
|
|||
## 2. smoke-test.sh 的新取码机制 |
|||
|
|||
``` |
|||
sms_code() |
|||
├─ require_mock_sms_mode() # SMS_MOCK_ENABLED 必须为 true |
|||
├─ require_test_otp_db_read_permission() # ALLOW_TEST_OTP_DB_READ 必须为 true |
|||
├─ 校验 PHONE 为 11 位纯数字(防注入) |
|||
├─ 记录并关闭 xtrace(set +x),执行后精确恢复原状 |
|||
├─ IFS= read -r code < <(otp_db_query ...) # 不使用 command substitution 取值 |
|||
├─ 校验 code 为 6 位数字(当前 code-length / CODE_PATTERN) |
|||
├─ 失败 → unset code + 固定提示 + return 1(绝不携带空值继续登录) |
|||
└─ 成功 → REPLY="$code"; unset code; return 0 |
|||
``` |
|||
|
|||
`otp_db_query()` 关键点: |
|||
|
|||
- **容器名不硬编码**:`compose_mysql_container()` 用 `awk` 解析项目实际 `backend-infra/compose.yaml` 中 `mysql` 服务的 `container_name`;用 `BASH_SOURCE` 定位自身目录(被 `source` 时同样正确) |
|||
- SQL 经 **stdin**(heredoc 风格 `printf | docker exec -i` ),不出现在任何进程参数中 |
|||
- 查询条件完整:`phone` + `purpose` + `used = 0` + `expired_at > NOW()` |
|||
(更正:本行原写作 `expires_at`,该列在 `sms_verification_codes` 中**不存在**,是 F6 记录的错误字段名;已于第四轮一并更正,详见文末「第四轮定向整改(F6)」) |
|||
- `ORDER BY created_at DESC, id DESC LIMIT 1`,只取 `code` 列 |
|||
- 不使用 `curl -v`;失败诊断不打印请求体 |
|||
|
|||
## 3. 为什么不会把验证码输出到终端 / 日志 / 文件 |
|||
|
|||
1. 取码函数**从不 `echo`/`printf` 验证码**,只写 `REPLY`;调用方 `SMS="$REPLY"` 后立即 `unset REPLY` |
|||
2. `set +x` 包住取值与查询,且**按调用前状态精确恢复**,不无条件开启 xtrace |
|||
3. 登录请求体经 `--data-binary @-` 从 stdin 送入 curl,验证码不进入 argv |
|||
4. 请求体只在 `LOGIN_BODY` 局部变量中存在,用完 `unset`;**不落盘** |
|||
5. `trap cleanup_secrets EXIT` 兜底清理 `OTP/REPLY/LOGIN_BODY` |
|||
6. 脚本控制台只输出 `PHONE=$(mask_phone "$PHONE")`(脱敏为 `199****9999`),不打印完整手机号 |
|||
7. 应用侧本就不输出验证码(S01 已整改),Mock 发送器仅输出固定文本 |
|||
|
|||
## 4. `ALLOW_TEST_OTP_DB_READ` 的强制条件 |
|||
|
|||
```bash |
|||
is_true() { v=$(printf '%s' "${1:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]'); [ "$v" = "true" ]; } |
|||
require_mock_sms_mode() { is_true "${SMS_MOCK_ENABLED:-}"; } |
|||
require_test_otp_db_read_permission() { is_true "${ALLOW_TEST_OTP_DB_READ:-}"; } |
|||
``` |
|||
|
|||
- 两者同时为 `true`(大小写/空白不敏感)才允许查询 |
|||
- 任一不满足:立即返回非 0,输出固定提示 `test OTP retrieval is disabled`,**不执行任何 docker/SQL 调用** |
|||
- 提示中不含手机号、验证码、数据库口令、SQL 结果或容器名 |
|||
|
|||
## 5. 未授权取码的实际结果 |
|||
|
|||
| 场景 | 实际结果 | |
|||
|---|---| |
|||
| `SMS_MOCK_ENABLED` 缺失 | 拒绝;输出 `test OTP retrieval is disabled`;**docker 未被调用** | |
|||
| `SMS_MOCK_ENABLED=false` | 拒绝;同上 | |
|||
| `ALLOW_TEST_OTP_DB_READ` 缺失 | 拒绝;同上 | |
|||
| `ALLOW_TEST_OTP_DB_READ=false` | 拒绝;同上 | |
|||
| 两者为 true 但无可用验证码 | `test OTP retrieval failed`,**不继续登录** | |
|||
| 两者为 true 但查询失败/结果非 6 位数字 | 同上,**不继续登录** | |
|||
|
|||
## 6. F2 修改的全部文档与行 |
|||
|
|||
| 文件 | 行(整改后) | 修改前 | 修改后要点 | |
|||
|---|---|---|---| |
|||
| `docs/acceptance-week1.md` | 94 | `# Mock 登录(从日志获取验证码)` | 指向受控取码方式 | |
|||
| `docs/acceptance-week1.md` | 148 | 注: 验证码从应用日志中获取 | 明确日志不输出;列出双开关;生产禁止查表 | |
|||
| `docs/acceptance-week1.md` | 152 | 验证码打印到日志 | 明确不输出;标注明文存储遗留 | |
|||
| `docs/api-v0.1.md` | 27 | Mock环境验证码打印到日志 | 明确不输出 + 受控取得 | |
|||
| `docs/api-v0.1.md` | 174 | 验证码打印到日志 | 明确不输出;未接入供应商返回服务不可用 | |
|||
| `docs/frontend-integration.md` | 37-49(§3 整节重写) | 「Mock 验证码日志位置」+ 旧示例行 `MOCK SMS CODE: … code=******` + 「看终端输出/看日志文件/取最新一条」 | 改为「本地获取测试验证码(不通过日志)」:双开关 + 受控脚本 + 生产禁止查表 + 明文遗留风险 | |
|||
| `docs/frontend-integration.md` | 149 | 验证码打印在服务日志 | 改为不再输出 + 受控取得 | |
|||
| `docs/offer-api.md` | 262 | 验证码见应用日志 | 改为受控取得 + 生产禁止查表 | |
|||
| `docs/resume-api.md` | 189 | 登录学生账号(Mock 验证码,见日志) | 改为受控取得 + 生产禁止查表 | |
|||
| `docs/smoke-test.sh` | 全文头部与取码段 | 依赖日志取码 | 见第 1-5 节 | |
|||
|
|||
**文档统一口径(已落实)**: |
|||
|
|||
1. 删除所有「从控制台/日志查看验证码」的操作指导 |
|||
2. 明确应用日志不会输出验证码 |
|||
3. dev/test 本地自动化使用受控测试夹具/数据库内部只读查询 |
|||
4. 冒烟脚本仅在 `mock=true` **且** `ALLOW_TEST_OTP_DB_READ=true` 时查表 |
|||
5. 真实生产环境**禁止**查询验证码表辅助登录 |
|||
6. `mock=false` 且无真实供应商时返回「服务不可用」 |
|||
7. 明确当前尚未接入真实短信供应商 |
|||
8. **未声称**验证码已加密/哈希存储 |
|||
9. 明确验证码当前仍为数据库**明文**遗留风险 |
|||
10. 示例中不含真实手机号、验证码、JWT 或数据库口令 |
|||
|
|||
**在途文件保护**:`docs/frontend-integration.md` 与 `docs/resume-api.md` 仅改动取码相关段落;`git diff` 复核确认简历上传加固内容(文件名净化、PDF/DOC/DOCX 结构校验、上传替换一致性、恶意软件扫描边界等)**逐字保留**,未覆盖、未还原、未重新格式化。 |
|||
|
|||
## 7. 全仓遗留搜索结果 |
|||
|
|||
``` |
|||
grep -rn -i "MOCK SMS CODE|控制台.*验证码|验证码.*打印到日志|从日志获取|查看.*验证码" |
|||
docs README.md mydreamjob-server/src |
|||
``` |
|||
|
|||
- **「打印到日志 / 从日志获取」命中数:0** |
|||
- `MOCK SMS CODE` 在 `mydreamjob-server/src` 与 `docs/` 中的命中数:**0** |
|||
- 剩余匹配全部为**禁止性表述或无关标识符**(如「日志不得包含验证码」「不得写入日志」「SMS_CODE_INVALID」枚举名) |
|||
- `README.md:167` 本身即「不写入敏感信息」的正确约定,未修改 |
|||
|
|||
## 8. F3 使用的 Bean 装配方案 |
|||
|
|||
采用**推荐的单 `@Configuration` + 单 `@Bean` 方案**(不再依赖两套互斥 `@ConditionalOnProperty`): |
|||
|
|||
```java |
|||
@Configuration |
|||
public class SmsSenderConfiguration { |
|||
private static final String MOCK_ENABLED_VALUE = "true"; |
|||
|
|||
@Bean |
|||
public SmsCodeSender smsCodeSender( |
|||
@Value("${app.sms.mock.enabled:false}") String rawEnabled) { |
|||
if (rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim())) { |
|||
return new MockSmsCodeSender(); |
|||
} |
|||
return new UnavailableSmsCodeSender(); |
|||
} |
|||
} |
|||
``` |
|||
|
|||
- `MockSmsCodeSender` / `UnavailableSmsCodeSender` 已移除 `@Component` 与 `@ConditionalOnProperty`,成为普通类 |
|||
- 满足:始终恰好一个 Bean;仅显式 `true` 启用 Mock;非法值不启用 Mock;**不根据 profile 偷偷启用**;`application-dev.yml` 仍显式 `true`;`application.yml` 默认 `false` |
|||
- 不把选择逻辑放回 `SmsVerificationService`;不新增第二套发送流程;不用 try/catch Bean 缺失兜底;不在每次发送时读配置;**不输出配置原值到日志** |
|||
|
|||
## 9. 各配置取值对应的实现 |
|||
|
|||
| `app.sms.mock.enabled` | 装配实现 | |
|||
|---|---| |
|||
| `true` / `TRUE` / `True` / `" true "` | `MockSmsCodeSender` | |
|||
| `false` | `UnavailableSmsCodeSender` | |
|||
| 缺失 | `UnavailableSmsCodeSender` | |
|||
| 空字符串(`=`) | `UnavailableSmsCodeSender` | |
|||
| 仅空白(`= `) | `UnavailableSmsCodeSender` | |
|||
| 非法值(`yes`/`1`/`on`/`enabled`/`null`/`mock`/`TRUE1`) | `UnavailableSmsCodeSender`(安全回退,**绝不启用 Mock**) | |
|||
|
|||
## 10. 每种配置的 Bean 数量 |
|||
|
|||
由 `SmsSenderConfigurationTest.everySupportedConfigurationCreatesExactlyOneSenderBean` 断言(真实 `ApplicationContextRunner`,含 9 种取值 + 缺失属性): |
|||
|
|||
**每种取值 `getBeanNamesForType(SmsCodeSender.class).length == 1`**。 |
|||
|
|||
## 11. 空值启动结果 |
|||
|
|||
| 层级 | 结果 | |
|||
|---|---| |
|||
| 快速装配测试(`ApplicationContextRunner`) | 上下文**启动成功**;恰好一个 Bean;类型 `UnavailableSmsCodeSender`;消费者构造注入成功;发送抛固定安全 `SERVICE_UNAVAILABLE` | |
|||
| **全应用上下文测试**(`SmsEmptyPropertyStartupIntegrationTest`,真实 H2 + Flyway + 全 Bean) | 应用上下文**启动成功**;恰好一个 `SmsCodeSender`;类型 `UnavailableSmsCodeSender`;`SmsVerificationService` **可注入**;发送返回固定安全服务不可用 ✅ | |
|||
|
|||
整改前该取值导致 `APPLICATION FAILED TO START … required a bean of type 'com.mydreamjob.auth.sms.SmsCodeSender' that could not be found`(独立验收阶段已复现并记录在验收报告 F3)。 |
|||
|
|||
> 本轮按要求**未操作 Docker / 未执行真实 MySQL 黑盒**,故未做真实 JAR 启动;全应用上下文启动已由 H2+Flyway 覆盖,真实环境启动仍待独立复验。 |
|||
|
|||
## 12. 是否恢复验证码日志 |
|||
|
|||
**未恢复。** `grep -rn "MOCK SMS CODE" mydreamjob-server/src` 命中 0;`MockSmsCodeSender` 仅输出固定文本 `SMS verification code generated (mock sender)`;`SmsVerificationService` 仅两处 `log.warn`,参数为 `maskPhone(phone)`;`AuthService` 仅记录 `id`。 |
|||
|
|||
## 13. 原 S01 权限与事务回归结果 |
|||
|
|||
| 原 S01 要求 | 结果 | |
|||
|---|---| |
|||
| 不恢复验证码日志 | ✅(第 12 节) | |
|||
| 不输出完整手机号 | ✅ `SmsVerificationSecurityTest` 10/10 | |
|||
| Mock 发送器不打印验证码 | ✅ | |
|||
| mock=false 不返回虚假成功 | ✅ `SmsSenderConfigurationTest` + 回滚集成测试 | |
|||
| mock=false 发送失败事务回滚 | ✅ `SmsUnavailableSenderRollbackIntegrationTest` 3/3(真实 Spring + H2 + Flyway:失败后该手机号**无任何验证码行**、总数不变、无虚假成功审计) | |
|||
| 匿名 POST /jobs = 401 | ✅ `AuthenticationBoundaryIntegrationTest` 15/15 | |
|||
| STUDENT/ADMIN 创建岗位 = 403/2002 | ✅ | |
|||
| COMPANY 合法创建保持成功 | ✅ | |
|||
| 非法 principal 返回 401 | ✅ `CurrentUserAccessorTest` 11/11 | |
|||
| 不返回 NumberFormatException / 内部 principal | ✅ | |
|||
| OpenAPI 173/211 | ✅ | |
|||
| 无新增公网取码接口 | ✅(OpenAPI 173/211 不变) | |
|||
| 无新 Flyway/表/索引/ErrorCode | ✅ | |
|||
|
|||
## 14. Shell 测试数量与结果 |
|||
|
|||
`docs/tests/smoke-test-sms-code-test.sh`(新增):**22 用例,PASS=22 / FAIL=0** |
|||
|
|||
覆盖:mock=true+ALLOW=true 可取码、mock=false 拒绝、配置缺失拒绝、ALLOW 缺失/为 false 拒绝、无可用验证码安全失败、查询失败不继续、结果非数字/长度非法拒绝、查询为空时不继续登录(无 login 请求)、stdout/stderr 不含验证码、不含完整手机号、生成文件不含验证码、不 grep 应用日志、不存在 `MOCK SMS CODE`、REPLY/SMS/LOGIN_BODY 清理与 EXIT trap 存在、未授权时不触碰数据库、拒绝提示不含手机号、**授权模式下可完成登录流程(登录请求已发出且携带 6 位码、登录步骤判定 PASS)**。 |
|||
|
|||
测试通过 PATH 注入假 `docker`/`curl` 实现,**不要求 Docker 或真实 MySQL**,仓库内不写入真实验证码,结束时扫描输出确认标记未泄露。 |
|||
|
|||
## 15. Maven 专项、关联与全量测试数 |
|||
|
|||
| 命令 | Tests run | |
|||
|---|---| |
|||
| `-Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest"` | **56** | |
|||
| `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **222** | |
|||
| `mvn test`(最终,含本轮新增 2 个集成测试) | **1671** | |
|||
| `mvn clean verify` | **1671** | |
|||
|
|||
S01 相关类明细: |
|||
|
|||
| 测试类 | Tests | |
|||
|---|---| |
|||
| `SmsSenderConfigurationTest`(F3,由 4 扩展至 12) | 12 | |
|||
| `SmsVerificationSecurityTest` | 10 | |
|||
| `SmsVerificationServiceTest` | 8 | |
|||
| `CurrentUserAccessorTest` | 11 | |
|||
| `AuthenticationBoundaryIntegrationTest` | 15 | |
|||
| `SmsUnavailableSenderRollbackIntegrationTest`(本轮新增) | 3 | |
|||
| `SmsEmptyPropertyStartupIntegrationTest`(本轮新增) | 2 | |
|||
|
|||
## 16. Failures / Errors / Skipped |
|||
|
|||
**Failures=0、Errors=0、Skipped=0**(全量与 clean verify 一致) |
|||
|
|||
## 17. clean verify 结果 |
|||
|
|||
`mvn clean verify` → **BUILD SUCCESS**(4:34),产出 `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。 |
|||
|
|||
## 18. OpenAPI paths / operations |
|||
|
|||
| 来源 | paths | operations | |
|||
|---|---|---| |
|||
| 静态 `docs/verify_openapi.py` | **173** | **211** | |
|||
| 校验输出 | `All checks passed!` | — | |
|||
|
|||
未新增/删除路径或 operation;无新增测试取码端点。 |
|||
|
|||
## 19. 精确修改文件 |
|||
|
|||
**修改(已跟踪,本轮 F1/F2)** |
|||
|
|||
| 文件 | 变更 | |
|||
|---|---| |
|||
| `docs/smoke-test.sh` | +176/−…(取码机制整体替换、双保护、脱敏输出、库模式钩子) | |
|||
| `docs/acceptance-week1.md` | 3 处取码表述 | |
|||
| `docs/api-v0.1.md` | 2 处取码表述 | |
|||
| `docs/frontend-integration.md` | §3 整节重写 + 1 处(**在途文件,最小改动**) | |
|||
| `docs/offer-api.md` | 1 处取码表述 | |
|||
| `docs/resume-api.md` | 1 处取码表述(**在途文件,最小改动**) | |
|||
|
|||
**修改(S01 新建文件,本轮扩展,F3)** |
|||
|
|||
| 文件 | 变更 | |
|||
|---|---| |
|||
| `auth/sms/MockSmsCodeSender.java` | 移除 `@Component`/`@ConditionalOnProperty`,改为普通类 | |
|||
| `auth/sms/UnavailableSmsCodeSender.java` | 同上 | |
|||
| `auth/SmsSenderConfigurationTest.java` | 由 4 用例扩展至 12(真实 ApplicationContextRunner + 消费者探针) | |
|||
|
|||
**新增** |
|||
|
|||
| 文件 | 用途 | |
|||
|---|---| |
|||
| `auth/sms/SmsSenderConfiguration.java` | F3 单 Bean 装配 | |
|||
| `auth/SmsUnavailableSenderRollbackIntegrationTest.java` | 真实 Spring+H2:失败不残留验证码 | |
|||
| `auth/SmsEmptyPropertyStartupIntegrationTest.java` | 空值全应用上下文启动 | |
|||
| `docs/tests/smoke-test-sms-code-test.sh` | F1 shell 安全测试(22 用例) | |
|||
|
|||
## 20. Git 状态 |
|||
|
|||
- 分支:`fix/resume-upload-safety`(未变) |
|||
- **暂存区:空** |
|||
- `git diff --check`:无空白错误(仅 CRLF 提示) |
|||
- 迁移改动:**无**(V1–V41 共 41 个文件,最大版本 41) |
|||
- 删除文件:**0** |
|||
- 历史未跟踪文件:未删除、未移动、未覆盖(含 `docs/acceptance/s01-20260911/` 全部验收证据 43 个文件与验收报告,保持原样) |
|||
|
|||
## 21. 是否修改 Flyway、表、ErrorCode 或公网接口 |
|||
|
|||
**均未修改。** |
|||
|
|||
- Flyway:无新增/修改,V1–V41 原样 |
|||
- 数据库表/索引:未新增或修改(验证码仍明文存储于 `sms_verification_codes`,属已记录遗留风险) |
|||
- `ErrorCode`:未被改动 |
|||
- 公网接口:OpenAPI 保持 173 paths / 211 operations;未新增取码接口 |
|||
|
|||
## 22. 明确未执行 git add -A / commit / push |
|||
|
|||
**未执行** `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。 |
|||
|
|||
## 23. 明确未操作 Docker、未执行真实 MySQL 黑盒 |
|||
|
|||
**未执行任何 Docker 命令**、**未执行真实 MySQL 黑盒**。本轮全部验证均为:shell 测试(假 docker/curl 注入)+ Maven 单元/集成测试(H2 + Flyway + 真实 Spring 上下文)。 |
|||
|
|||
## 24. 真实 MySQL / Docker 复验仍待独立验收 |
|||
|
|||
F1 的数据库取码路径(`docker exec` + 容器内 SQL)**尚未在真实 MySQL 环境端到端执行**;F3 的运行期 JAR 启动(含空值 / 缺失 / 非法值)亦未在真实环境启动验证。二者**必须由独立复验在真实 MySQL/Docker 环境完成**,不得以本轮 H2 结果代替。 |
|||
|
|||
--- |
|||
|
|||
## 遗留与后续 |
|||
|
|||
1. **验证码仍为数据库明文存储**(本轮未改表结构,属已知遗留) |
|||
2. **真实短信供应商未接入**;生产 `mock=false` 下发码一律安全失败,需产品确认上线策略 |
|||
3. **存量 43 处直接身份解析**未收口(均不可匿名到达;`DeletionGuardInterceptor` 对 20 位数字溢出仍可能 500)——非本轮范围 |
|||
4. **安全层 401 响应不含 requestId**(既有 `AuthenticationEntryPoint` 固定载荷),与业务层错误响应格式不一致 |
|||
5. 上一轮验收的其余观察项(失败后残留 `sms:rate` 限流键 60s;Spring Boot 默认 `generated security password` 入日志)**本轮未处理**,仍属非阻断遗留 |
|||
|
|||
--- |
|||
|
|||
# 第三轮定向整改(F4 / F5) |
|||
|
|||
- **整改日期**:2026-09-11(第二轮之后) |
|||
- **独立复验结论**:S01 仍 FAIL —— F2/F3 已通过,但新发现 F4(xtrace 泄露)与 F5(数据库密码进入 mysql argv)两个阻断项 |
|||
- **本轮范围**:仅修 F4/F5,并相应扩展 shell 测试、修正文档测试口径 |
|||
- **本轮修改文件**:`docs/smoke-test.sh`、`docs/tests/smoke-test-sms-code-test.sh`、本报告 |
|||
- **本轮未触碰**:S01 Java 生产代码(时间戳仍为 15:45,本轮为 17:38+ 仅改 docs) |
|||
|
|||
## T1. F4 修复前的泄露位置 |
|||
|
|||
以 `bash -x` 执行时,以下位置被 trace 到 stderr(原文来自复验报告,此处仅列位置): |
|||
|
|||
| 位置 | 泄露内容 | 原因 | |
|||
|---|---|---| |
|||
| `PHONE="${STUDENT_PHONE}"` / 生成分支 | 完整手机号 | 脚本开头未关闭 xtrace | |
|||
| `SMS="$REPLY"` | 验证码 | `sms_code()` 返回前 `set -x` 恢复,赋值被 trace | |
|||
| `LOGIN_BODY="{...code...}"` | 验证码 + 完整手机号 | 赋值被 trace | |
|||
| `printf '%s' "$LOGIN_BODY" \| curl ...` | 登录请求体(含验证码) | 命令与参数被 trace | |
|||
| `LOGIN="$(...)"` | 登录响应全文(含 JWT) | 赋值被 trace | |
|||
| `TOKEN="$(extract_token "$LOGIN")"` | JWT | 赋值被 trace | |
|||
| `AUTH="Authorization: Bearer $TOKEN"` | JWT | 赋值被 trace | |
|||
| 后续 `curl ... -H "$AUTH"` | JWT | 命令参数被 trace | |
|||
| `sms_code()` 内部 `set +x` / `set -x` | 只保护了取码阶段 | 保护范围不足 | |
|||
|
|||
## T2. 全局 xtrace 处理方式 |
|||
|
|||
采用「**开头立即关闭且全程不恢复**」(任务书推荐方案): |
|||
|
|||
```bash |
|||
#!/usr/bin/env bash |
|||
|
|||
# ── xtrace 防护(S01 第三轮 F4)── |
|||
# 必须在读取任何环境变量、组装任何请求之前关闭 xtrace。 |
|||
# 脚本是独立可执行程序,安全优先:全程不重新 set -x。 |
|||
case "$-" in |
|||
*x*) set +x ;; |
|||
esac |
|||
|
|||
set -u |
|||
``` |
|||
|
|||
- 位置:`set +x` 位于**任何环境变量读取与任何请求组装之前**(仅在 shebang 与注释之后) |
|||
- `sms_code()` 内部的 `xtrace_was_on` 与恢复 `set -x` 逻辑**已彻底删除**(`grep -c xtrace_was_on` = 0) |
|||
- 脚本中**不存在可执行的 `set -x`**(仅 1 处注释提及,测试 F4-9 已断言) |
|||
- 保护范围覆盖全脚本:验证码取码、手机号、请求体、登录响应、TOKEN、AUTH 及**后续所有携带 Authorization 的 curl 调用** |
|||
- 未使用 `curl -v`/`--trace`/`--trace-ascii`(测试第 20 条断言) |
|||
- 登录响应全文不再交给打印函数:新增 `extract_status_fields()` + `report_fields()`,登录步骤只把 http / business code / requestId 传出去(测试第 21 条断言) |
|||
|
|||
## T3. `bash -x` 真实执行结果 |
|||
|
|||
由测试脚本内部驱动(捕获被测脚本 stdout+stderr 后扫描标记,只输出 PASS/FAIL): |
|||
|
|||
| 用例 | 结果 | |
|||
|---|---| |
|||
| F4-1 bash -x 下不泄露验证码 | PASS | |
|||
| F4-2 bash -x 下不泄露完整手机号 | PASS | |
|||
| F4-3 bash -x 下不泄露 JWT | PASS | |
|||
| F4-4 bash -x 下不泄露 Authorization 头 | PASS | |
|||
| F4-5 bash -x 下不泄露数据库密码 | PASS | |
|||
| F4-6 bash -x 下不泄露 LOGIN_BODY | PASS | |
|||
| F4-7 bash -x 下正常流程仍可完成登录 | PASS | |
|||
| F4-8 bash -x 下登录步骤判定 PASS | PASS | |
|||
| F4-9 脚本中不存在可执行的 set -x | PASS | |
|||
| F4-10 失败流程(无可用验证码)同样不泄露 | PASS | |
|||
| F4-11 source 模式下 REPLY 契约可用 | PASS | |
|||
| F4-12 source 模式下清理后无敏感变量残留 | PASS | |
|||
| F4-13 source 模式下 EXIT trap 已登记清理函数 | PASS | |
|||
| F4-14 cleanup_secrets 覆盖 PHONE/TOKEN/AUTH/COMPANY_TOKEN | PASS | |
|||
|
|||
另外单独执行 `bash -x docs/tests/smoke-test-sms-code-test.sh` → exit=0、PASS=51/FAIL=0; |
|||
该模式下测试脚本自身的假值可能被 trace(任务书已明确属预期,不作为安全判定), |
|||
但**测试结论输出(stdout)中标记值命中数为 0**。 |
|||
|
|||
## T4. stdout/stderr 敏感标记命中数 |
|||
|
|||
以 `bash -x` 执行被测 `smoke-test.sh`(正常流程与失败流程各一次),对捕获的 stdout+stderr 递归扫描: |
|||
|
|||
| 标记类别 | 命中数 | |
|||
|---|---| |
|||
| 测试验证码 | **0** | |
|||
| 完整手机号 | **0** | |
|||
| 测试 JWT | **0** | |
|||
| `Authorization: Bearer` | **0** | |
|||
| 数据库密码标记 | **0** | |
|||
| `LOGIN_BODY=` | **0** | |
|||
|
|||
(报告不记录标记值本身;标记值均为测试用固定假值,仅存在于测试脚本内。) |
|||
|
|||
## T5. TOKEN / AUTH 生命周期 |
|||
|
|||
| 变量 | 生命周期 | |
|||
|---|---| |
|||
| `REPLY` | `sms_code()` 成功时写入;调用方立即 `SMS="$REPLY"; unset REPLY` | |
|||
| `SMS` | 组装 `LOGIN_BODY` 后立即 `unset SMS` | |
|||
| `LOGIN_BODY` | 经 `--data-binary @-` 送入 curl 后立即 `unset LOGIN_BODY` | |
|||
| `LOGIN`(响应全文) | 仅用于提取 http/code/requestId 与 token;`extract_token` 后立即 `unset LOGIN` | |
|||
| `LOGIN_FIELDS` | 只含可安全打印的三字段,用完 `unset` | |
|||
| `TOKEN` | **保留到流程结束**(后续接口需要),但不打印、不写文件、不进验收产物 | |
|||
| `AUTH` | 由 TOKEN 派生,仅作为 curl 头参数使用;EXIT trap 统一清理 | |
|||
| `COMPANY_TOKEN` | 调用方传入,仅作 curl 头参数;EXIT trap 统一清理 | |
|||
| `PHONE` / `STUDENT_PHONE` | EXIT trap 统一清理;控制台只输出脱敏形式 | |
|||
|
|||
`cleanup_secrets()` 现覆盖:`PHONE SMS OTP REPLY LOGIN_BODY LOGIN TOKEN AUTH` 与 `STUDENT_PHONE COMPANY_TOKEN`,由 `trap cleanup_secrets EXIT` 统一清理。 |
|||
注意:登记块**不再**给 `PHONE`/`STUDENT_PHONE`/`COMPANY_TOKEN` 赋空值(避免覆盖调用方传入值)。 |
|||
|
|||
## T6. F5 修复前后的 mysql 调用形式 |
|||
|
|||
**修复前** |
|||
|
|||
```bash |
|||
docker exec -i "$container" sh -c \ |
|||
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -N -B "${MYSQL_DATABASE:-mydreamjob}"' |
|||
``` |
|||
容器内 shell 会把 `$MYSQL_ROOT_PASSWORD` 展开为 `-p<真实密码>`,进入 mysql 进程 argv。 |
|||
|
|||
**修复后** |
|||
|
|||
```bash |
|||
docker exec -i "$container" sh -c ' |
|||
if [ -z "${MYSQL_USER:-}" ] || [ -z "${MYSQL_PASSWORD:-}" ]; then |
|||
echo "db credentials unavailable" >&2 |
|||
exit 3 |
|||
fi |
|||
MYSQL_PWD="$MYSQL_PASSWORD" exec mysql \ |
|||
-u"$MYSQL_USER" \ |
|||
-N -B \ |
|||
"${MYSQL_DATABASE:-mydreamjob}"' 2>/dev/null \ |
|||
| head -n 1 |
|||
``` |
|||
|
|||
## T7. mysql argv 是否含密码 |
|||
|
|||
测试用假 `mysql` 记录自身真实 argv(它正是被检查的进程),实测结果形态为: |
|||
|
|||
``` |
|||
-u<应用账号> -N -B <数据库名> |
|||
``` |
|||
|
|||
| 断言 | 结果 | |
|||
|---|---| |
|||
| F5-1 mysql argv 不含数据库密码值 | PASS | |
|||
| F5-2 宿主机 docker 命令行不含密码值 | PASS | |
|||
| F5-3 容器侧脚本文本不含密码值 | PASS | |
|||
| F5-4 容器侧脚本以变量名引用 MYSQL_PASSWORD | PASS | |
|||
| F5-5 mysql argv 不含 `-p<密码>` | PASS | |
|||
| F5-6 mysql argv 不含 `--password` | PASS | |
|||
| F5-13 密码不经 argv 传递 | PASS | |
|||
| F5-14 密码经 `MYSQL_PWD` 环境变量传递 | PASS | |
|||
|
|||
## T8. 使用的数据库账号类型 |
|||
|
|||
- 使用**应用账号**(容器环境变量 `MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DATABASE`) |
|||
- **不使用 root**;`MYSQL_ROOT_PASSWORD` 在脚本中命中数为 0,`-uroot` 命中数为 0(F5-7 / F5-8 PASS) |
|||
- 凭据缺失时 `exit 3` 安全失败,**不回退 root**(F5-15 PASS) |
|||
- 未读取、未打印、未落盘凭据;未修改 compose.yaml;未修改 MySQL 用户权限;查询仅 `SELECT`(F5-11/F5-12 PASS) |
|||
|
|||
## T9. Shell 测试实际数量 |
|||
|
|||
`docs/tests/smoke-test-sms-code-test.sh`:**51 用例,PASS=51 / FAIL=0** |
|||
|
|||
构成:F1 取码保护 10 项 + F4 xtrace/源模式 14 项 + F5 argv 18 项 + 静态保证与文档门禁 9 项。 |
|||
|
|||
## T10. Maven 专项 / 关联 / 全量数量 |
|||
|
|||
| 命令 | Tests run | |
|||
|---|---| |
|||
| 7 类安全专项(含空值与回滚集成测试) | **61** | |
|||
| `*Auth*Test,*Security*Test,*Sms*Test,*Job*Test` | **227** | |
|||
| `mvn test` | **1671** | |
|||
| `mvn clean verify` | **1671** | |
|||
|
|||
## T11. Failures / Errors / Skipped |
|||
|
|||
**Failures=0、Errors=0、Skipped=0**(专项、关联、全量、clean verify 全部一致) |
|||
|
|||
## T12. clean verify |
|||
|
|||
`mvn clean verify` → **BUILD SUCCESS**(4:34),产出最新 JAR。 |
|||
|
|||
## T13. OpenAPI 数量 |
|||
|
|||
**173 paths / 211 operations**(`All checks passed!`);本轮未改 Controller 映射、未新增取码接口。 |
|||
|
|||
## T14. 精确修改文件(本轮) |
|||
|
|||
| 文件 | 变更 | |
|||
|---|---| |
|||
| `docs/smoke-test.sh` | 开头新增全局 xtrace 关闭(全程不恢复);删除 `xtrace_was_on`/恢复逻辑;`otp_db_query` 改为应用账号 + `MYSQL_PWD`(去 root、去 `-p`);`cleanup_secrets` 覆盖面扩展;新增 `report_fields`/`extract_status_fields`,登录响应不再交给打印函数 | |
|||
| `docs/tests/smoke-test-sms-code-test.sh` | 扩展至 51 用例:真实 `bash -x` 执行与标记扫描、假 docker 执行容器侧脚本 + 假 mysql 记录 argv、source 模式泄漏防护、活跃文件文档门禁 | |
|||
| `docs/s01-remediation-f1f2f3-20260911.md` | 新增本「第三轮定向整改」章节 | |
|||
|
|||
**未修改**:任何 Java 生产代码、任何既有测试、`docs/smoke-test.sh` 以外的文档正文、`.env`、compose.yaml、Flyway。 |
|||
|
|||
## T15. Git 状态 |
|||
|
|||
- 分支:`fix/resume-upload-safety`(未变) |
|||
- 暂存区:**空** |
|||
- `git diff --check`:无空白错误(仅 CRLF 提示) |
|||
- 迁移改动:**无**(V1–V41 共 41 个) |
|||
- 删除文件:**0**;历史未跟踪文件:**145 个全部保留** |
|||
- 本轮已跟踪文件改动仅 `docs/smoke-test.sh`(S01 Java 文件时间戳仍为 15:45,本轮未触碰) |
|||
|
|||
## T16. 是否修改 Flyway、表、ErrorCode 或接口 |
|||
|
|||
**均未修改**:无新增/修改迁移、无表/索引变更、`ErrorCode` 未改、OpenAPI 保持 173/211。 |
|||
|
|||
## T17. 明确未暂存、未 commit、未 push |
|||
|
|||
**未执行** `git add` / `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。 |
|||
|
|||
## T18. 真实 MySQL / Docker 最终复验仍待独立执行 |
|||
|
|||
本轮**未执行任何 Docker 命令**,**未连接真实 MySQL**: |
|||
|
|||
- F5 的 `docker exec` + `MYSQL_PWD` 路径仅在**假 docker / 假 mysql** 下验证了 argv 与 stdin 形态; |
|||
真实容器中 `MYSQL_USER` / `MYSQL_PASSWORD` 是否存在、`MYSQL_PWD` 是否被 mysql 客户端接受,**必须由独立复验在真实 Docker/MySQL 环境确认**。 |
|||
- F4 的 xtrace 防护已由真实 `bash -x` 本地执行验证(无需容器),但**完整真实登录链路**仍待真实环境复验。 |
|||
|
|||
## T19. 文档测试口径修正(§7) |
|||
|
|||
历史验收报告 `docs/s01-independent-mysql-acceptance-20260911.md` 与本报告**合理保留修复前缺陷证据**(含修复前的日志依赖描述与旧命令),因此**不得**以「整个 docs 目录命中数为 0」作为门禁结论。 |
|||
|
|||
修正后的口径(已落入 shell 测试第 22-24 条断言): |
|||
|
|||
- **A. 活跃操作文档与正式代码**(必须无「从日志取码」有效指导): |
|||
`docs/acceptance-week1.md`、`docs/api-v0.1.md`、`docs/frontend-integration.md`、`docs/offer-api.md`、`docs/resume-api.md`、`docs/smoke-test.sh`、`README.md`、`mydreamjob-server/src/main/java` |
|||
- **B. 历史验收报告**(允许保留修复前失败证据,须明示为历史): |
|||
`docs/s01-independent-mysql-acceptance-20260911.md`、`docs/s01-remediation-f1f2f3-20260911.md` |
|||
|
|||
测试使用**精确活跃文件列表**扫描(不扫描全部 docs),并额外断言两份历史报告仍然存在。 |
|||
|
|||
## T20. 本轮遗留(非阻断,待后续) |
|||
|
|||
1. JWT 仍作为 `-H "Authorization: Bearer <token>"` 的 curl 参数存在于**进程 argv**(非 xtrace/stdout/stderr 问题;如需消除,应改用 `curl --config` 经 stdin 传头) |
|||
2. 验证码仍为数据库明文存储(未改表结构) |
|||
3. 真实短信供应商未接入 |
|||
4. 存量 43 处直接身份解析未收口;安全层 401 响应不含 requestId |
|||
5. 上轮观察项(失败后残留 `sms:rate` 限流键 60s;Spring Boot 默认 `generated security password` 入日志)本轮未处理 |
|||
|
|||
--- |
|||
|
|||
**S01第三轮定向整改完成,待独立代码复验及真实MySQL/Docker黑盒复验。** |
|||
|
|||
--- |
|||
|
|||
# 第四轮定向整改(F6) |
|||
|
|||
- **整改日期**:2026-09-14 |
|||
- **独立复验结论**:S01 仍 FAIL —— F4 与 F5 已通过(bash -x 不泄露 / Shell 51 项 / Maven 安全专项 61 项 / 密码不进 mysql argv 均通过),新阻断项 **F6**:冒烟脚本使用了 `sms_verification_codes` 不存在的列 `expires_at` |
|||
- **本轮范围**:仅修 F6,并补真实表结构契约测试 |
|||
- **本轮修改文件**:`docs/smoke-test.sh`、`docs/tests/smoke-test-sms-code-test.sh`、`docs/tests/smoke-test-schema-contract-test.sh`(新增)、本报告 |
|||
- **本轮未触碰**:全部 Java 生产代码、实体、Repository、Flyway、数据库表、`ErrorCode`、公网接口 |
|||
|
|||
## G1. F6 错误位置 |
|||
|
|||
| 项 | 内容 | |
|||
|---|---| |
|||
| 文件 | `docs/smoke-test.sh` | |
|||
| 行 | **L122** | |
|||
| 修复前 | `" AND expires_at > NOW()" \` | |
|||
| 修复后 | `" AND expired_at > NOW()" \` | |
|||
| 后果 | 真实 MySQL 报 ERROR 1054,冒烟脚本取不到验证码,**F1 未闭环** | |
|||
|
|||
## G2. 真实 MySQL SHOW COLUMNS 结果摘要 |
|||
|
|||
独立复验在真实 MySQL 执行 `SHOW COLUMNS FROM sms_verification_codes`,实际字段为: |
|||
|
|||
``` |
|||
id phone code purpose attempts max_attempts expired_at used created_at |
|||
``` |
|||
|
|||
**不存在 `expires_at`。** 该结论与两个契约来源一致: |
|||
|
|||
| 来源 | 位置 | 结果 | |
|||
|---|---|---| |
|||
| 实体 `@Column` | `auth/SmsVerificationCode.java:46` | `@Column(name = "expired_at", nullable = false)` | |
|||
| 建表 Flyway | `db/migration/V1__base_schema.sql:37` | `expired_at DATETIME NOT NULL` | |
|||
|
|||
## G3. 修复前的真实报错 |
|||
|
|||
``` |
|||
ERROR 1054 (42S22): Unknown column 'expires_at' in 'where clause' |
|||
``` |
|||
|
|||
## G4. 修复后的 SQL |
|||
|
|||
```sql |
|||
SELECT code FROM sms_verification_codes |
|||
WHERE phone = '<phone>' AND purpose = '<purpose>' AND used = 0 |
|||
AND expired_at > NOW() |
|||
ORDER BY created_at DESC, id DESC LIMIT 1; |
|||
``` |
|||
|
|||
除列名外**未改动任何查询口径**,以下均保持不变:phone 精确匹配、purpose 精确匹配、`used = 0`、`expired_at > NOW()`、`ORDER BY created_at DESC, id DESC`、`LIMIT 1`、仅 SELECT、使用应用数据库账号(非 root)、密码经 `MYSQL_PWD` 不进 argv、SQL 经 stdin、验证码不输出。 |
|||
|
|||
## G5. 新增的 Shell 断言 |
|||
|
|||
`docs/tests/smoke-test-sms-code-test.sh` 由 **51 项扩展至 69 项**,新增 Part 6 共 **18 条**: |
|||
|
|||
| 编号 | 断言 | |
|||
|---|---| |
|||
| F6-1 | `otp_db_query` 使用 `expired_at > NOW()` | |
|||
| F6-2 | `otp_db_query` 不再出现错误字段 `expires_at` | |
|||
| F6-3 | 查询只使用 `sms_verification_codes` 的真实列 | |
|||
| F6-4 | 真实列清单由 Flyway 派生且含 `expired_at`(非空、非自证) | |
|||
| F6-5 | 假 mysql 捕获到完整 stdin SQL(非无条件返回验证码) | |
|||
| F6-6 | SQL 字段错误时假 mysql 返回失败(非 0) | |
|||
| F6-6b | 字段错误时报 ERROR 1054 (42S22) | |
|||
| F6-7 | 字段错误时 `sms_code()` 不继续登录 | |
|||
| F6-7b | 字段错误时未发出任何 login 请求 | |
|||
| F6-8 | 正确字段时取码成功并完成登录 | |
|||
| F6-9 | `expired_at` 恰好等于 NOW() 时不得取出 | |
|||
| F6-10 | `expired_at` 晚于 NOW() 时可以取出 | |
|||
| F6-11 | `expired_at` 早于 NOW() 时不得取出 | |
|||
| F6-12 | `used=1` 不得取出 | |
|||
| F6-13 | `purpose` 不匹配不得取出 | |
|||
| F6-14 | `phone` 不匹配不得取出 | |
|||
| F6-15 | 多条有效记录只取 `created_at`/`id` 最新一条 | |
|||
| F6-15b | 与输入顺序无关,按 `created_at`/`id` 排序取值 | |
|||
|
|||
**另新增独立契约门禁** `docs/tests/smoke-test-schema-contract-test.sh`(**15 条**),契约来源为**实体 `@Column` + 建表 Flyway**,并交叉校验二者列集合一致: |
|||
|
|||
C1–C4 来源存在性与解析非空 → C5 两个独立来源列集合一致 → C6 `expired_at` 存在 → C7 **`expires_at` 不存在**(表限定)→ C8 实体映射存在 → C9 `expires_at` 确实是其他表的真实列 → C10–C15 脚本 SQL 的表名、列存在性、列不含 `expires_at`、所用列全部真实。 |
|||
|
|||
## G6. fake docker / fake mysql 是否校验 SQL |
|||
|
|||
**本轮之前:不校验。** 旧假 mysql 只是 `cat` 掉 stdin 后无条件 `printf "${FAKE_CODE}"`,因此错误的 `expires_at` 也能"取到"验证码并通过测试——这正是 F6 能逃过 51 项测试的原因。 |
|||
|
|||
**本轮之后:校验。** 假 mysql 改为: |
|||
|
|||
1. 完整读取并保存 stdin SQL(不再丢弃); |
|||
2. 若 SQL 命中 `sms_verification_codes` 且包含 `expires_at` → 如实复现真实 MySQL 的 `ERROR 1054 (42S22)` 并 `exit 1`; |
|||
3. 设置 `FAKE_ROWS` 时,按行套用 `WHERE phone/purpose/used` 与 `expired_at > NOW()` 语义、按 `created_at DESC, id DESC` 取值;未设置时保留历史行为供 F1/F5 既有用例使用。 |
|||
|
|||
## G7. 过期 / 有效 / used / purpose / phone 口径 |
|||
|
|||
| 场景 | 假表语义 | 期望 | 结果 | |
|||
|---|---|---|---| |
|||
| `expired_at` 恰好 = NOW() | offset = 0 | 不取出(查询为严格 `>`) | PASS | |
|||
| `expired_at` 晚于 NOW() | offset = +300 | 取出 | PASS | |
|||
| `expired_at` 早于 NOW() | offset = −300 | 不取出 | PASS | |
|||
| `used = 1` | — | 不取出 | PASS | |
|||
| `purpose` 不匹配 | 行 purpose=`register`,查 `login` | 不取出 | PASS | |
|||
| `phone` 不匹配 | 行 phone 为另一号码 | 不取出 | PASS | |
|||
| 多条有效 | 两条均有效,`created_at` 不同 | 只取最新一条 | PASS | |
|||
|
|||
## G8. F4 / F5 回归结果 |
|||
|
|||
全部保持通过(本轮未改动 xtrace 防护与 `MYSQL_PWD` 路径): |
|||
|
|||
| 回归项 | 结果 | |
|||
|---|---| |
|||
| `bash -x` 不泄露验证码 / 完整手机号 / JWT / Authorization / 数据库密码 / LOGIN_BODY | PASS(F4-1 ~ F4-6) | |
|||
| `bash -x` 下正常流程仍可完成登录 | PASS(F4-7、F4-8) | |
|||
| 脚本中不存在可执行的 `set -x` | PASS(F4-9) | |
|||
| 失败流程同样不泄露 | PASS(F4-10) | |
|||
| source 模式 REPLY 契约 / 无敏感变量残留 / EXIT trap | PASS(F4-11 ~ F4-14) | |
|||
| mysql argv 不含密码值、不含 `-p`/`--password` | PASS(F5-1 ~ F5-6) | |
|||
| 不使用 root / 使用应用账号与数据库名 | PASS(F5-7 ~ F5-10) | |
|||
| SQL 经 stdin 且仅 SELECT | PASS(F5-11、F5-12) | |
|||
| 密码经 `MYSQL_PWD` 传递、不经 argv | PASS(F5-13、F5-14) | |
|||
| 凭据缺失时安全失败(不回退 root) | PASS(F5-15) | |
|||
| 失败路径 stderr 不含数据库密码 | PASS(F5-18) | |
|||
| 不恢复 `MOCK SMS CODE` 日志、活跃文档不指导从日志取码 | PASS(14、22、23) | |
|||
|
|||
## G9. Shell 测试数量 |
|||
|
|||
| 文件 | 用例数 | 结果 | |
|||
|---|---|---| |
|||
| `docs/tests/smoke-test-sms-code-test.sh` | **69** | PASS=69 / FAIL=0 | |
|||
| `docs/tests/smoke-test-schema-contract-test.sh` | **15** | PASS=15 / FAIL=0 | |
|||
|
|||
合计 **84** 条,全部通过(真实 Git Bash 执行)。 |
|||
|
|||
**断言非空转的证据(测试自检)**:把 `smoke-test.sh` 还原为 buggy 版本(`expires_at`)放入镜像目录,用**同一套测试**执行: |
|||
|
|||
- `smoke-test-sms-code-test.sh` → `PASS=56 FAIL=13`,其中 **F6-1 / F6-2 / F6-3 翻红**,F6-3 直接点名不存在的列 `expires_at`;F6-8/10/15/15b 因取不到码一并失败; |
|||
- `smoke-test-schema-contract-test.sh` → `PASS=12 FAIL=3`,**C12 / C13 / C14 翻红**,C14 点名 `expires_at`。 |
|||
|
|||
即:本轮断言确实能识别**不存在的 SQL 列**,而非"改前改后都通过"。 |
|||
|
|||
## G10. Maven 专项 / 关联 / 全量测试数 |
|||
|
|||
| 命令 | Tests run | |
|||
|---|---| |
|||
| `-Dtest="SmsSenderConfigurationTest,SmsVerificationSecurityTest,SmsVerificationServiceTest,CurrentUserAccessorTest,AuthenticationBoundaryIntegrationTest,SmsEmptyPropertyStartupIntegrationTest,SmsUnavailableSenderRollbackIntegrationTest"` | **61** | |
|||
| `-Dtest="*Auth*Test,*Security*Test,*Sms*Test,*Job*Test"` | **227** | |
|||
| `mvn test` | **1671** | |
|||
| `mvn clean verify` | **1671** | |
|||
|
|||
本轮仅改 `docs/` 下的脚本与报告,未增删任何 Java 测试,故总数与上一轮一致(1671)。 |
|||
|
|||
## G11. Failures / Errors / Skipped |
|||
|
|||
**Failures=0、Errors=0、Skipped=0**(专项、关联、全量、clean verify 四者一致)。 |
|||
|
|||
## G12. clean verify |
|||
|
|||
`mvn clean verify` → **BUILD SUCCESS**(Total time 02:11),产出 `target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。 |
|||
|
|||
## G13. OpenAPI 计数 |
|||
|
|||
`python docs/verify_openapi.py` → **All checks passed!**,**173 paths / 211 operations**(与上一轮一致,未新增/删除路径或 operation)。 |
|||
|
|||
## G14. Git 状态 |
|||
|
|||
- 分支:`fix/resume-upload-safety`(未变) |
|||
- **暂存区:空** |
|||
- `git diff --check`:无空白错误(仅 CRLF 提示) |
|||
- 迁移改动:**无**(`git diff --name-only -- mydreamjob-server/src/main/resources/db/migration` 无输出;目录内 41 个文件,最大版本 `V41__message_recall.sql`) |
|||
- 本轮文件时间戳:`docs/smoke-test.sh` 09:22、`smoke-test-sms-code-test.sh` 09:24、`smoke-test-schema-contract-test.sh` 09:27、本报告 09:32(均 2026-09-14) |
|||
- **S01 Java 生产代码时间戳仍为 2026-09-11 15:45**,本轮未触碰 |
|||
- 删除文件:**0**;历史未跟踪文件:未删除、未移动、未覆盖 |
|||
|
|||
## G15. 明确未修改 Java、Flyway、表、ErrorCode 或接口 |
|||
|
|||
**均未修改。** |
|||
|
|||
- Java 生产代码:**0 处改动**(时间戳可证) |
|||
- 实体与 Repository:未改 |
|||
- Flyway:未新增/修改,V1–V41 原样 |
|||
- 数据库表/列/索引:未新增或修改;**未为迁就脚本把列改成 `expires_at`** |
|||
- `ErrorCode`:未改 |
|||
- 公网接口:OpenAPI 保持 173/211,未新增取码接口 |
|||
- **未恢复验证码日志**(`grep -rn "MOCK SMS CODE" mydreamjob-server/src` 命中 0) |
|||
|
|||
## G16. 明确未暂存、commit、push |
|||
|
|||
**未执行** `git add` / `git add -A`、**未 commit**、**未 push**(亦未 `reset`/`restore`/`checkout`/`clean`)。**未操作 Docker**,未进入 S02。 |
|||
|
|||
## G17. 真实 MySQL 完整冒烟链仍待最终独立复验 |
|||
|
|||
本轮**未执行任何 Docker 命令、未连接真实 MySQL**。全部验证均为:shell 测试(假 docker / 假 mysql 注入)+ Maven H2/Flyway 测试。 |
|||
|
|||
因此以下**必须由独立复验在真实 MySQL/Docker 环境完成**: |
|||
|
|||
1. `SHOW COLUMNS FROM sms_verification_codes` 的 Field 集合与 Flyway 列集合对账(含 `expired_at`、不含 `expires_at`); |
|||
2. 修复后的取码 SQL 在真实 MySQL 端到端执行成功(不再出现 ERROR 1054); |
|||
3. F1 完整冒烟链(发码 → 取码 → 登录 → 后续接口)闭环。 |
|||
|
|||
**不得以本轮假库结果代替真实环境验收。** |
|||
|
|||
## G18. 一处必须记录的旁证(不在本轮修改范围) |
|||
|
|||
`docs/acceptance/s01-20260911/snapshot-sms.sql:4` 同样使用 `expires_at` 查询 `sms_verification_codes`: |
|||
|
|||
```sql |
|||
SELECT id, purpose, CHAR_LENGTH(code) AS code_len, |
|||
TIMESTAMPDIFF(SECOND, NOW(), expires_at) AS expire_delta_s, ... |
|||
FROM sms_verification_codes |
|||
``` |
|||
|
|||
这是同一字段错误的传播。该文件属上一轮独立验收证据,**不在本轮允许修改的文件范围内**,故保持原样未改,仅在此记录,供后续处理。 |
|||
|
|||
## G19. 字段名易混淆的说明(供后续维护) |
|||
|
|||
`expires_at` 并非拼写错误,它是本仓库中**三张表的真实列**: |
|||
|
|||
| 表 | 迁移 | |
|||
|---|---| |
|||
| `personal_data_export_tasks.expires_at` | V30 | |
|||
| `ability_attempts.expires_at` | V35 | |
|||
| `talent_invitations.expires_at`(含索引) | V40 | |
|||
|
|||
只有 `sms_verification_codes` 用 `expired_at`。因此本轮门禁**按表限定**,未写成"全库禁用 `expires_at`"——那样会误报并掩盖真实错误。契约门禁 C9 专门守卫这一区分。 |
|||
|
|||
--- |
|||
|
|||
**S01第四轮定向整改完成,待最终独立代码复验及真实MySQL/Docker黑盒复验。** |
|||
@ -0,0 +1,144 @@ |
|||
#!/usr/bin/env bash |
|||
# S01 第四轮 F6 —— sms_verification_codes 表结构契约门禁 |
|||
# |
|||
# 目的:防止冒烟脚本再次使用**不存在的列**(F6:expires_at), |
|||
# 且不允许测试自己维护一份与生产无关的假字段列表来自证正确。 |
|||
# |
|||
# 契约来源(本脚本同时使用前两个,并交叉校验二者一致): |
|||
# 1. 实体 @Column:mydreamjob-server/.../auth/SmsVerificationCode.java |
|||
# 2. 建表 Flyway:mydreamjob-server/.../db/migration/V1__base_schema.sql |
|||
# 3. 真实 MySQL SHOW COLUMNS —— 属**真实验收阶段**,本脚本只输出对账命令, |
|||
# 不在自动化阶段伪造该来源。 |
|||
# |
|||
# 重要:expires_at 是 personal_data_export_tasks(V30) / ability_attempts(V35) / |
|||
# talent_invitations(V40) 三张表的**真实列**。因此本门禁只对 |
|||
# sms_verification_codes 这一个表断言"不存在 expires_at", |
|||
# 不得写成全库禁令(那会误报,并掩盖真正的字段错误)。 |
|||
# |
|||
# 运行: bash docs/tests/smoke-test-schema-contract-test.sh |
|||
set -u |
|||
|
|||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" |
|||
SMOKE="$REPO_ROOT/docs/smoke-test.sh" |
|||
ENTITY="$REPO_ROOT/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationCode.java" |
|||
MIGRATION_DIR="$REPO_ROOT/mydreamjob-server/src/main/resources/db/migration" |
|||
FLYWAY="$MIGRATION_DIR/V1__base_schema.sql" |
|||
TABLE="sms_verification_codes" |
|||
|
|||
PASS=0 |
|||
FAIL=0 |
|||
ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); } |
|||
bad() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); } |
|||
check() { if [ "$1" = "1" ]; then ok "$2"; else bad "$2"; fi; } |
|||
|
|||
echo "=== S01 F6: sms_verification_codes 表结构契约门禁 ===" |
|||
echo "实体来源 : $ENTITY" |
|||
echo "Flyway来源: $FLYWAY" |
|||
echo "脚本 : $SMOKE" |
|||
echo "" |
|||
|
|||
# ── 契约来源 1:实体 @Column ────────────────────────────────── |
|||
entity_cols="" |
|||
if [ -f "$ENTITY" ]; then |
|||
# 主键 id 在实体中以 @Id 标注(无 @Column(name=...)),单独补入 |
|||
grep -qE '^[[:space:]]*@Id[[:space:]]*$' "$ENTITY" && entity_cols="id" |
|||
entity_cols="$entity_cols $(grep -oE '@Column\(name[[:space:]]*=[[:space:]]*"[A-Za-z0-9_]+"' "$ENTITY" \ |
|||
| sed 's/.*"\(.*\)"/\1/')" |
|||
entity_cols="$(printf '%s' "$entity_cols" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ')" |
|||
fi |
|||
|
|||
# ── 契约来源 2:建表 Flyway ─────────────────────────────────── |
|||
flyway_cols="" |
|||
if [ -f "$FLYWAY" ]; then |
|||
flyway_cols="$(awk -v t="$TABLE" ' |
|||
$0 ~ "^CREATE TABLE (IF NOT EXISTS )?" t "([[:space:]]|\\()" { in_t=1; next } |
|||
in_t && /^\)/ { in_t=0 } |
|||
in_t { |
|||
line=$0 |
|||
sub(/^[[:space:]]+/, "", line) |
|||
if (line ~ /^(INDEX|KEY|PRIMARY|CONSTRAINT|UNIQUE|FOREIGN)/) next |
|||
if (line ~ /^[A-Za-z0-9_]+[[:space:]]/) { split(line, a, /[[:space:]]/); print a[1] } |
|||
} |
|||
' "$FLYWAY" | sort -u | tr '\n' ' ')" |
|||
fi |
|||
|
|||
check "$([ -f "$ENTITY" ] && echo 1 || echo 0)" "C1. 实体文件存在" |
|||
check "$([ -f "$FLYWAY" ] && echo 1 || echo 0)" "C2. 建表 Flyway 存在" |
|||
check "$([ -n "$entity_cols" ] && echo 1 || echo 0)" "C3. 实体 @Column 解析出非空列集合" |
|||
check "$([ -n "$flyway_cols" ] && echo 1 || echo 0)" "C4. Flyway 解析出非空列集合" |
|||
|
|||
# 两个独立来源必须一致(否则说明解析失败或生产定义本身不一致) |
|||
check "$([ "$(printf '%s' "$entity_cols" | tr ' ' '\n' | grep -c .)" -gt 0 ] \ |
|||
&& [ "$entity_cols" = "$flyway_cols" ] && echo 1 || echo 0)" \ |
|||
"C5. 实体 @Column 与建表 Flyway 的列集合一致" |
|||
|
|||
echo " 实体列 : ${entity_cols:-<空>}" |
|||
echo " Flyway列: ${flyway_cols:-<空>}" |
|||
echo "" |
|||
|
|||
# ── 字段存在性(表限定)─────────────────────────────────────── |
|||
check "$(case " $flyway_cols " in *" expired_at "*) echo 1 ;; *) echo 0 ;; esac)" \ |
|||
"C6. sms_verification_codes 存在 expired_at 列" |
|||
check "$(case " $flyway_cols " in *" expires_at "*) echo 0 ;; *) echo 1 ;; esac)" \ |
|||
"C7. sms_verification_codes **不存在** expires_at 列" |
|||
check "$(case " $entity_cols " in *" expired_at "*) echo 1 ;; *) echo 0 ;; esac)" \ |
|||
"C8. 实体 expired_at 映射存在" |
|||
|
|||
# ── 反向护栏:expires_at 确实是别的表的真实列 ────────────────── |
|||
# 若此条失败,说明有人把它当成了"全库不存在的列"——那会让本门禁退化成误报。 |
|||
other_tables_with_expires_at=0 |
|||
for f in "$MIGRATION_DIR"/V30__personal_data_export_tasks.sql \ |
|||
"$MIGRATION_DIR"/V35__ability_assessment.sql \ |
|||
"$MIGRATION_DIR"/V40__enterprise_talent_invitations.sql; do |
|||
[ -f "$f" ] || continue |
|||
grep -qE '^[[:space:]]*expires_at[[:space:]]' "$f" && other_tables_with_expires_at=$((other_tables_with_expires_at + 1)) |
|||
done |
|||
check "$([ "$other_tables_with_expires_at" -gt 0 ] && echo 1 || echo 0)" \ |
|||
"C9. expires_at 确实是其他表的真实列(故门禁必须按表限定,不得全局禁用)" |
|||
|
|||
# ── 脚本侧:表名与列名 ──────────────────────────────────────── |
|||
sql_block="$(awk '/SELECT code FROM sms_verification_codes/,/LIMIT 1;/' "$SMOKE" 2>/dev/null)" |
|||
# 只保留双引号内的 SQL 片段,剥掉 shell 语法(printf / 引号 / 续行反斜杠), |
|||
# 否则 printf、%s 之类会被误当成列名。 |
|||
sql_text="$(printf '%s' "$sql_block" | grep -oE '"[^"]*"' | tr -d '"' | tr '\n' ' ')" |
|||
check "$([ -n "$sql_text" ] && echo 1 || echo 0)" "C10. 能从 smoke-test.sh 提取取码 SQL 文本" |
|||
|
|||
check "$(printf '%s' "$sql_text" | grep -q "FROM $TABLE" && echo 1 || echo 0)" \ |
|||
"C11. 取码 SQL 查询表为 $TABLE" |
|||
check "$(printf '%s' "$sql_text" | grep -q 'expired_at' && echo 1 || echo 0)" \ |
|||
"C12. 取码 SQL 使用 expired_at" |
|||
check "$(printf '%s' "$sql_text" | grep -q 'expires_at' && echo 0 || echo 1)" \ |
|||
"C13. 取码 SQL **不含** expires_at" |
|||
|
|||
# 脚本 SQL 使用的每个列名都必须落在真实列集合内 |
|||
used_cols="$(printf '%s' "$sql_text" \ |
|||
| sed "s/'[^']*'//g" \ |
|||
| grep -oE '\b[A-Za-z_][A-Za-z0-9_]*\b' \ |
|||
| grep -viE '^(SELECT|code|FROM|WHERE|AND|OR|ORDER|BY|DESC|ASC|LIMIT|NOW)$|^'$TABLE'$' \ |
|||
| sort -u | tr '\n' ' ')" |
|||
unknown="" |
|||
for c in $used_cols; do |
|||
case " $flyway_cols " in |
|||
*" $c "*) ;; |
|||
*) unknown="$unknown $c" ;; |
|||
esac |
|||
done |
|||
check "$([ -z "$unknown" ] && echo 1 || echo 0)" \ |
|||
"C14. 取码 SQL 使用的列全部存在于 $TABLE${unknown:+(不存在的列:$unknown)}" |
|||
|
|||
# 取码列 code 必须在真实列集合内(正向确认解析没跑偏) |
|||
check "$(case " $flyway_cols " in *" code "*) echo 1 ;; *) echo 0 ;; esac)" \ |
|||
"C15. 取码 SQL 的列 code 是 $TABLE 的真实列" |
|||
|
|||
echo "" |
|||
echo "── 真实验收阶段必须执行(本脚本不伪造该来源)───────────────" |
|||
echo "docker exec -i <mysql容器> sh -c \\" |
|||
echo " 'MYSQL_PWD=\"\$MYSQL_PASSWORD\" mysql -u\"\$MYSQL_USER\" -N -B \"\${MYSQL_DATABASE:-mydreamjob}\" <<<\"SHOW COLUMNS FROM $TABLE;\"'" |
|||
echo "对账要求:SHOW COLUMNS 的 Field 集合必须等于上方 Flyway列;" |
|||
echo " 必须包含 expired_at;必须不含 expires_at。" |
|||
echo "" |
|||
|
|||
echo "============================================" |
|||
echo "PASS=$PASS FAIL=$FAIL" |
|||
echo "============================================" |
|||
[ "$FAIL" -eq 0 ] |
|||
@ -0,0 +1,548 @@ |
|||
#!/usr/bin/env bash |
|||
# S01 定向整改 F1 / F4 / F5 —— smoke-test.sh 敏感信息泄漏防护测试 |
|||
# |
|||
# 覆盖: |
|||
# F1 取码机制(双重保护 / 不依赖应用日志 / 格式校验 / 清理) |
|||
# F4 xtrace 泄漏:**真实以 `bash -x` 执行被测脚本**,捕获 stdout+stderr 并扫描敏感标记 |
|||
# F4 source 模式不把敏感变量带回调用方 |
|||
# F5 数据库密码不进入 mysql argv(真实执行容器侧脚本文本,检查假 mysql 的实际 argv) |
|||
# §7 文档门禁只针对**活跃文件列表**,不对历史验收报告做全 docs 扫描 |
|||
# |
|||
# 测试使用假的 docker / curl / mysql(PATH 注入),不要求 Docker 或真实 MySQL。 |
|||
# 仓库内不写入真实验证码、JWT 或数据库口令;测试结束扫描输出确认标记未泄露。 |
|||
# |
|||
# 运行: bash docs/tests/smoke-test-sms-code-test.sh |
|||
set -u |
|||
|
|||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" |
|||
SMOKE="$REPO_ROOT/docs/smoke-test.sh" |
|||
|
|||
# ── 固定测试标记(均为假值,非真实凭据)───────────────────────── |
|||
TEST_CODE="739251" |
|||
TEST_PHONE="19900009999" |
|||
TEST_JWT="FAKEJWT_MARKER_QA7X" |
|||
TEST_DBPASS="FAKEDBPASS_MARKER_QA7X" |
|||
MARKER_MSG="test OTP retrieval is disabled" |
|||
|
|||
TMP="$(mktemp -d)" |
|||
FAKEBIN="$TMP/bin" |
|||
mkdir -p "$FAKEBIN" |
|||
export FAKE_DIR="$TMP" |
|||
trap 'rm -rf "$TMP"' EXIT |
|||
|
|||
PASS=0 |
|||
FAIL=0 |
|||
ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); } |
|||
bad() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); } |
|||
check() { if [ "$1" = "1" ]; then ok "$2"; else bad "$2"; fi; } |
|||
|
|||
# ── 假 docker:真实执行被传入的容器侧 sh -c 脚本(含假 mysql)────── |
|||
cat > "$FAKEBIN/docker" <<'FAKEDOCKER' |
|||
#!/usr/bin/env bash |
|||
# 记录完整 argv(用于 F5 断言:宿主机侧命令行不得含密码) |
|||
printf '%s\n' "$*" >> "${FAKE_DIR:?}/docker-argv.log" |
|||
script="" |
|||
for a in "$@"; do script="$a"; done |
|||
# 记录容器侧脚本文本(变量名可以出现,值不可以) |
|||
printf '%s\n' "$script" > "${FAKE_DIR}/container-script.txt" |
|||
case "${FAKE_DOCKER_MODE:-ok}" in |
|||
fail) exit 1 ;; |
|||
empty) cat >/dev/null 2>&1 || true; exit 0 ;; |
|||
garbage) cat >/dev/null 2>&1 || true; printf '%s\n' "${FAKE_GARBAGE:-not-a-code}"; exit 0 ;; |
|||
short) cat >/dev/null 2>&1 || true; printf '%s\n' "12345"; exit 0 ;; |
|||
esac |
|||
# 真实以本地 sh 执行容器侧脚本,使假 mysql 成为真正被检查 argv 的进程 |
|||
exec sh -c "$script" |
|||
FAKEDOCKER |
|||
chmod +x "$FAKEBIN/docker" |
|||
|
|||
# ── 假 mysql:记录 argv 与 stdin(SQL),并按 sms_verification_codes 真实语义作答 ── |
|||
# 它**不是**无条件返回验证码:先校验 SQL 使用的列。 |
|||
# 真实 MySQL 对未知列返回 ERROR 1054 (42S22),这里如实复现, |
|||
# 使脚本里字段名写错(例如 expires_at)无法被"总能拿到验证码"掩盖。 |
|||
# |
|||
# FAKE_ROWS 未设置 → 保留历史行为(输出 FAKE_CODE),供 F1/F5 既有用例使用。 |
|||
# FAKE_ROWS 设置 → 按行套用 WHERE / ORDER BY 语义作答。行格式: |
|||
# id|phone|purpose|used|expire_offset_s|created_at|code |
|||
# expire_offset_s > 0 表示 expired_at 晚于 NOW();查询用严格大于,故 =0 不命中。 |
|||
cat > "$FAKEBIN/mysql" <<'FAKEMYSQL' |
|||
#!/usr/bin/env bash |
|||
printf '%s\n' "$*" > "${FAKE_DIR:?}/mysql-argv.log" |
|||
sql="$(cat)" |
|||
printf '%s' "$sql" > "${FAKE_DIR}/mysql-stdin.sql" |
|||
|
|||
# ── 表结构契约检查:sms_verification_codes 的真实列是 expired_at ── |
|||
case "$sql" in |
|||
*sms_verification_codes*) |
|||
case "$sql" in |
|||
*expires_at*) |
|||
printf "ERROR 1054 (42S22): Unknown column 'expires_at' in 'where clause'\n" >&2 |
|||
exit 1 |
|||
;; |
|||
esac |
|||
;; |
|||
esac |
|||
|
|||
# 未提供假数据:保持历史行为 |
|||
if [ -z "${FAKE_ROWS:-}" ]; then |
|||
printf '%s\n' "${FAKE_CODE:-}" |
|||
exit 0 |
|||
fi |
|||
|
|||
# 从 SQL 解析查询参数(形态与应用脚本一致) |
|||
phone="$(printf '%s' "$sql" | sed -n "s/.*phone *= *'\([^']*\)'.*/\1/p" | head -n 1)" |
|||
purpose="$(printf '%s' "$sql" | sed -n "s/.*purpose *= *'\([^']*\)'.*/\1/p" | head -n 1)" |
|||
|
|||
# 逐行套用 WHERE 语义,按 created_at DESC, id DESC 取第一条 |
|||
best_key="" best_code="" |
|||
while IFS='|' read -r rid rphone rpurpose rused roffset rcreated rcode; do |
|||
[ -n "${rid:-}" ] || continue |
|||
[ "$rphone" = "$phone" ] || continue |
|||
[ "$rpurpose" = "$purpose" ] || continue |
|||
[ "$rused" = "0" ] || continue |
|||
case "$roffset" in |
|||
''|*[!0-9-]*) continue ;; |
|||
esac |
|||
[ "$roffset" -gt 0 ] || continue |
|||
key="$(printf '%s|%010d' "$rcreated" "$rid")" |
|||
if [ -z "$best_key" ] || [[ "$key" > "$best_key" ]]; then |
|||
best_key="$key" best_code="$rcode" |
|||
fi |
|||
done <<< "${FAKE_ROWS}" |
|||
|
|||
[ -n "$best_code" ] && printf '%s\n' "$best_code" |
|||
exit 0 |
|||
FAKEMYSQL |
|||
chmod +x "$FAKEBIN/mysql" |
|||
|
|||
# ── 假 curl:记录请求;登录响应含固定测试 JWT ──────────────────── |
|||
cat > "$FAKEBIN/curl" <<'FAKECURL' |
|||
#!/usr/bin/env bash |
|||
url=""; want_stdin=0 |
|||
for a in "$@"; do |
|||
case "$a" in |
|||
http*) url="$a" ;; |
|||
--data-binary) want_stdin=1 ;; |
|||
*@-) want_stdin=1 ;; |
|||
esac |
|||
done |
|||
has_code="no" |
|||
if [ "$want_stdin" = "1" ]; then |
|||
body="$(cat)" |
|||
case "$body" in |
|||
*[0-9][0-9][0-9][0-9][0-9][0-9]*) has_code="yes" ;; |
|||
esac |
|||
fi |
|||
case "$url" in |
|||
*"/auth/login"*) |
|||
echo "login-attempted has6digit=$has_code" >> "${FAKE_DIR:?}/flow.log" |
|||
printf '{"code":200,"data":{"accessToken":"%s"}}\n200\n' "${FAKE_JWT:-FAKEJWT}" ;; |
|||
*"/auth/sms/send"*) printf '{}\n200\n' ;; |
|||
*"/health"*) printf '{"code":200}\n200\n' ;; |
|||
*"/notifications/unread-count"*) printf '{"unreadCount":1}\n200\n' ;; |
|||
*"/notifications/"*) printf '{"message":"marked as read"}\n200\n' ;; |
|||
*"/notifications"*) printf '{"items":[{"id":88}]}\n200\n' ;; |
|||
*"/applications/"*) printf '{"status":"VIEWED"}\n200\n' ;; |
|||
*"/applications"*) printf '{"id":77}\n200\n' ;; |
|||
*"/jobs/public/"*) printf '{"title":"x"}\n200\n' ;; |
|||
*"/match"*) printf '{"rulesVersion":"v1"}\n200\n' ;; |
|||
*"/jobs"*) printf '{"items":[{"id":11,"title":"x"}]}\n200\n' ;; |
|||
*) printf '{}\n200\n' ;; |
|||
esac |
|||
exit 0 |
|||
FAKECURL |
|||
chmod +x "$FAKEBIN/curl" |
|||
export PATH="$FAKEBIN:$PATH" |
|||
export FAKE_JWT="$TEST_JWT" |
|||
|
|||
# 数据库凭据标记:让容器侧脚本的凭据前置检查通过, |
|||
# 同时用固定标记值验证它绝不出现在 argv / stdout / stderr / 文件中 |
|||
export MYSQL_USER="smoke_readonly" |
|||
export MYSQL_PASSWORD="$TEST_DBPASS" |
|||
export MYSQL_DATABASE="mydreamjob" |
|||
|
|||
# 扫描给定文本是否含任一敏感标记;输出命中的标记名(不含值本身) |
|||
scan_hits() { |
|||
local text="$1" hits="" |
|||
case "$text" in *"$TEST_CODE"*) hits="$hits CODE" ;; esac |
|||
case "$text" in *"$TEST_PHONE"*) hits="$hits PHONE" ;; esac |
|||
case "$text" in *"$TEST_JWT"*) hits="$hits JWT" ;; esac |
|||
case "$text" in *"$TEST_DBPASS"*) hits="$hits DBPASS" ;; esac |
|||
case "$text" in *"Authorization: Bearer"*) hits="$hits AUTH" ;; esac |
|||
case "$text" in *"LOGIN_BODY="*) hits="$hits LOGIN_BODY" ;; esac |
|||
printf '%s' "$hits" |
|||
} |
|||
|
|||
echo "=== S01 F1/F4/F5: smoke-test.sh 敏感信息泄漏防护测试 ===" |
|||
echo "smoke-test.sh: $SMOKE" |
|||
echo "固定测试标记已加载(值不打印)。" |
|||
echo "" |
|||
|
|||
# ══════════════════════════════════════════════════════════════ |
|||
# Part 1 — F1 取码保护(进程内,可观测 REPLY) |
|||
# ══════════════════════════════════════════════════════════════ |
|||
run_sms_code() { |
|||
local mock="$1" allow="$2" mode="$3" garbage="${4:-}" |
|||
local out |
|||
out="$(cd "$REPO_ROOT" && bash -c ' |
|||
set -u |
|||
SMOKE_TEST_LIB_ONLY=1 |
|||
export SMOKE_TEST_LIB_ONLY |
|||
if [ "$1" != "__UNSET__" ]; then export SMS_MOCK_ENABLED="$1"; else unset SMS_MOCK_ENABLED; fi |
|||
if [ "$2" != "__UNSET__" ]; then export ALLOW_TEST_OTP_DB_READ="$2"; else unset ALLOW_TEST_OTP_DB_READ; fi |
|||
export FAKE_DOCKER_MODE="$3" |
|||
export FAKE_CODE="'"$TEST_CODE"'" |
|||
export FAKE_GARBAGE="$4" |
|||
# shellcheck disable=SC1090 |
|||
. "'"$SMOKE"'" |
|||
if sms_code; then |
|||
if [ -n "${REPLY:-}" ]; then printf "RESULT=ok\n"; else printf "RESULT=ok_but_empty\n"; fi |
|||
unset REPLY |
|||
else |
|||
printf "RESULT=fail\n" |
|||
fi |
|||
' _ "$mock" "$allow" "$mode" "$garbage" 2>&1)" |
|||
printf '%s' "$out" |
|||
} |
|||
|
|||
# ── 以指定假表数据运行 sms_code,并比对取回的验证码 ───────────── |
|||
# 只输出 RESULT=ok/fail 与 MATCH=yes/no,**绝不输出验证码本身**。 |
|||
run_sms_code_with_rows() { |
|||
local rows="$1" expect="$2" |
|||
local out |
|||
out="$(cd "$REPO_ROOT" && bash -c ' |
|||
set -u |
|||
export SMOKE_TEST_LIB_ONLY=1 |
|||
export SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE=ok |
|||
export FAKE_ROWS="$1" |
|||
export STUDENT_PHONE="'"$TEST_PHONE"'" |
|||
export MYSQL_USER="smoke_readonly" MYSQL_PASSWORD="$2" MYSQL_DATABASE="mydreamjob" |
|||
# shellcheck disable=SC1090 |
|||
. "'"$SMOKE"'" |
|||
if sms_code; then |
|||
if [ -n "${REPLY:-}" ]; then |
|||
printf "RESULT=ok\n" |
|||
if [ "${REPLY}" = "$3" ]; then printf "MATCH=yes\n"; else printf "MATCH=no\n"; fi |
|||
else |
|||
printf "RESULT=ok_but_empty\n" |
|||
fi |
|||
unset REPLY |
|||
else |
|||
printf "RESULT=fail\n" |
|||
fi |
|||
' _ "$rows" "$TEST_DBPASS" "$expect" 2>&1)" |
|||
printf '%s' "$out" |
|||
} |
|||
|
|||
# ── 端到端运行指定脚本(用于验证「字段错误时不继续登录」)──────── |
|||
# 脚本置于 $TMP 下镜像 docs/ 的结构,使其相对定位 backend-infra/compose.yaml 生效。 |
|||
run_full_script() { |
|||
local script="$1" |
|||
: > "$TMP/flow.log" |
|||
(cd "$REPO_ROOT" && \ |
|||
SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE=ok \ |
|||
FAKE_CODE="$TEST_CODE" FAKE_JWT="$TEST_JWT" \ |
|||
MYSQL_PASSWORD="$TEST_DBPASS" MYSQL_USER="smoke_readonly" MYSQL_DATABASE="mydreamjob" \ |
|||
STUDENT_PHONE="$TEST_PHONE" BASE_URL="http://127.0.0.1:1/api/v1" \ |
|||
bash "$script" 2>&1) || true |
|||
} |
|||
|
|||
r="$(run_sms_code true true ok)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=ok' && echo 1 || echo 0)" "1. mock=true且ALLOW=true时可取得测试验证码" |
|||
check "$(printf '%s' "$r" | grep -q "$TEST_CODE" && echo 0 || echo 1)" "10a. 取码调用 stdout/stderr 不含验证码" |
|||
|
|||
r="$(run_sms_code false true ok)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "2. mock=false 时拒绝查询" |
|||
check "$(printf '%s' "$r" | grep -qF "$MARKER_MSG" && echo 1 || echo 0)" "2b. 拒绝时输出固定提示" |
|||
|
|||
r="$(run_sms_code __UNSET__ true ok)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "3. 配置缺失时拒绝查询" |
|||
|
|||
r="$(run_sms_code true __UNSET__ ok)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "4. ALLOW_TEST_OTP_DB_READ 缺失时拒绝查询" |
|||
|
|||
r="$(run_sms_code true false ok)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "5. ALLOW_TEST_OTP_DB_READ=false 时拒绝查询" |
|||
|
|||
r="$(run_sms_code true true empty)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "6. 没有可用验证码时安全失败" |
|||
|
|||
r="$(run_sms_code true true fail)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "7. 查询失败时不继续" |
|||
|
|||
r="$(run_sms_code true true garbage 'not-a-code')" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "8a. 非数字结果被拒绝" |
|||
r="$(run_sms_code true true short)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "8b. 长度非法结果被拒绝" |
|||
|
|||
# ══════════════════════════════════════════════════════════════ |
|||
# Part 2 — F4:真实 `bash -x` 执行,扫描 stdout+stderr |
|||
# ══════════════════════════════════════════════════════════════ |
|||
run_smoke_xtrace() { |
|||
local mode="$1" |
|||
: > "$TMP/flow.log"; : > "$TMP/docker-argv.log"; : > "$TMP/mysql-argv.log" |
|||
(cd "$REPO_ROOT" && \ |
|||
SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true FAKE_DOCKER_MODE="$mode" \ |
|||
FAKE_CODE="$TEST_CODE" FAKE_JWT="$TEST_JWT" \ |
|||
MYSQL_PASSWORD="$TEST_DBPASS" MYSQL_USER="smoke_readonly" MYSQL_DATABASE="mydreamjob" \ |
|||
STUDENT_PHONE="$TEST_PHONE" BASE_URL="http://127.0.0.1:1/api/v1" \ |
|||
bash -x "$SMOKE" 2>&1) || true |
|||
} |
|||
|
|||
out_x="$(run_smoke_xtrace ok)" |
|||
hits_x="$(scan_hits "$out_x")" |
|||
check "$(printf '%s' "$hits_x" | grep -q 'CODE' && echo 0 || echo 1)" "F4-1. bash -x 下不泄露验证码" |
|||
check "$(printf '%s' "$hits_x" | grep -q 'PHONE' && echo 0 || echo 1)" "F4-2. bash -x 下不泄露完整手机号" |
|||
check "$(printf '%s' "$hits_x" | grep -q 'JWT' && echo 0 || echo 1)" "F4-3. bash -x 下不泄露 JWT" |
|||
check "$(printf '%s' "$hits_x" | grep -q 'AUTH' && echo 0 || echo 1)" "F4-4. bash -x 下不泄露 Authorization 头" |
|||
check "$(printf '%s' "$hits_x" | grep -q 'DBPASS' && echo 0 || echo 1)" "F4-5. bash -x 下不泄露数据库密码" |
|||
check "$(printf '%s' "$hits_x" | grep -q 'LOGIN_BODY' && echo 0 || echo 1)" "F4-6. bash -x 下不泄露 LOGIN_BODY" |
|||
|
|||
# 正常流程仍可完成(登录被调用且带 6 位码;登录判定 PASS) |
|||
check "$(grep -q 'login-attempted has6digit=yes' "$TMP/flow.log" 2>/dev/null && echo 1 || echo 0)" \ |
|||
"F4-7. bash -x 下正常流程仍可完成登录" |
|||
check "$(printf '%s' "$out_x" | grep -q '\[PASS\] login' && echo 1 || echo 0)" \ |
|||
"F4-8. bash -x 下登录步骤判定 PASS" |
|||
|
|||
# xtrace 未在脚本内被重新启用:脚本内不存在可执行的 set -x |
|||
setx_code="$(grep -n 'set -x' "$SMOKE" | grep -v '^[0-9]*:[[:space:]]*#' | wc -l)" |
|||
check "$([ "$setx_code" -eq 0 ] && echo 1 || echo 0)" "F4-9. 脚本中不存在可执行的 set -x(不会恢复 xtrace)" |
|||
|
|||
# 失败流程同样不泄露 |
|||
out_f="$(run_smoke_xtrace empty)" |
|||
hits_f="$(scan_hits "$out_f")" |
|||
check "$([ -z "$hits_f" ] && echo 1 || echo 0)" "F4-10. 失败流程(无可用验证码)同样不泄露敏感值" |
|||
|
|||
# ══════════════════════════════════════════════════════════════ |
|||
# Part 3 — F4:source 模式不把敏感值带回调用方 |
|||
# ══════════════════════════════════════════════════════════════ |
|||
src_out="$(cd "$REPO_ROOT" && bash -c ' |
|||
set -u |
|||
export SMOKE_TEST_LIB_ONLY=1 |
|||
export SMS_MOCK_ENABLED=true ALLOW_TEST_OTP_DB_READ=true |
|||
export FAKE_DOCKER_MODE=ok FAKE_CODE="'"$TEST_CODE"'" |
|||
. "'"$SMOKE"'" |
|||
trap -p EXIT > "'"$TMP"'/trap.txt" 2>/dev/null || true |
|||
if sms_code; then |
|||
if [ -n "${REPLY:-}" ]; then echo "reply-nonempty"; else echo "reply-empty"; fi |
|||
unset REPLY |
|||
fi |
|||
# 清理后不得残留任何非空敏感变量 |
|||
leak=0 |
|||
for v in REPLY SMS OTP LOGIN_BODY LOGIN TOKEN AUTH; do |
|||
eval "val=\${$v:-}" |
|||
[ -n "$val" ] && leak=1 |
|||
done |
|||
echo "leak=$leak" |
|||
' 2>&1)" |
|||
check "$(printf '%s' "$src_out" | grep -q 'reply-nonempty' && echo 1 || echo 0)" "F4-11. source 模式下取码契约(REPLY)可用" |
|||
check "$(printf '%s' "$src_out" | grep -q 'leak=0' && echo 1 || echo 0)" "F4-12. source 模式下清理后无敏感变量残留" |
|||
check "$(grep -q 'cleanup_secrets' "$TMP/trap.txt" 2>/dev/null && echo 1 || echo 0)" "F4-13. source 模式下 EXIT trap 已登记清理函数" |
|||
check "$(grep -q 'PHONE' "$SMOKE" && grep -q 'TOKEN' "$SMOKE" && grep -q 'AUTH' "$SMOKE" && grep -q 'COMPANY_TOKEN' "$SMOKE" && echo 1 || echo 0)" \ |
|||
"F4-14. cleanup_secrets 覆盖 PHONE/TOKEN/AUTH/COMPANY_TOKEN" |
|||
|
|||
# ══════════════════════════════════════════════════════════════ |
|||
# Part 4 — F5:数据库密码不进入 mysql argv |
|||
# ══════════════════════════════════════════════════════════════ |
|||
: > "$TMP/mysql-argv.log"; : > "$TMP/docker-argv.log" |
|||
_f5="$(run_sms_code true true ok)" |
|||
mysql_argv="$(cat "$TMP/mysql-argv.log" 2>/dev/null)" |
|||
docker_argv="$(cat "$TMP/docker-argv.log" 2>/dev/null)" |
|||
container_script="$(cat "$TMP/container-script.txt" 2>/dev/null)" |
|||
mysql_stdin="$(cat "$TMP/mysql-stdin.sql" 2>/dev/null)" |
|||
|
|||
check "$(printf '%s' "$mysql_argv" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-1. mysql argv 不含数据库密码值" |
|||
check "$(printf '%s' "$docker_argv" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-2. 宿主机 docker 命令行不含数据库密码值" |
|||
check "$(printf '%s' "$container_script" | grep -qF "$TEST_DBPASS" && echo 0 || echo 1)" "F5-3. 容器侧脚本文本不含密码值" |
|||
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PASSWORD' && echo 1 || echo 0)" "F5-4. 容器侧脚本以变量名引用 MYSQL_PASSWORD" |
|||
check "$(printf '%s' "$mysql_argv" | grep -qE '(^| )-p' && echo 0 || echo 1)" "F5-5. mysql argv 不含 -p<密码>" |
|||
check "$(printf '%s' "$mysql_argv" | grep -q -- '--password' && echo 0 || echo 1)" "F5-6. mysql argv 不含 --password" |
|||
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_ROOT_PASSWORD' && echo 0 || echo 1)" "F5-7. 不使用 root 凭据(无 MYSQL_ROOT_PASSWORD)" |
|||
check "$(printf '%s' "$container_script" | grep -q 'uroot' && echo 0 || echo 1)" "F5-8. 不使用 -uroot" |
|||
check "$(printf '%s' "$mysql_argv" | grep -q 'smoke_readonly' && echo 1 || echo 0)" "F5-9. 使用应用账号 MYSQL_USER" |
|||
check "$(printf '%s' "$mysql_argv" | grep -q 'mydreamjob' && echo 1 || echo 0)" "F5-10. 使用 MYSQL_DATABASE" |
|||
check "$(printf '%s' "$mysql_stdin" | grep -qi '^SELECT' && echo 1 || echo 0)" "F5-11. SQL 经 stdin 传入" |
|||
check "$(printf '%s' "$mysql_stdin" | grep -qiE 'INSERT|UPDATE|DELETE|DROP|ALTER' && echo 0 || echo 1)" "F5-12. 查询仅包含 SELECT(无写操作)" |
|||
check "$(printf '%s' "$mysql_argv" | grep -q 'MYSQL_PWD\|MYSQL_PASSWORD' && echo 0 || echo 1)" "F5-13. 密码不经 argv 传递" |
|||
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PWD' && echo 1 || echo 0)" "F5-14. 密码经 MYSQL_PWD 环境变量传递" |
|||
check "$(printf '%s' "$container_script" | grep -q 'MYSQL_PASSWORD' && grep -q 'exit 3' "$TMP/container-script.txt" && echo 1 || echo 0)" \ |
|||
"F5-15. 凭据缺失时安全失败(不回退 root)" |
|||
check "$(printf '%s' "$_f5" | grep -q 'RESULT=ok' && echo 1 || echo 0)" "F5-16. argv 安全前提下取码仍可用" |
|||
|
|||
# docker 不可用(容器不存在)时不泄露且安全失败 |
|||
: > "$TMP/mysql-argv.log" |
|||
r="$(run_sms_code true true fail)" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" "F5-17. 查询失败时安全失败" |
|||
check "$(printf '%s' "$r" | grep -q "$TEST_DBPASS" && echo 0 || echo 1)" "F5-18. 失败路径 stderr 不含数据库密码" |
|||
|
|||
# ══════════════════════════════════════════════════════════════ |
|||
# Part 5 — F1 静态保证 + §7 活跃文件文档门禁 |
|||
# ══════════════════════════════════════════════════════════════ |
|||
check "$(grep -qE 'grep[^|]*\.log|grep[^|]*stdout' "$SMOKE" && echo 0 || echo 1)" "13. smoke-test.sh 不 grep 应用日志" |
|||
check "$(grep -q 'MOCK SMS CODE' "$SMOKE" && echo 0 || echo 1)" "14. smoke-test.sh 中不存在 MOCK SMS CODE" |
|||
mech=1 |
|||
grep -q 'unset REPLY' "$SMOKE" || mech=0 |
|||
grep -q 'unset SMS' "$SMOKE" || mech=0 |
|||
grep -q 'trap cleanup_secrets EXIT' "$SMOKE" || mech=0 |
|||
grep -q 'unset LOGIN_BODY' "$SMOKE" || mech=0 |
|||
check "$mech" "15. 存在 REPLY/SMS/LOGIN_BODY 清理与 EXIT trap" |
|||
check "$(grep -qE 'curl.*(-v|--trace|--trace-ascii)' "$SMOKE" && echo 0 || echo 1)" "20. 未使用 curl -v/--trace/--trace-ascii" |
|||
check "$(grep -q 'report_fields' "$SMOKE" && echo 1 || echo 0)" "21. 登录响应经 report_fields(不把响应全文交给打印函数)" |
|||
|
|||
# §7:仅扫描**活跃操作文档与正式代码**,不把历史验收报告计入 |
|||
ACTIVE_FILES="docs/acceptance-week1.md docs/api-v0.1.md docs/frontend-integration.md docs/offer-api.md docs/resume-api.md docs/smoke-test.sh README.md" |
|||
active_viol=0 |
|||
for f in $ACTIVE_FILES; do |
|||
[ -f "$REPO_ROOT/$f" ] || continue |
|||
c=$(grep -c "验证码.*打印到日志" "$REPO_ROOT/$f" 2>/dev/null); c=${c:-0} |
|||
c2=$(grep -c "从日志获取验证码" "$REPO_ROOT/$f" 2>/dev/null); c2=${c2:-0} |
|||
c3=$(grep -c "MOCK SMS CODE" "$REPO_ROOT/$f" 2>/dev/null); c3=${c3:-0} |
|||
[ $((c + c2 + c3)) -ne 0 ] && active_viol=$((active_viol + 1)) |
|||
done |
|||
check "$([ "$active_viol" -eq 0 ] && echo 1 || echo 0)" "22. 活跃操作文档不存在『从日志取码』有效指导" |
|||
src_viol=$(grep -rn "MOCK SMS CODE" "$REPO_ROOT/mydreamjob-server/src/main/java" 2>/dev/null | wc -l) |
|||
check "$([ "$src_viol" -eq 0 ] && echo 1 || echo 0)" "23. 正式 Java 代码中不存在 MOCK SMS CODE" |
|||
hist_ok=1 |
|||
[ -f "$REPO_ROOT/docs/s01-independent-mysql-acceptance-20260911.md" ] || hist_ok=0 |
|||
[ -f "$REPO_ROOT/docs/s01-remediation-f1f2f3-20260911.md" ] || hist_ok=0 |
|||
check "$hist_ok" "24. 历史验收报告仍保留(作为历史失败证据,不参与活跃文档扫描)" |
|||
|
|||
# ══════════════════════════════════════════════════════════════ |
|||
# Part 6 — F6:SQL 字段契约(expired_at)与查询语义 |
|||
# ══════════════════════════════════════════════════════════════ |
|||
# 背景:expires_at 在本仓库中是 personal_data_export_tasks(V30)、 |
|||
# ability_attempts(V35)、talent_invitations(V40) 三张表的**真实列**; |
|||
# 只有 sms_verification_codes 使用 expired_at。因此断言必须限定在该表的查询上, |
|||
# **不能**写成"全库不存在 expires_at"——那会误报。 |
|||
# |
|||
# 真实列清单从**创建该表的 Flyway** 派生,不在测试里手工维护一份与生产无关的 |
|||
# 假字段列表来自证正确。 |
|||
FLYWAY="$REPO_ROOT/mydreamjob-server/src/main/resources/db/migration/V1__base_schema.sql" |
|||
real_cols="$(awk ' |
|||
/^CREATE TABLE (IF NOT EXISTS )?sms_verification_codes/ { in_t=1; next } |
|||
in_t && /^\)/ { in_t=0 } |
|||
in_t { |
|||
line=$0 |
|||
sub(/^[[:space:]]+/, "", line) |
|||
if (line ~ /^(INDEX|KEY|PRIMARY|CONSTRAINT|UNIQUE|FOREIGN)/) next |
|||
if (line ~ /^[a-z_]+[[:space:]]/) { split(line, a, /[[:space:]]/); print a[1] } |
|||
} |
|||
' "$FLYWAY" | sort -u | tr '\n' ' ')" |
|||
|
|||
# 取一次真实查询文本(此调用未设置 FAKE_ROWS,走历史路径) |
|||
: > "$TMP/mysql-stdin.sql" |
|||
_f6="$(run_sms_code true true ok)" |
|||
sql_text="$(cat "$TMP/mysql-stdin.sql" 2>/dev/null)" |
|||
|
|||
check "$(printf '%s' "$sql_text" | grep -q 'expired_at > NOW()' && echo 1 || echo 0)" \ |
|||
"F6-1. otp_db_query 使用 expired_at > NOW()" |
|||
check "$(printf '%s' "$sql_text" | grep -q 'expires_at' && echo 0 || echo 1)" \ |
|||
"F6-2. otp_db_query 不再出现错误字段 expires_at" |
|||
|
|||
# 解析查询里使用的列名(去掉字符串字面量与 SQL 关键字) |
|||
used_cols="$(printf '%s' "$sql_text" \ |
|||
| sed "s/'[^']*'//g" \ |
|||
| grep -oE '\b[a-z_][a-z0-9_]*\b' \ |
|||
| grep -vEx 'select|from|where|and|or|order|by|desc|asc|limit|now|sms_verification_codes' \ |
|||
| sort -u)" |
|||
unknown_cols="" |
|||
for c in $used_cols; do |
|||
case " $real_cols " in |
|||
*" $c "*) ;; |
|||
*) unknown_cols="$unknown_cols $c" ;; |
|||
esac |
|||
done |
|||
check "$([ -z "$unknown_cols" ] && echo 1 || echo 0)" \ |
|||
"F6-3. 查询只使用 sms_verification_codes 的真实列${unknown_cols:+(命中不存在的列:$unknown_cols)}" |
|||
# 防止"派生失败得到空清单"导致上一条恒真 |
|||
check "$(printf '%s' "$real_cols" | grep -q 'expired_at' && echo 1 || echo 0)" \ |
|||
"F6-4. 真实列清单由 Flyway 派生且包含 expired_at(非空、非自证)" |
|||
|
|||
check "$(printf '%s' "$sql_text" | grep -qiE '^SELECT code FROM sms_verification_codes' && echo 1 || echo 0)" \ |
|||
"F6-5. 假 mysql 捕获到完整 stdin SQL(不是无条件返回验证码)" |
|||
|
|||
# 字段错误 → 假 mysql 必须失败(复现真实 MySQL 的 ERROR 1054) |
|||
printf '%s\n' "SELECT code FROM sms_verification_codes WHERE phone='x' AND expires_at > NOW();" \ |
|||
| "$FAKEBIN/mysql" >/dev/null 2>"$TMP/err1054.txt" || rc1054=$? |
|||
rc1054="${rc1054:-0}" |
|||
check "$([ "$rc1054" -ne 0 ] && echo 1 || echo 0)" \ |
|||
"F6-6. SQL 字段错误时假 mysql 返回失败(非 0)" |
|||
check "$(grep -q '1054' "$TMP/err1054.txt" 2>/dev/null && echo 1 || echo 0)" \ |
|||
"F6-6b. 字段错误时报 ERROR 1054 (42S22)" |
|||
|
|||
# 端到端:把字段改错的副本跑完整流程,验证不会继续登录 |
|||
BUGDIR="$TMP/bugrepo" |
|||
mkdir -p "$BUGDIR/docs" "$BUGDIR/backend-infra" |
|||
cp "$REPO_ROOT/backend-infra/compose.yaml" "$BUGDIR/backend-infra/compose.yaml" 2>/dev/null || true |
|||
sed 's/expired_at > NOW()/expires_at > NOW()/' "$SMOKE" > "$BUGDIR/docs/smoke-test.sh" |
|||
out_bug="$(run_full_script "$BUGDIR/docs/smoke-test.sh")" |
|||
check "$(printf '%s' "$out_bug" | grep -q '\[FAIL\] login skipped' && echo 1 || echo 0)" \ |
|||
"F6-7. 字段错误时 sms_code() 不继续登录" |
|||
check "$(grep -q 'login-attempted' "$TMP/flow.log" 2>/dev/null && echo 0 || echo 1)" \ |
|||
"F6-7b. 字段错误时未发出任何 login 请求" |
|||
|
|||
# 正确字段 → 取码成功并可完成登录 |
|||
out_ok="$(run_full_script "$SMOKE")" |
|||
check "$(grep -q 'login-attempted has6digit=yes' "$TMP/flow.log" 2>/dev/null && echo 1 || echo 0)" \ |
|||
"F6-8. 正确字段时取码成功并完成登录" |
|||
|
|||
# ── 过期 / used / purpose / phone / 最新一条 语义 ────────────── |
|||
# 行格式 id|phone|purpose|used|expire_offset_s|created_at|code |
|||
P="$TEST_PHONE" |
|||
R_FUTURE="1|$P|login|0|300|2026-09-11 10:00:00|$TEST_CODE" |
|||
R_EXACT="1|$P|login|0|0|2026-09-11 10:00:00|$TEST_CODE" |
|||
R_PAST="1|$P|login|0|-300|2026-09-11 10:00:00|$TEST_CODE" |
|||
R_USED="1|$P|login|1|300|2026-09-11 10:00:00|$TEST_CODE" |
|||
R_PURPOSE="1|$P|register|0|300|2026-09-11 10:00:00|$TEST_CODE" |
|||
R_PHONE="1|19900001111|login|0|300|2026-09-11 10:00:00|$TEST_CODE" |
|||
R_OLD="1|$P|login|0|300|2026-09-11 10:00:00|111111" |
|||
R_NEW="2|$P|login|0|300|2026-09-11 11:00:00|222222" |
|||
|
|||
r="$(run_sms_code_with_rows "$R_EXACT" "$TEST_CODE")" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ |
|||
"F6-9. expired_at 恰好等于 NOW() 时不得取出" |
|||
|
|||
r="$(run_sms_code_with_rows "$R_FUTURE" "$TEST_CODE")" |
|||
check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \ |
|||
"F6-10. expired_at 晚于 NOW() 时可以取出" |
|||
|
|||
r="$(run_sms_code_with_rows "$R_PAST" "$TEST_CODE")" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ |
|||
"F6-11. expired_at 早于 NOW() 时不得取出" |
|||
|
|||
r="$(run_sms_code_with_rows "$R_USED" "$TEST_CODE")" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ |
|||
"F6-12. used=1 不得取出" |
|||
|
|||
r="$(run_sms_code_with_rows "$R_PURPOSE" "$TEST_CODE")" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ |
|||
"F6-13. purpose 不匹配不得取出" |
|||
|
|||
r="$(run_sms_code_with_rows "$R_PHONE" "$TEST_CODE")" |
|||
check "$(printf '%s' "$r" | grep -q 'RESULT=fail' && echo 1 || echo 0)" \ |
|||
"F6-14. phone 不匹配不得取出" |
|||
|
|||
r="$(run_sms_code_with_rows "$(printf '%s\n%s' "$R_OLD" "$R_NEW")" "222222")" |
|||
check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \ |
|||
"F6-15. 多条有效记录只取 created_at/id 最新一条" |
|||
# 逆序输入仍取同一条,证明按 ORDER BY 取值而非"最后一行胜出" |
|||
r="$(run_sms_code_with_rows "$(printf '%s\n%s' "$R_NEW" "$R_OLD")" "222222")" |
|||
check "$(printf '%s' "$r" | grep -q 'MATCH=yes' && echo 1 || echo 0)" \ |
|||
"F6-15b. 与输入顺序无关,按 created_at/id 排序取值" |
|||
|
|||
echo "" |
|||
echo "============================================" |
|||
echo "PASS=$PASS FAIL=$FAIL" |
|||
echo "============================================" |
|||
|
|||
# 最终泄露扫描(测试自身输出也不得含标记) |
|||
if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_CODE"; then |
|||
echo "LEAK DETECTED: code"; exit 1 |
|||
fi |
|||
if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_JWT"; then |
|||
echo "LEAK DETECTED: jwt"; exit 1 |
|||
fi |
|||
if printf '%s' "$out_x$out_f$r$src_out" | grep -qF "$TEST_DBPASS"; then |
|||
echo "LEAK DETECTED: dbpass"; exit 1 |
|||
fi |
|||
[ "$FAIL" -eq 0 ] |
|||
@ -0,0 +1,23 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
import lombok.extern.slf4j.Slf4j; |
|||
|
|||
/** |
|||
* Mock 短信发送实现。 |
|||
* |
|||
* <p>由 {@link SmsSenderConfiguration} 在 {@code app.sms.mock.enabled} 显式解析为 |
|||
* {@code true} 时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。</p> |
|||
* |
|||
* <p>不接入真实短信网关,仅记录一条固定文本的发送事件。 |
|||
* <strong>不得</strong>把验证码或完整手机号写入日志。</p> |
|||
*/ |
|||
@Slf4j |
|||
public class MockSmsCodeSender implements SmsCodeSender { |
|||
|
|||
@Override |
|||
public void sendVerificationCode(String phone, String purpose, String code) { |
|||
// 固定安全文本:不包含验证码、手机号、Token 或任何配置细节。
|
|||
// 本地开发如需验证码,请从 sms_verification_codes 表读取(见 docs 说明),不要重新写回日志。
|
|||
log.info("SMS verification code generated (mock sender)"); |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
/** |
|||
* 短信验证码发送抽象。 |
|||
* |
|||
* <p>由 {@code app.sms.mock.enabled} 决定加载哪一个实现: |
|||
* <ul> |
|||
* <li>{@code true} → {@link MockSmsCodeSender}(本地/测试环境,不调用外部网关)</li> |
|||
* <li>{@code false} 或未配置 → {@link UnavailableSmsCodeSender}(安全失败,不伪造成功)</li> |
|||
* </ul> |
|||
* |
|||
* <p>实现方<strong>禁止</strong>把验证码或完整手机号写入任何级别的日志。 |
|||
*/ |
|||
public interface SmsCodeSender { |
|||
|
|||
/** |
|||
* 发送验证码。 |
|||
* |
|||
* @param phone 接收手机号(实现方不得写入日志) |
|||
* @param purpose 验证码用途 |
|||
* @param code 验证码(实现方不得写入日志) |
|||
*/ |
|||
void sendVerificationCode(String phone, String purpose, String code); |
|||
} |
|||
@ -0,0 +1,37 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.context.annotation.Bean; |
|||
import org.springframework.context.annotation.Configuration; |
|||
|
|||
/** |
|||
* 短信发送实现装配(单 Bean 方案)。 |
|||
* |
|||
* <p>由本配置类<strong>始终且仅</strong>产生一个 {@link SmsCodeSender},从而彻底避免 |
|||
* “两个互斥 {@code @ConditionalOnProperty} 在空值/空白值下都不匹配 → 0 个 Bean → |
|||
* 模糊 Spring 装配错误导致启动失败”的问题。</p> |
|||
* |
|||
* <p>取值语义(大小写不敏感,两侧空白忽略):</p> |
|||
* <ul> |
|||
* <li>{@code true} → {@link MockSmsCodeSender}</li> |
|||
* <li>{@code false} / 缺失 / 空字符串 / 仅空白 / 其他非法值 → {@link UnavailableSmsCodeSender}</li> |
|||
* </ul> |
|||
* |
|||
* <p>安全约束:只有显式 {@code true} 才启用 Mock;非法值一律安全回退到不可用实现, |
|||
* 既不启用 Mock,也不进入发送流程。本类不输出配置原值到日志。</p> |
|||
*/ |
|||
@Configuration |
|||
public class SmsSenderConfiguration { |
|||
|
|||
/** 显式启用 Mock 的唯一取值。 */ |
|||
private static final String MOCK_ENABLED_VALUE = "true"; |
|||
|
|||
@Bean |
|||
public SmsCodeSender smsCodeSender( |
|||
@Value("${app.sms.mock.enabled:false}") String rawEnabled) { |
|||
if (rawEnabled != null && MOCK_ENABLED_VALUE.equalsIgnoreCase(rawEnabled.trim())) { |
|||
return new MockSmsCodeSender(); |
|||
} |
|||
return new UnavailableSmsCodeSender(); |
|||
} |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
package com.mydreamjob.auth.sms; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
|
|||
/** |
|||
* 不可用短信发送实现。 |
|||
* |
|||
* <p>由 {@link SmsSenderConfiguration} 在 {@code app.sms.mock.enabled} 为 false、缺失、 |
|||
* 空值、仅空白或其他非法值时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。</p> |
|||
* |
|||
* <p>当前未接入真实短信网关,因此本实现<strong>安全失败</strong>: |
|||
* 抛出既有的“服务不可用”业务异常,绝不返回虚假的“发送成功”。 |
|||
* 异常消息为固定安全文本,不含手机号、验证码或任何配置细节。</p> |
|||
*/ |
|||
public class UnavailableSmsCodeSender implements SmsCodeSender { |
|||
|
|||
/** 固定安全文本:不含手机号、验证码、Token、内网地址或配置项。 */ |
|||
static final String SAFE_MESSAGE = "sms sender unavailable"; |
|||
|
|||
@Override |
|||
public void sendVerificationCode(String phone, String purpose, String code) { |
|||
throw new BusinessException(ErrorCode.SERVICE_UNAVAILABLE, SAFE_MESSAGE); |
|||
} |
|||
} |
|||
@ -0,0 +1,82 @@ |
|||
package com.mydreamjob.security; |
|||
|
|||
import org.springframework.security.authentication.AnonymousAuthenticationToken; |
|||
import org.springframework.security.authentication.InsufficientAuthenticationException; |
|||
import org.springframework.security.core.Authentication; |
|||
import org.springframework.security.core.context.SecurityContextHolder; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
/** |
|||
* 安全关键路径的当前用户解析器。 |
|||
* |
|||
* <p>身份<strong>只</strong>来自 JWT / SecurityContext,不接受任何请求参数传入的 userId。 |
|||
* 所有非法主体一律转换为既有的“未登录”语义({@link InsufficientAuthenticationException} |
|||
* 由 {@code GlobalExceptionHandler} 映射为 HTTP 401),从而避免 |
|||
* {@code NumberFormatException} 逃逸为 HTTP 500。</p> |
|||
* |
|||
* <p>明确拒绝:Authentication 为 null、未认证、{@link AnonymousAuthenticationToken}、 |
|||
* 名称为空、名称为 anonymousUser、名称不是合法 Long、userId <= 0。</p> |
|||
* |
|||
* <p>本类<strong>只</strong>负责身份解析,不承担角色校验与资源归属校验—— |
|||
* 那两类判断仍由既有业务逻辑负责。</p> |
|||
*/ |
|||
@Component |
|||
public class CurrentUserAccessor { |
|||
|
|||
/** 固定安全文本:不回显任何非法 principal。 */ |
|||
static final String UNAUTHENTICATED_MESSAGE = "authentication required"; |
|||
|
|||
private static final String ANONYMOUS_PRINCIPAL = "anonymousUser"; |
|||
|
|||
/** 解析当前请求的用户 ID(读取 SecurityContext)。 */ |
|||
public Long requireUserId() { |
|||
return requireUserId(SecurityContextHolder.getContext().getAuthentication()); |
|||
} |
|||
|
|||
/** |
|||
* 解析用户 ID。任何非法输入都抛出未登录异常,绝不抛出 NumberFormatException。 |
|||
* |
|||
* @param authentication 待校验的认证对象(可为 null) |
|||
* @return 大于 0 的已验证用户 ID |
|||
*/ |
|||
public Long requireUserId(Authentication authentication) { |
|||
if (authentication == null |
|||
|| !authentication.isAuthenticated() |
|||
|| authentication instanceof AnonymousAuthenticationToken) { |
|||
throw unauthenticated(); |
|||
} |
|||
String name = authentication.getName(); |
|||
if (name == null || name.isBlank() || ANONYMOUS_PRINCIPAL.equals(name)) { |
|||
throw unauthenticated(); |
|||
} |
|||
if (!isAsciiDigits(name)) { |
|||
throw unauthenticated(); |
|||
} |
|||
long userId; |
|||
try { |
|||
userId = Long.parseLong(name); |
|||
} catch (NumberFormatException e) { |
|||
// 位数溢出等极端情况:同样按未登录处理,不回显原始值
|
|||
throw unauthenticated(); |
|||
} |
|||
if (userId <= 0) { |
|||
throw unauthenticated(); |
|||
} |
|||
return userId; |
|||
} |
|||
|
|||
/** 严格校验:仅接受 ASCII 数字,排除 "+42"、"-42"、空白与 Unicode 数字。 */ |
|||
private boolean isAsciiDigits(String value) { |
|||
for (int i = 0; i < value.length(); i++) { |
|||
char c = value.charAt(i); |
|||
if (c < '0' || c > '9') { |
|||
return false; |
|||
} |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
private InsufficientAuthenticationException unauthenticated() { |
|||
return new InsufficientAuthenticationException(UNAUTHENTICATED_MESSAGE); |
|||
} |
|||
} |
|||
@ -0,0 +1,431 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import ch.qos.logback.classic.Level; |
|||
import ch.qos.logback.classic.Logger; |
|||
import ch.qos.logback.classic.spi.ILoggingEvent; |
|||
import ch.qos.logback.core.read.ListAppender; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import com.mydreamjob.job.Job; |
|||
import com.mydreamjob.job.JobRepository; |
|||
import com.mydreamjob.job.JobStatus; |
|||
import io.jsonwebtoken.Jwts; |
|||
import io.jsonwebtoken.security.Keys; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.slf4j.LoggerFactory; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.http.MediaType; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import javax.crypto.SecretKey; |
|||
import java.nio.charset.StandardCharsets; |
|||
import java.util.Comparator; |
|||
import java.util.Date; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Objects; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
import java.util.stream.Stream; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.hamcrest.Matchers.containsString; |
|||
import static org.hamcrest.Matchers.not; |
|||
import static org.mockito.ArgumentMatchers.anyString; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
/** |
|||
* S01 认证与授权边界(真实 SecurityFilterChain + 真实 H2 Service)。 |
|||
* |
|||
* <p>覆盖任务书第三节与第九节 C 组: |
|||
* 匿名写请求必须是 401(修复前为 500),越权请求不得产生业务写入, |
|||
* 公开 GET 语义保持不变,且失败响应不得回显 anonymousUser、非法 principal 或内部类名。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
class AuthenticationBoundaryIntegrationTest { |
|||
|
|||
private static final AtomicLong PHONE_SEQ = new AtomicLong(7100); |
|||
private static final String BAD_PRINCIPAL = "INTERNAL_BAD_PRINCIPAL_SECRET"; |
|||
private static final String VALID_JOB_BODY = """ |
|||
{"title":"安全边界测试岗位","description":"desc","city":"杭州","employmentType":"FULL_TIME"} |
|||
"""; |
|||
|
|||
@MockBean |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Autowired |
|||
private MockMvc mockMvc; |
|||
|
|||
@Autowired |
|||
private JwtTokenProvider jwtTokenProvider; |
|||
|
|||
@Autowired |
|||
private UserRepository userRepository; |
|||
|
|||
@Autowired |
|||
private CompanyRepository companyRepository; |
|||
|
|||
@Autowired |
|||
private JobRepository jobRepository; |
|||
|
|||
@Value("${app.jwt.secret}") |
|||
private String jwtSecret; |
|||
|
|||
private Long companyUserId; |
|||
private Long otherCompanyUserId; |
|||
private Long studentUserId; |
|||
private Long adminUserId; |
|||
private Long companyId; |
|||
private Long otherCompanyId; |
|||
private Long jobId; |
|||
|
|||
private ListAppender<ILoggingEvent> appender; |
|||
private Logger appLogger; |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(anyString())).thenReturn(false); |
|||
|
|||
companyUserId = createUser(User.Role.COMPANY); |
|||
otherCompanyUserId = createUser(User.Role.COMPANY); |
|||
studentUserId = createUser(User.Role.STUDENT); |
|||
adminUserId = createUser(User.Role.ADMIN); |
|||
|
|||
companyId = createCompany(companyUserId, "安全边界甲公司"); |
|||
otherCompanyId = createCompany(otherCompanyUserId, "安全边界乙公司"); |
|||
|
|||
jobId = jobRepository.save(Job.builder() |
|||
.companyId(companyId) |
|||
.title("边界夹具岗位") |
|||
.description("fixture") |
|||
.city("杭州") |
|||
.employmentType("FULL_TIME") |
|||
.status(JobStatus.DRAFT.name()) |
|||
.build()).getId(); |
|||
|
|||
appLogger = (Logger) LoggerFactory.getLogger("com.mydreamjob"); |
|||
appender = new ListAppender<>(); |
|||
appender.start(); |
|||
appLogger.addAppender(appender); |
|||
appLogger.setLevel(Level.TRACE); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
if (appLogger != null && appender != null) { |
|||
appLogger.detachAppender(appender); |
|||
appender.stop(); |
|||
} |
|||
// 本测试类与其他集成测试共用同一 JVM 内的 H2 命名库,必须清理自身夹具(FK 安全顺序),
|
|||
// 否则残留的 companies 行会让后续 AuthIntegrationTest 的 userRepository.deleteAll() 触发 FK 约束错误。
|
|||
List<Long> myCompanyIds = Stream.of(companyId, otherCompanyId) |
|||
.filter(Objects::nonNull).toList(); |
|||
if (!myCompanyIds.isEmpty()) { |
|||
jobRepository.deleteAll(jobRepository.findAll().stream() |
|||
.filter(j -> myCompanyIds.contains(j.getCompanyId())) |
|||
.toList()); |
|||
companyRepository.deleteAllById(myCompanyIds); |
|||
} |
|||
List<Long> myUserIds = Stream.of(companyUserId, otherCompanyUserId, studentUserId, adminUserId) |
|||
.filter(Objects::nonNull).toList(); |
|||
if (!myUserIds.isEmpty()) { |
|||
userRepository.deleteAllById(myUserIds); |
|||
} |
|||
} |
|||
|
|||
// ── A. 匿名写请求必须 401,不得 500,且无业务写入 ──────────────
|
|||
|
|||
@Test |
|||
void anonymousPostJobsMustReturn401InsteadOf500() throws Exception { |
|||
List<String> before = snapshotJobs(); |
|||
|
|||
mockMvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(VALID_JOB_BODY)) |
|||
.andExpect(status().isUnauthorized()) |
|||
.andExpect(jsonPath("$.code").value(401)) |
|||
.andExpect(content().string(not(containsString("anonymousUser")))) |
|||
.andExpect(content().string(not(containsString("NumberFormatException")))) |
|||
.andExpect(content().string(not(containsString("Exception")))) |
|||
.andExpect(content().string(not(containsString("com.mydreamjob")))) |
|||
.andExpect(content().string(not(containsString("java.")))); |
|||
|
|||
assertThat(snapshotJobs()).as("匿名创建不得写入任何岗位").isEqualTo(before); |
|||
} |
|||
|
|||
@Test |
|||
void anonymousCannotCreateJob() throws Exception { |
|||
List<String> before = snapshotJobs(); |
|||
|
|||
mockMvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(VALID_JOB_BODY)) |
|||
.andExpect(status().isUnauthorized()); |
|||
|
|||
assertThat(snapshotJobs()) |
|||
.as("jobs 表完整内容不得变化(不只是行数)") |
|||
.isEqualTo(before); |
|||
} |
|||
|
|||
@Test |
|||
void anonymousCannotUpdateJob() throws Exception { |
|||
List<String> before = snapshotJobs(); |
|||
|
|||
mockMvc.perform(put("/jobs/" + jobId) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"title\":\"被篡改\"}")) |
|||
.andExpect(status().isUnauthorized()); |
|||
|
|||
assertThat(snapshotJobs()).isEqualTo(before); |
|||
} |
|||
|
|||
@Test |
|||
void anonymousCannotDeleteJob() throws Exception { |
|||
List<String> before = snapshotJobs(); |
|||
|
|||
mockMvc.perform(delete("/jobs/" + jobId)) |
|||
.andExpect(status().isUnauthorized()); |
|||
|
|||
assertThat(snapshotJobs()).isEqualTo(before); |
|||
} |
|||
|
|||
@Test |
|||
void anonymousCannotPublishOrCloseJob() throws Exception { |
|||
List<String> before = snapshotJobs(); |
|||
|
|||
mockMvc.perform(patch("/jobs/" + jobId + "/status") |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"status\":\"PUBLISHED\"}")) |
|||
.andExpect(status().isUnauthorized()); |
|||
|
|||
assertThat(snapshotJobs()).isEqualTo(before); |
|||
assertThat(jobRepository.findById(jobId).orElseThrow().getStatus()) |
|||
.isEqualTo(JobStatus.DRAFT.name()); |
|||
} |
|||
|
|||
@Test |
|||
void anonymousAuthenticationTokenMustBeRejectedBeforeControllerBusinessLogic() throws Exception { |
|||
List<String> before = snapshotJobs(); |
|||
|
|||
// 匿名请求会被 Spring Security 包装为 AnonymousAuthenticationToken(isAuthenticated() == true)。
|
|||
// 修复前:命中 permitAll → 进入 Controller → Long.valueOf("anonymousUser") → 500。
|
|||
mockMvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(VALID_JOB_BODY)) |
|||
.andExpect(status().isUnauthorized()) |
|||
.andExpect(content().string(not(containsString("anonymousUser")))); |
|||
|
|||
assertThat(snapshotJobs()).isEqualTo(before); |
|||
} |
|||
|
|||
@Test |
|||
void nonNumericPrincipalMustReturn401WithoutEcho() throws Exception { |
|||
List<String> before = snapshotJobs(); |
|||
|
|||
mockMvc.perform(post("/jobs") |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content(VALID_JOB_BODY) |
|||
.header("Authorization", "Bearer " + signedTokenWithSubject(BAD_PRINCIPAL))) |
|||
.andExpect(status().isUnauthorized()) |
|||
.andExpect(content().string(not(containsString(BAD_PRINCIPAL)))) |
|||
.andExpect(content().string(not(containsString("NumberFormatException")))) |
|||
.andExpect(content().string(not(containsString("java.")))); |
|||
|
|||
assertThat(capturedLogs()) |
|||
.as("日志不得回显非法 principal") |
|||
.doesNotContain(BAD_PRINCIPAL); |
|||
assertThat(snapshotJobs()).isEqualTo(before); |
|||
} |
|||
|
|||
// ── B. 公开 GET 语义保持不变 ───────────────────────────────────
|
|||
|
|||
@Test |
|||
void anonymousCanListPublishedJobs() throws Exception { |
|||
mockMvc.perform(get("/jobs")) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.code").value(200)) |
|||
.andExpect(jsonPath("$.data.items").isArray()); |
|||
} |
|||
|
|||
@Test |
|||
void anonymousCanReadPublicJobDetailIfExistingContractAllows() throws Exception { |
|||
Job job = jobRepository.findById(jobId).orElseThrow(); |
|||
job.setStatus(JobStatus.PUBLISHED.name()); |
|||
jobRepository.save(job); |
|||
|
|||
mockMvc.perform(get("/jobs/public/" + jobId)) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.code").value(200)); |
|||
} |
|||
|
|||
@Test |
|||
void anonymousCanSearchAndFilterJobs() throws Exception { |
|||
mockMvc.perform(get("/jobs/search")).andExpect(status().isOk()); |
|||
mockMvc.perform(get("/jobs/filters")).andExpect(status().isOk()); |
|||
} |
|||
|
|||
// ── C. 已登录但非企业主体:不得创建岗位 ───────────────────────
|
|||
|
|||
@Test |
|||
void studentCannotCreateJob() throws Exception { |
|||
List<String> before = snapshotJobs(); |
|||
|
|||
// 既有语义:非 COMPANY 角色 → FORBIDDEN(2002) → HTTP 403
|
|||
mockMvc.perform(post("/jobs") |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content(VALID_JOB_BODY) |
|||
.header("Authorization", token(studentUserId))) |
|||
.andExpect(status().isForbidden()) |
|||
.andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); |
|||
|
|||
assertThat(snapshotJobs()).isEqualTo(before); |
|||
} |
|||
|
|||
@Test |
|||
void adminCannotCreateJob() throws Exception { |
|||
List<String> before = snapshotJobs(); |
|||
|
|||
// 既有语义:ADMIN 也不是 COMPANY 角色 → FORBIDDEN(2002) → HTTP 403
|
|||
mockMvc.perform(post("/jobs") |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content(VALID_JOB_BODY) |
|||
.header("Authorization", token(adminUserId))) |
|||
.andExpect(status().isForbidden()) |
|||
.andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN.getCode())); |
|||
|
|||
assertThat(snapshotJobs()).isEqualTo(before); |
|||
} |
|||
|
|||
// ── D. 企业主体:可创建;他企业不得修改 ────────────────────────
|
|||
|
|||
@Test |
|||
void companyCanCreateJob() throws Exception { |
|||
List<String> before = snapshotJobs(); |
|||
|
|||
mockMvc.perform(post("/jobs") |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content(VALID_JOB_BODY) |
|||
.header("Authorization", token(companyUserId))) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.code").value(200)) |
|||
.andExpect(jsonPath("$.data.status").value(JobStatus.DRAFT.name())); |
|||
|
|||
List<String> after = snapshotJobs(); |
|||
assertThat(after).hasSize(before.size() + 1); |
|||
assertThat(jobRepository.findAll()) |
|||
.anySatisfy(j -> { |
|||
assertThat(j.getTitle()).isEqualTo("安全边界测试岗位"); |
|||
assertThat(j.getCompanyId()).isEqualTo(companyOf(companyUserId)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
void otherCompanyCannotModifyOwnedJob() throws Exception { |
|||
List<String> before = snapshotJobs(); |
|||
|
|||
// 既有语义:归属校验失败为 JOB_OWNER_MISMATCH(7002) → HTTP 400(本轮不改变错误码)
|
|||
mockMvc.perform(put("/jobs/" + jobId) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"title\":\"他企业篡改\"}") |
|||
.header("Authorization", token(otherCompanyUserId))) |
|||
.andExpect(status().isBadRequest()) |
|||
.andExpect(jsonPath("$.code").value(ErrorCode.JOB_OWNER_MISMATCH.getCode())); |
|||
|
|||
assertThat(snapshotJobs()).isEqualTo(before); |
|||
} |
|||
|
|||
@Test |
|||
void otherCompanyCannotPublishOwnedJob() throws Exception { |
|||
List<String> before = snapshotJobs(); |
|||
|
|||
mockMvc.perform(patch("/jobs/" + jobId + "/status") |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"status\":\"PUBLISHED\"}") |
|||
.header("Authorization", token(otherCompanyUserId))) |
|||
.andExpect(status().isBadRequest()) |
|||
.andExpect(jsonPath("$.code").value(ErrorCode.JOB_OWNER_MISMATCH.getCode())); |
|||
|
|||
assertThat(snapshotJobs()).isEqualTo(before); |
|||
} |
|||
|
|||
// ── 辅助 ──────────────────────────────────────────────────────
|
|||
|
|||
private List<String> snapshotJobs() { |
|||
return jobRepository.findAll().stream() |
|||
.sorted(Comparator.comparing(Job::getId)) |
|||
.map(j -> String.join("|", |
|||
String.valueOf(j.getId()), String.valueOf(j.getCompanyId()), |
|||
String.valueOf(j.getTitle()), String.valueOf(j.getDescription()), |
|||
String.valueOf(j.getCity()), String.valueOf(j.getEmploymentType()), |
|||
String.valueOf(j.getStatus()), |
|||
String.valueOf(j.getSalaryMin()), String.valueOf(j.getSalaryMax()), |
|||
String.valueOf(j.getPublishedAt()), String.valueOf(j.getUpdatedAt()))) |
|||
.toList(); |
|||
} |
|||
|
|||
private String capturedLogs() { |
|||
StringBuilder sb = new StringBuilder(); |
|||
for (ILoggingEvent event : appender.list) { |
|||
sb.append(event.getLevel()).append(' ').append(event.getFormattedMessage()).append('\n'); |
|||
if (event.getThrowableProxy() != null) { |
|||
sb.append(event.getThrowableProxy().getMessage()).append('\n'); |
|||
} |
|||
} |
|||
return sb.toString(); |
|||
} |
|||
|
|||
private String token(Long userId) { |
|||
return "Bearer " + jwtTokenProvider.generateAccessToken(userId, "1", Map.of("roles", "STUDENT")); |
|||
} |
|||
|
|||
private String signedTokenWithSubject(String subject) { |
|||
SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); |
|||
long now = System.currentTimeMillis(); |
|||
return Jwts.builder() |
|||
.subject(subject) |
|||
.issuedAt(new Date(now)) |
|||
.expiration(new Date(now + 3_600_000L)) |
|||
.signWith(key) |
|||
.compact(); |
|||
} |
|||
|
|||
private Long createUser(User.Role role) { |
|||
return userRepository.save(User.builder() |
|||
.phone("17" + String.format("%09d", PHONE_SEQ.incrementAndGet())) |
|||
.nickname("边界用户") |
|||
.role(role) |
|||
// 角色集合为 @ElementCollection,业务角色校验读取的是 roles 而非 role
|
|||
.roles(new java.util.HashSet<>(java.util.Set.of(role))) |
|||
.active(true) |
|||
.verified(true) |
|||
.build()).getId(); |
|||
} |
|||
|
|||
private Long createCompany(Long ownerUserId, String name) { |
|||
return companyRepository.save(Company.builder() |
|||
.ownerUserId(ownerUserId) |
|||
.name(name) |
|||
.build()).getId(); |
|||
} |
|||
|
|||
private Long companyOf(Long ownerUserId) { |
|||
return companyRepository.findByOwnerUserId(ownerUserId).orElseThrow().getId(); |
|||
} |
|||
} |
|||
@ -0,0 +1,79 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import com.mydreamjob.auth.sms.SmsCodeSender; |
|||
import com.mydreamjob.auth.sms.UnavailableSmsCodeSender; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.context.ApplicationContext; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
import static org.mockito.ArgumentMatchers.anyString; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* S01 定向整改 F3 核心回归:{@code app.sms.mock.enabled=} <strong>空值</strong>下的 |
|||
* 全应用上下文启动。 |
|||
* |
|||
* <p>整改前该取值会使两个互斥 {@code @ConditionalOnProperty} 都不匹配 → 0 个 |
|||
* {@code SmsCodeSender} Bean → 应用以 |
|||
* {@code APPLICATION FAILED TO START ... required a bean of type 'SmsCodeSender'} |
|||
* 直接启动失败。</p> |
|||
* |
|||
* <p>本测试以真实全量 Spring 上下文(H2 + Flyway)启动,证明:上下文启动成功、 |
|||
* {@link SmsVerificationService} 可注入、恰好一个 {@link SmsCodeSender}、 |
|||
* 类型为 {@link UnavailableSmsCodeSender},且调用发送时安全失败。</p> |
|||
*/ |
|||
@SpringBootTest(properties = {"app.sms.mock.enabled="}) |
|||
@ActiveProfiles("integration-test") |
|||
class SmsEmptyPropertyStartupIntegrationTest { |
|||
|
|||
private static final String PHONE = "19900005678"; |
|||
|
|||
@MockBean |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Autowired |
|||
private ApplicationContext applicationContext; |
|||
|
|||
@Autowired |
|||
private SmsVerificationService smsVerificationService; |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void stubRedis() { |
|||
// 不触发锁定/限流分支,使流程到达发送器调用
|
|||
when(redisTemplate.hasKey(anyString())).thenReturn(false); |
|||
when(redisTemplate.opsForValue()).thenReturn(mock(ValueOperations.class)); |
|||
} |
|||
|
|||
@Test |
|||
void emptyPropertyStartsContextWithExactlyOneUnavailableSender() { |
|||
assertThat(applicationContext.getBeanNamesForType(SmsCodeSender.class)) |
|||
.as("空值必须恰好装配一个 SmsCodeSender") |
|||
.hasSize(1); |
|||
assertThat(applicationContext.getBean(SmsCodeSender.class)) |
|||
.isInstanceOf(UnavailableSmsCodeSender.class); |
|||
assertThat(applicationContext) |
|||
.as("SmsVerificationService 必须可注入") |
|||
.isNotNull(); |
|||
assertThat(smsVerificationService).isNotNull(); |
|||
} |
|||
|
|||
@Test |
|||
void emptyPropertySenderFailsSafely() { |
|||
assertThatThrownBy(() -> smsVerificationService.sendCode(PHONE, "login", null, null, null)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt()) |
|||
.isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode())); |
|||
} |
|||
} |
|||
@ -0,0 +1,230 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import ch.qos.logback.classic.Level; |
|||
import ch.qos.logback.classic.Logger; |
|||
import ch.qos.logback.classic.spi.ILoggingEvent; |
|||
import ch.qos.logback.core.read.ListAppender; |
|||
import com.mydreamjob.auth.sms.MockSmsCodeSender; |
|||
import com.mydreamjob.auth.sms.SmsCodeSender; |
|||
import com.mydreamjob.auth.sms.SmsSenderConfiguration; |
|||
import com.mydreamjob.auth.sms.UnavailableSmsCodeSender; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.slf4j.LoggerFactory; |
|||
import org.springframework.boot.test.context.runner.ApplicationContextRunner; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
|
|||
/** |
|||
* S01 定向整改 F3:{@code app.sms.mock.enabled} 在 |
|||
* true / false / 缺失 / 空值 / 仅空白 / 大写 / 非法值下都必须装配 |
|||
* <strong>恰好一个</strong> {@link SmsCodeSender},且只有显式 true 才启用 Mock。 |
|||
* |
|||
* <p>全部用例使用真实 {@link ApplicationContextRunner} 装配(不直接 new 配置类、 |
|||
* 不测试字符串解析辅助函数、不使用 Mockito 验证)。</p> |
|||
* |
|||
* <p>{@link SenderConsumerProbe} 是<strong>消费者</strong>探针:它以构造参数方式依赖 |
|||
* {@link SmsCodeSender},用于复现“构造注入时缺少 Bean”的原始缺陷场景; |
|||
* 它<strong>不生产</strong> any SmsCodeSender,因此不会掩盖生产 Bean 缺失。</p> |
|||
*/ |
|||
class SmsSenderConfigurationTest { |
|||
|
|||
/** 仅消费、不生产 SmsCodeSender 的探针。 */ |
|||
static class SenderConsumerProbe { |
|||
final SmsCodeSender sender; |
|||
|
|||
SenderConsumerProbe(SmsCodeSender sender) { |
|||
this.sender = sender; |
|||
} |
|||
} |
|||
|
|||
private final ApplicationContextRunner runner = new ApplicationContextRunner() |
|||
.withUserConfiguration(SmsSenderConfiguration.class, SenderConsumerProbe.class); |
|||
|
|||
private static final String ACC_PHONE = "19900009999"; |
|||
private static final String ACC_CODE = "739251"; |
|||
|
|||
// ── 7 类取值行为 ────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void trueUsesMockSender() { |
|||
runner.withPropertyValues("app.sms.mock.enabled=true").run(context -> { |
|||
assertThat(context).hasNotFailed(); |
|||
assertThat(context).hasSingleBean(SmsCodeSender.class); |
|||
assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(MockSmsCodeSender.class); |
|||
assertThat(context).doesNotHaveBean(UnavailableSmsCodeSender.class); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
void falseUsesUnavailableSender() { |
|||
runner.withPropertyValues("app.sms.mock.enabled=false").run(context -> { |
|||
assertThat(context).hasNotFailed(); |
|||
assertThat(context).hasSingleBean(SmsCodeSender.class); |
|||
assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class); |
|||
assertThat(context).doesNotHaveBean(MockSmsCodeSender.class); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
void missingPropertyUsesUnavailableSender() { |
|||
runner.run(context -> { |
|||
assertThat(context).hasNotFailed(); |
|||
assertThat(context).hasSingleBean(SmsCodeSender.class); |
|||
assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
void blankPropertyUsesUnavailableSender() { |
|||
// F3 核心回归:空值曾导致 0 个 Bean 与模糊 Spring 装配启动失败
|
|||
runner.withPropertyValues("app.sms.mock.enabled=").run(context -> { |
|||
assertThat(context) |
|||
.as("空值必须仍能启动且装配一个可用实现") |
|||
.hasNotFailed(); |
|||
assertThat(context).hasSingleBean(SmsCodeSender.class); |
|||
assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class); |
|||
assertThat(context).doesNotHaveBean(MockSmsCodeSender.class); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
void whitespacePropertyUsesUnavailableSender() { |
|||
runner.withPropertyValues("app.sms.mock.enabled= ").run(context -> { |
|||
assertThat(context).hasNotFailed(); |
|||
assertThat(context).hasSingleBean(SmsCodeSender.class); |
|||
assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(UnavailableSmsCodeSender.class); |
|||
assertThat(context).doesNotHaveBean(MockSmsCodeSender.class); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
void invalidPropertyNeverEnablesMockSender() { |
|||
for (String invalid : new String[]{"yes", "1", "on", "enabled", "TRUE1", "mock", "null"}) { |
|||
runner.withPropertyValues("app.sms.mock.enabled=" + invalid).run(context -> { |
|||
assertThat(context).as("非法值 %s 不应导致启动失败", invalid).hasNotFailed(); |
|||
assertThat(context).hasSingleBean(SmsCodeSender.class); |
|||
assertThat(context.getBean(SmsCodeSender.class)) |
|||
.as("非法值 %s 绝不允许启用 Mock", invalid) |
|||
.isInstanceOf(UnavailableSmsCodeSender.class); |
|||
assertThat(context).doesNotHaveBean(MockSmsCodeSender.class); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void uppercaseTrueBehaviorIsExplicit() { |
|||
// 明确语义:大小写不敏感 + 忽略两侧空白 → TRUE / True / " true " 视为启用 Mock
|
|||
for (String accepted : new String[]{"TRUE", "True", " true ", "tRuE"}) { |
|||
runner.withPropertyValues("app.sms.mock.enabled=" + accepted).run(context -> { |
|||
assertThat(context).hasSingleBean(SmsCodeSender.class); |
|||
assertThat(context.getBean(SmsCodeSender.class)) |
|||
.as("取值 [%s] 应视为显式启用 Mock", accepted) |
|||
.isInstanceOf(MockSmsCodeSender.class); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void everySupportedConfigurationCreatesExactlyOneSenderBean() { |
|||
String[] values = {"true", "false", "", " ", "TRUE", "yes", "1", "on", "null"}; |
|||
for (String value : values) { |
|||
runner.withPropertyValues("app.sms.mock.enabled=" + value).run(context -> { |
|||
assertThat(context).as("取值 [%s]", value).hasNotFailed(); |
|||
assertThat(context.getBeanNamesForType(SmsCodeSender.class)) |
|||
.as("取值 [%s] 必须恰好一个 SmsCodeSender Bean", value) |
|||
.hasSize(1); |
|||
}); |
|||
} |
|||
// 缺失属性
|
|||
runner.run(context -> { |
|||
assertThat(context).hasNotFailed(); |
|||
assertThat(context.getBeanNamesForType(SmsCodeSender.class)) |
|||
.as("缺失属性时必须恰好一个 SmsCodeSender Bean") |
|||
.hasSize(1); |
|||
}); |
|||
} |
|||
|
|||
// ── 空值场景的完整证据链(消费者注入 + 安全失败 + 无敏感日志)────
|
|||
|
|||
@Test |
|||
void blankPropertyStillAllowsConstructorInjectionIntoConsumer() { |
|||
runner.withPropertyValues("app.sms.mock.enabled=").run(context -> { |
|||
assertThat(context).hasNotFailed(); |
|||
// 复现原始缺陷场景:构造参数注入必须成功(曾因 0 个 Bean 失败)
|
|||
SenderConsumerProbe probe = context.getBean(SenderConsumerProbe.class); |
|||
assertThat(probe.sender).isNotNull().isInstanceOf(UnavailableSmsCodeSender.class); |
|||
// 同一实例,证明容器内只有一个 Bean
|
|||
assertThat(probe.sender).isSameAs(context.getBean(SmsCodeSender.class)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
void blankPropertySenderFailsSafelyWithoutLeakingOrLogging() { |
|||
ListAppender<ILoggingEvent> appender = new ListAppender<>(); |
|||
appender.start(); |
|||
Logger logger = (Logger) LoggerFactory.getLogger("com.mydreamjob.auth.sms"); |
|||
logger.addAppender(appender); |
|||
logger.setLevel(Level.TRACE); |
|||
try { |
|||
runner.withPropertyValues("app.sms.mock.enabled=").run(context -> { |
|||
SmsCodeSender sender = context.getBean(SmsCodeSender.class); |
|||
|
|||
assertThatThrownBy(() -> sender.sendVerificationCode(ACC_PHONE, "login", ACC_CODE)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.satisfies(e -> { |
|||
BusinessException be = (BusinessException) e; |
|||
assertThat(be.getErrorCodeInt()) |
|||
.isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode()); |
|||
assertThat(be.getMessage()) |
|||
.as("固定安全文本契约") |
|||
.isEqualTo("sms sender unavailable") |
|||
.doesNotContain(ACC_PHONE) |
|||
.doesNotContain(ACC_CODE) |
|||
.doesNotContainIgnoringCase("mock") |
|||
.doesNotContain("app.sms"); |
|||
}); |
|||
|
|||
String logs = appender.list.stream() |
|||
.map(ILoggingEvent::getFormattedMessage) |
|||
.reduce("", (a, b) -> a + '\n' + b); |
|||
assertThat(logs) |
|||
.doesNotContain(ACC_CODE) |
|||
.doesNotContain(ACC_PHONE) |
|||
.doesNotContain("app.sms.mock.enabled"); |
|||
}); |
|||
} finally { |
|||
logger.detachAppender(appender); |
|||
appender.stop(); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void unavailableSenderDoesNotReturnFalseSuccess() { |
|||
runner.withPropertyValues("app.sms.mock.enabled=false").run(context -> { |
|||
SmsCodeSender sender = context.getBean(SmsCodeSender.class); |
|||
|
|||
assertThatThrownBy(() -> sender.sendVerificationCode(ACC_PHONE, "login", ACC_CODE)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.satisfies(e -> { |
|||
BusinessException be = (BusinessException) e; |
|||
assertThat(be.getErrorCodeInt()).isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode()); |
|||
assertThat(be.getMessage()) |
|||
.doesNotContain(ACC_PHONE) |
|||
.doesNotContain(ACC_CODE) |
|||
.doesNotContainIgnoringCase("mock"); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
void mockSenderLoadsOnlyWhenExplicitlyEnabled() { |
|||
runner.withPropertyValues("app.sms.mock.enabled=true").run(context -> { |
|||
assertThat(context).hasSingleBean(SmsCodeSender.class); |
|||
assertThat(context.getBean(SmsCodeSender.class)).isInstanceOf(MockSmsCodeSender.class); |
|||
assertThat(context).doesNotHaveBean(UnavailableSmsCodeSender.class); |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,115 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import com.mydreamjob.audit.AuditLog; |
|||
import com.mydreamjob.audit.AuditLogRepository; |
|||
import com.mydreamjob.auth.sms.SmsCodeSender; |
|||
import com.mydreamjob.auth.sms.UnavailableSmsCodeSender; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.context.ApplicationContext; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
import static org.mockito.ArgumentMatchers.anyString; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* S01 定向整改 —— 补充回归:不可用发送实现下的事务回滚。 |
|||
* |
|||
* <p>本测试使用<strong>真实 Spring 上下文 + 真实 H2 + Flyway + 真实 |
|||
* {@link SmsVerificationService} 与真实 {@code @Transactional} 代理</strong>, |
|||
* 并在本测试类中把 {@code app.sms.mock.enabled} 覆盖为 {@code false}, |
|||
* 因此容器装配的是 {@link UnavailableSmsCodeSender}。</p> |
|||
* |
|||
* <p>测试类本身<strong>不加</strong> {@code @Transactional},以保证 |
|||
* {@code sendCode} 的事务独立提交/回滚,从而真实验证“发送失败不残留可用验证码”。</p> |
|||
*/ |
|||
@SpringBootTest(properties = {"app.sms.mock.enabled=false"}) |
|||
@ActiveProfiles("integration-test") |
|||
class SmsUnavailableSenderRollbackIntegrationTest { |
|||
|
|||
private static final String PHONE = "19900001234"; |
|||
private static final String PURPOSE = "login"; |
|||
|
|||
@MockBean |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Autowired |
|||
private ApplicationContext applicationContext; |
|||
|
|||
@Autowired |
|||
private SmsVerificationService smsVerificationService; |
|||
|
|||
@Autowired |
|||
private SmsVerificationCodeRepository codeRepository; |
|||
|
|||
@Autowired |
|||
private AuditLogRepository auditLogRepository; |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(anyString())).thenReturn(false); |
|||
} |
|||
|
|||
@Test |
|||
void containerProvidesExactlyOneUnavailableSenderWhenMockDisabled() { |
|||
assertThat(applicationContext.getBeanNamesForType(SmsCodeSender.class)) |
|||
.as("mock=false 时必须恰好一个 SmsCodeSender") |
|||
.hasSize(1); |
|||
assertThat(applicationContext.getBean(SmsCodeSender.class)) |
|||
.isInstanceOf(UnavailableSmsCodeSender.class); |
|||
} |
|||
|
|||
@Test |
|||
void unavailableSenderFailureLeavesNoVerificationCodeRow() { |
|||
// 清理本测试手机号的既有数据,保证断言只反映本次调用
|
|||
List<SmsVerificationCode> pre = codeRepository.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE); |
|||
if (!pre.isEmpty()) { |
|||
codeRepository.deleteAll(pre); |
|||
} |
|||
long beforeTotal = codeRepository.count(); |
|||
LocalDateTime since = LocalDateTime.now().minusMinutes(1); |
|||
long auditBefore = auditLogRepository.findByCreatedAtAfter(since).stream() |
|||
.filter(a -> "SMS_SEND".equals(a.getAction())) |
|||
.count(); |
|||
|
|||
assertThatThrownBy(() -> smsVerificationService.sendCode(PHONE, PURPOSE, "rollback-test", null, null)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt()) |
|||
.isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode())); |
|||
|
|||
// 事务已回滚:不得残留该手机号的任何验证码行(不只是数量相等)
|
|||
assertThat(codeRepository.findByPhoneAndPurposeOrderByCreatedAtDesc(PHONE, PURPOSE)) |
|||
.as("发送失败后不得残留可用验证码") |
|||
.isEmpty(); |
|||
assertThat(codeRepository.count()).isEqualTo(beforeTotal); |
|||
|
|||
// 失败不得产生“发送成功”审计
|
|||
long auditAfter = auditLogRepository.findByCreatedAtAfter(since).stream() |
|||
.filter(a -> "SMS_SEND".equals(a.getAction())) |
|||
.count(); |
|||
assertThat(auditAfter).isEqualTo(auditBefore); |
|||
} |
|||
|
|||
@Test |
|||
void unavailableSenderFailureDoesNotReturnFalseSuccess() { |
|||
assertThatThrownBy(() -> smsVerificationService.sendCode(PHONE, PURPOSE, null, null, null)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.isNotInstanceOf(NumberFormatException.class); |
|||
} |
|||
} |
|||
@ -0,0 +1,332 @@ |
|||
package com.mydreamjob.auth; |
|||
|
|||
import ch.qos.logback.classic.Level; |
|||
import ch.qos.logback.classic.Logger; |
|||
import ch.qos.logback.classic.spi.ILoggingEvent; |
|||
import ch.qos.logback.core.read.ListAppender; |
|||
import com.mydreamjob.audit.AuditService; |
|||
import com.mydreamjob.auth.sms.MockSmsCodeSender; |
|||
import com.mydreamjob.auth.sms.SmsCodeSender; |
|||
import com.mydreamjob.auth.sms.UnavailableSmsCodeSender; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.ArgumentCaptor; |
|||
import org.mockito.InjectMocks; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.slf4j.LoggerFactory; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
|
|||
import java.time.Duration; |
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.stream.Stream; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyString; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.Mockito.lenient; |
|||
import static org.mockito.Mockito.never; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* S01 短信安全:验证码与手机号<strong>不得</strong>泄露到日志。 |
|||
* |
|||
* <p>本测试不使用 Mockito 校验日志,而是挂载真实 Logback {@link ListAppender} |
|||
* 捕获实际日志事件后做字符串断言。</p> |
|||
* |
|||
* <p>生产语义不动:长度、有效期、最大尝试次数、锁定时间、一次性消费保持不变。</p> |
|||
*/ |
|||
@ExtendWith(MockitoExtension.class) |
|||
class SmsVerificationSecurityTest { |
|||
|
|||
/** 唯一敏感标记:手机号 */ |
|||
private static final String SENSITIVE_PHONE = "19900009999"; |
|||
private static final String PURPOSE = "login"; |
|||
private static final int EXPECTED_CODE_LENGTH = 6; |
|||
private static final int EXPECTED_EXPIRE_SECONDS = 300; |
|||
private static final int EXPECTED_MAX_ATTEMPTS = 5; |
|||
private static final int EXPECTED_LOCKOUT_SECONDS = 300; |
|||
|
|||
@Mock |
|||
private SmsVerificationCodeRepository codeRepository; |
|||
|
|||
@Mock |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Mock |
|||
private ValueOperations<String, Object> valueOperations; |
|||
|
|||
@Mock |
|||
private AuditService auditService; |
|||
|
|||
@Mock |
|||
private SmsCodeSender smsCodeSender; |
|||
|
|||
@InjectMocks |
|||
private SmsVerificationService service; |
|||
|
|||
private Logger rootLogger; |
|||
private Logger appLogger; |
|||
private ListAppender<ILoggingEvent> appender; |
|||
private Level originalRootLevel; |
|||
private Level originalAppLevel; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
ReflectionTestUtils.setField(service, "codeLength", EXPECTED_CODE_LENGTH); |
|||
ReflectionTestUtils.setField(service, "expireSeconds", EXPECTED_EXPIRE_SECONDS); |
|||
ReflectionTestUtils.setField(service, "maxAttempts", EXPECTED_MAX_ATTEMPTS); |
|||
ReflectionTestUtils.setField(service, "lockoutSeconds", EXPECTED_LOCKOUT_SECONDS); |
|||
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); |
|||
|
|||
// ── 挂载真实 Logback 捕获器(root + 应用包,覆盖 INFO/WARN/ERROR/DEBUG)──
|
|||
rootLogger = (Logger) LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME); |
|||
appLogger = (Logger) LoggerFactory.getLogger("com.mydreamjob"); |
|||
originalRootLevel = rootLogger.getLevel(); |
|||
originalAppLevel = appLogger.getLevel(); |
|||
rootLogger.setLevel(Level.TRACE); |
|||
appLogger.setLevel(Level.TRACE); |
|||
appender = new ListAppender<>(); |
|||
appender.start(); |
|||
rootLogger.addAppender(appender); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
rootLogger.detachAppender(appender); |
|||
appender.stop(); |
|||
rootLogger.setLevel(originalRootLevel); |
|||
appLogger.setLevel(originalAppLevel); |
|||
} |
|||
|
|||
private void stubSendAllowed() { |
|||
when(redisTemplate.hasKey(anyString())).thenReturn(false); |
|||
when(codeRepository.save(any(SmsVerificationCode.class))) |
|||
.thenAnswer(invocation -> invocation.getArgument(0)); |
|||
} |
|||
|
|||
private String capturedLogText() { |
|||
StringBuilder sb = new StringBuilder(); |
|||
for (ILoggingEvent event : appender.list) { |
|||
sb.append(event.getLevel()).append(' ') |
|||
.append(event.getFormattedMessage()).append('\n'); |
|||
if (event.getThrowableProxy() != null) { |
|||
sb.append(event.getThrowableProxy().getMessage()).append('\n'); |
|||
} |
|||
} |
|||
return sb.toString(); |
|||
} |
|||
|
|||
// ── 核心:生产语义下不得泄露验证码/手机号 ──────────────────────
|
|||
|
|||
@Test |
|||
void productionProfileMustNotLogVerificationCodeOrFullPhone() { |
|||
stubSendAllowed(); |
|||
ArgumentCaptor<SmsVerificationCode> captor = ArgumentCaptor.forClass(SmsVerificationCode.class); |
|||
|
|||
service.sendCode(SENSITIVE_PHONE, PURPOSE, "req-1", "127.0.0.1", "junit"); |
|||
|
|||
verify(codeRepository).save(captor.capture()); |
|||
String generatedCode = captor.getValue().getCode(); |
|||
assertThat(generatedCode).hasSize(EXPECTED_CODE_LENGTH); |
|||
|
|||
String logs = capturedLogText(); |
|||
assertThat(logs) |
|||
.as("日志不得包含验证码明文") |
|||
.doesNotContain(generatedCode); |
|||
assertThat(logs) |
|||
.as("日志不得包含完整手机号") |
|||
.doesNotContain(SENSITIVE_PHONE); |
|||
assertThat(logs) |
|||
.as("日志不得包含完整验证码的任何形式") |
|||
.doesNotContain("code=" + generatedCode); |
|||
} |
|||
|
|||
@Test |
|||
void smsLogsNeverContainCodePhoneTokenPasswordOrSecret() { |
|||
stubSendAllowed(); |
|||
ArgumentCaptor<SmsVerificationCode> captor = ArgumentCaptor.forClass(SmsVerificationCode.class); |
|||
|
|||
service.sendCode(SENSITIVE_PHONE, PURPOSE, "req-2", "127.0.0.1", "junit"); |
|||
|
|||
// 同时触发锁定与限流分支,覆盖 WARN 级日志
|
|||
when(redisTemplate.hasKey(eq("sms:lockout:" + SENSITIVE_PHONE))).thenReturn(true); |
|||
assertThatThrownBy(() -> service.sendCode(SENSITIVE_PHONE, PURPOSE, "req-3", "127.0.0.1", "junit")) |
|||
.isInstanceOf(BusinessException.class); |
|||
|
|||
verify(codeRepository).save(captor.capture()); |
|||
String generatedCode = captor.getValue().getCode(); |
|||
|
|||
String logs = capturedLogText(); |
|||
assertThat(logs) |
|||
.doesNotContain(generatedCode) |
|||
.doesNotContain(SENSITIVE_PHONE) |
|||
.doesNotContainIgnoringCase("password") |
|||
.doesNotContainIgnoringCase("secret"); |
|||
} |
|||
|
|||
@Test |
|||
void auditDetailsMustNotContainFullPhone() { |
|||
stubSendAllowed(); |
|||
|
|||
service.sendCode(SENSITIVE_PHONE, PURPOSE, "req-4", "127.0.0.1", "junit"); |
|||
|
|||
ArgumentCaptor<String> details = ArgumentCaptor.forClass(String.class); |
|||
verify(auditService).log(any(), eq("SMS_SEND"), eq("PHONE"), any(), details.capture(), |
|||
any(), any(), any()); |
|||
|
|||
assertThat(details.getValue()) |
|||
.as("审计明细不得记录完整手机号") |
|||
.doesNotContain(SENSITIVE_PHONE); |
|||
} |
|||
|
|||
// ── 生产语义回归:长度/有效期/尝试次数/锁定/一次性消费 ──────────
|
|||
|
|||
@Test |
|||
void verificationExpiryRemainsUnchanged() { |
|||
stubSendAllowed(); |
|||
ArgumentCaptor<SmsVerificationCode> captor = ArgumentCaptor.forClass(SmsVerificationCode.class); |
|||
LocalDateTime before = LocalDateTime.now(); |
|||
|
|||
service.sendCode(SENSITIVE_PHONE, PURPOSE, null, null, null); |
|||
|
|||
verify(codeRepository).save(captor.capture()); |
|||
SmsVerificationCode saved = captor.getValue(); |
|||
assertThat(saved.getCode()).hasSize(EXPECTED_CODE_LENGTH); |
|||
assertThat(saved.getExpiredAt()) |
|||
.isAfter(before.plusSeconds(EXPECTED_EXPIRE_SECONDS - 5)) |
|||
.isBefore(before.plusSeconds(EXPECTED_EXPIRE_SECONDS + 5)); |
|||
assertThat(saved.getMaxAttempts()).isEqualTo(EXPECTED_MAX_ATTEMPTS); |
|||
assertThat(saved.getUsed()).isFalse(); |
|||
assertThat(saved.getAttempts()).isZero(); |
|||
} |
|||
|
|||
@Test |
|||
void verificationAttemptLimitRemainsUnchanged() { |
|||
SmsVerificationCode stored = code("111111", 0, false); |
|||
when(redisTemplate.hasKey(anyString())).thenReturn(false); |
|||
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE)) |
|||
.thenReturn(Stream.of(stored)); |
|||
ArgumentCaptor<SmsVerificationCode> captor = ArgumentCaptor.forClass(SmsVerificationCode.class); |
|||
|
|||
assertThatThrownBy(() -> service.verifyCode(SENSITIVE_PHONE, "999999", PURPOSE)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt()) |
|||
.isEqualTo(ErrorCode.SMS_CODE_INVALID.getCode())); |
|||
|
|||
verify(codeRepository).save(captor.capture()); |
|||
assertThat(captor.getValue().getAttempts()).isEqualTo(1); |
|||
assertThat(stored.getUsed()).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void verificationLockoutRemainsUnchanged() { |
|||
SmsVerificationCode stored = code("111111", EXPECTED_MAX_ATTEMPTS, false); |
|||
when(redisTemplate.hasKey(anyString())).thenReturn(false); |
|||
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE)) |
|||
.thenReturn(Stream.of(stored)); |
|||
|
|||
assertThatThrownBy(() -> service.verifyCode(SENSITIVE_PHONE, "111111", PURPOSE)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt()) |
|||
.isEqualTo(ErrorCode.SMS_CODE_ATTEMPTS_EXCEEDED.getCode())); |
|||
|
|||
verify(valueOperations).set("sms:lockout:" + SENSITIVE_PHONE, "1", |
|||
(long) EXPECTED_LOCKOUT_SECONDS, TimeUnit.SECONDS); |
|||
} |
|||
|
|||
@Test |
|||
void successfulVerificationStillConsumesCodeOnce() { |
|||
SmsVerificationCode stored = code("654321", 0, false); |
|||
when(redisTemplate.hasKey(anyString())).thenReturn(false); |
|||
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE)) |
|||
.thenReturn(Stream.of(stored)); |
|||
|
|||
assertThat(service.verifyCode(SENSITIVE_PHONE, "654321", PURPOSE)).isTrue(); |
|||
assertThat(stored.getUsed()).isTrue(); |
|||
verify(codeRepository).save(stored); |
|||
|
|||
// 重放:已使用记录不再被查询命中
|
|||
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE)) |
|||
.thenReturn(Stream.empty()); |
|||
assertThatThrownBy(() -> service.verifyCode(SENSITIVE_PHONE, "654321", PURPOSE)) |
|||
.isInstanceOf(BusinessException.class); |
|||
} |
|||
|
|||
@Test |
|||
void expiredCodeStillRejected() { |
|||
SmsVerificationCode expired = SmsVerificationCode.builder() |
|||
.phone(SENSITIVE_PHONE).code("222222").purpose(PURPOSE) |
|||
.expiredAt(LocalDateTime.now().minus(Duration.ofSeconds(1))) |
|||
.attempts(0).maxAttempts(EXPECTED_MAX_ATTEMPTS).used(false) |
|||
.createdAt(LocalDateTime.now().minus(Duration.ofMinutes(6))) |
|||
.build(); |
|||
when(redisTemplate.hasKey(anyString())).thenReturn(false); |
|||
when(codeRepository.streamLatestUnusedByPhoneAndPurpose(SENSITIVE_PHONE, PURPOSE)) |
|||
.thenReturn(Stream.of(expired)); |
|||
|
|||
assertThatThrownBy(() -> service.verifyCode(SENSITIVE_PHONE, "222222", PURPOSE)) |
|||
.isInstanceOf(BusinessException.class) |
|||
.satisfies(e -> assertThat(((BusinessException) e).getErrorCodeInt()) |
|||
.isEqualTo(ErrorCode.SMS_CODE_EXPIRED.getCode())); |
|||
verify(codeRepository, never()).save(any(SmsVerificationCode.class)); |
|||
} |
|||
|
|||
// ── 发送实现安全边界 ───────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void mockSenderMustNotLogCodeOrPhone() { |
|||
ListAppender<ILoggingEvent> senderAppender = new ListAppender<>(); |
|||
senderAppender.start(); |
|||
Logger senderLogger = (Logger) LoggerFactory.getLogger(MockSmsCodeSender.class); |
|||
senderLogger.addAppender(senderAppender); |
|||
senderLogger.setLevel(Level.TRACE); |
|||
try { |
|||
new MockSmsCodeSender().sendVerificationCode(SENSITIVE_PHONE, PURPOSE, "739251"); |
|||
|
|||
String logs = senderAppender.list.stream() |
|||
.map(ILoggingEvent::getFormattedMessage) |
|||
.reduce("", (a, b) -> a + '\n' + b); |
|||
assertThat(logs).doesNotContain("739251").doesNotContain(SENSITIVE_PHONE); |
|||
} finally { |
|||
senderLogger.detachAppender(senderAppender); |
|||
senderAppender.stop(); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void unavailableSenderFailsSafelyWithoutLeaking() { |
|||
assertThatThrownBy(() -> |
|||
new UnavailableSmsCodeSender().sendVerificationCode(SENSITIVE_PHONE, PURPOSE, "739251")) |
|||
.isInstanceOf(BusinessException.class) |
|||
.satisfies(e -> { |
|||
BusinessException be = (BusinessException) e; |
|||
assertThat(be.getErrorCodeInt()).isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode()); |
|||
assertThat(be.getMessage()) |
|||
.doesNotContain(SENSITIVE_PHONE) |
|||
.doesNotContain("739251") |
|||
.doesNotContainIgnoringCase("mock") |
|||
.doesNotContain("app.sms"); |
|||
}); |
|||
} |
|||
|
|||
private SmsVerificationCode code(String value, int attempts, boolean used) { |
|||
return SmsVerificationCode.builder() |
|||
.phone(SENSITIVE_PHONE).code(value).purpose(PURPOSE) |
|||
.expiredAt(LocalDateTime.now().plusSeconds(EXPECTED_EXPIRE_SECONDS)) |
|||
.attempts(attempts).maxAttempts(EXPECTED_MAX_ATTEMPTS).used(used) |
|||
.createdAt(LocalDateTime.now()) |
|||
.build(); |
|||
} |
|||
} |
|||
@ -0,0 +1,152 @@ |
|||
package com.mydreamjob.security; |
|||
|
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.security.authentication.AnonymousAuthenticationToken; |
|||
import org.springframework.security.authentication.InsufficientAuthenticationException; |
|||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; |
|||
import org.springframework.security.core.Authentication; |
|||
import org.springframework.security.core.authority.AuthorityUtils; |
|||
import org.springframework.security.core.context.SecurityContextHolder; |
|||
|
|||
import java.util.List; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
|
|||
/** |
|||
* S01 当前用户解析安全边界。 |
|||
* |
|||
* <p>覆盖任务书要求的 8 类输入,并额外覆盖 Long 溢出与“已认证但名称为 anonymousUser”的绕过尝试。 |
|||
* 所有非法输入必须转换为既有“未登录”语义({@link InsufficientAuthenticationException}, |
|||
* 由全局异常处理器映射为 401),且<strong>不回显</strong>非法 principal。</p> |
|||
*/ |
|||
class CurrentUserAccessorTest { |
|||
|
|||
private static final String BAD_PRINCIPAL = "INTERNAL_BAD_PRINCIPAL_SECRET"; |
|||
|
|||
private final CurrentUserAccessor accessor = new CurrentUserAccessor(); |
|||
|
|||
@AfterEach |
|||
void clearContext() { |
|||
SecurityContextHolder.clearContext(); |
|||
} |
|||
|
|||
@Test |
|||
void nullAuthenticationIsUnauthorized() { |
|||
assertUnauthorized(() -> accessor.requireUserId((Authentication) null)); |
|||
} |
|||
|
|||
@Test |
|||
void unauthenticatedAuthenticationIsUnauthorized() { |
|||
// 双参构造 → isAuthenticated() == false
|
|||
Authentication unauthenticated = new UsernamePasswordAuthenticationToken("42", null); |
|||
assertThat(unauthenticated.isAuthenticated()).isFalse(); |
|||
|
|||
assertUnauthorized(() -> accessor.requireUserId(unauthenticated)); |
|||
} |
|||
|
|||
@Test |
|||
void anonymousAuthenticationTokenIsUnauthorized() { |
|||
AnonymousAuthenticationToken anonymous = new AnonymousAuthenticationToken( |
|||
"anonymous-key", "anonymousUser", |
|||
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")); |
|||
// 关键前提:Spring 的匿名令牌 isAuthenticated() 为 true,
|
|||
// 仅检查 isAuthenticated() 会把匿名当成已登录用户。
|
|||
assertThat(anonymous.isAuthenticated()).isTrue(); |
|||
|
|||
assertUnauthorized(() -> accessor.requireUserId(anonymous)); |
|||
} |
|||
|
|||
@Test |
|||
void anonymousUserNameIsUnauthorized() { |
|||
Authentication authenticatedButAnonymousName = |
|||
UsernamePasswordAuthenticationToken.authenticated("anonymousUser", null, List.of()); |
|||
|
|||
assertUnauthorized(() -> accessor.requireUserId(authenticatedButAnonymousName)); |
|||
} |
|||
|
|||
@Test |
|||
void blankPrincipalIsUnauthorized() { |
|||
assertUnauthorized(() -> accessor.requireUserId( |
|||
UsernamePasswordAuthenticationToken.authenticated(" ", null, List.of()))); |
|||
} |
|||
|
|||
@Test |
|||
void nonNumericPrincipalIsUnauthorizedWithoutEcho() { |
|||
Authentication bad = UsernamePasswordAuthenticationToken.authenticated(BAD_PRINCIPAL, null, List.of()); |
|||
|
|||
assertThatThrownBy(() -> accessor.requireUserId(bad)) |
|||
.isInstanceOf(InsufficientAuthenticationException.class) |
|||
.satisfies(e -> { |
|||
assertThat(e.getMessage()).doesNotContain(BAD_PRINCIPAL); |
|||
assertThat(String.valueOf(e)).doesNotContain(BAD_PRINCIPAL); |
|||
// 异常及其堆栈不得携带非法 principal 原文
|
|||
assertThat(java.util.Arrays.toString(e.getStackTrace())) |
|||
.doesNotContain(BAD_PRINCIPAL); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
void nonPositiveUserIdIsUnauthorized() { |
|||
assertUnauthorized(() -> accessor.requireUserId( |
|||
UsernamePasswordAuthenticationToken.authenticated("0", null, List.of()))); |
|||
assertUnauthorized(() -> accessor.requireUserId( |
|||
UsernamePasswordAuthenticationToken.authenticated("-1", null, List.of()))); |
|||
} |
|||
|
|||
@Test |
|||
void validNumericPrincipalReturnsUserId() { |
|||
assertThat(accessor.requireUserId( |
|||
UsernamePasswordAuthenticationToken.authenticated("42", null, List.of()))) |
|||
.isEqualTo(42L); |
|||
|
|||
assertThat(accessor.requireUserId( |
|||
UsernamePasswordAuthenticationToken.authenticated( |
|||
String.valueOf(Long.MAX_VALUE), null, List.of()))) |
|||
.isEqualTo(Long.MAX_VALUE); |
|||
} |
|||
|
|||
// ── 额外边界:不得让 NumberFormatException 逃逸 ─────────────────
|
|||
|
|||
@Test |
|||
void numericOverflowIsUnauthorizedNotNumberFormatException() { |
|||
Authentication overflow = UsernamePasswordAuthenticationToken.authenticated( |
|||
"99999999999999999999", null, List.of()); |
|||
|
|||
assertThatThrownBy(() -> accessor.requireUserId(overflow)) |
|||
.isInstanceOf(InsufficientAuthenticationException.class) |
|||
.isNotInstanceOf(NumberFormatException.class); |
|||
} |
|||
|
|||
@Test |
|||
void plusSignAndWhitespacePrincipalsAreUnauthorized() { |
|||
assertUnauthorized(() -> accessor.requireUserId( |
|||
UsernamePasswordAuthenticationToken.authenticated("+42", null, List.of()))); |
|||
assertUnauthorized(() -> accessor.requireUserId( |
|||
UsernamePasswordAuthenticationToken.authenticated(" 42", null, List.of()))); |
|||
assertUnauthorized(() -> accessor.requireUserId( |
|||
UsernamePasswordAuthenticationToken.authenticated("42 ", null, List.of()))); |
|||
} |
|||
|
|||
// ── SecurityContext 版本 ────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void requireUserIdReadsFromSecurityContext() { |
|||
assertUnauthorized(() -> accessor.requireUserId()); |
|||
|
|||
SecurityContextHolder.getContext().setAuthentication( |
|||
UsernamePasswordAuthenticationToken.authenticated("7", null, List.of())); |
|||
assertThat(accessor.requireUserId()).isEqualTo(7L); |
|||
|
|||
SecurityContextHolder.getContext().setAuthentication( |
|||
new AnonymousAuthenticationToken("k", "anonymousUser", |
|||
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"))); |
|||
assertUnauthorized(() -> accessor.requireUserId()); |
|||
} |
|||
|
|||
private void assertUnauthorized(Runnable action) { |
|||
assertThatThrownBy(action::run) |
|||
.isInstanceOf(InsufficientAuthenticationException.class); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue