From b5084fdd9c28f4fe20721e1bd77e98beee802f41 Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Mon, 14 Sep 2026 10:12:25 +0800 Subject: [PATCH] fix: harden SMS authentication and job access control --- docs/acceptance-week1.md | 6 +- docs/api-v0.1.md | 4 +- docs/frontend-integration.md | 23 +- docs/offer-api.md | 2 +- docs/openapi-rc.json | 4 +- docs/resume-api.md | 2 +- ...1-independent-mysql-acceptance-20260911.md | 483 +++++++++++ ...1-independent-mysql-acceptance-20260914.md | 288 +++++++ docs/s01-remediation-f1f2f3-20260911.md | 786 ++++++++++++++++++ docs/smoke-test.sh | 225 ++++- docs/tests/smoke-test-schema-contract-test.sh | 144 ++++ docs/tests/smoke-test-sms-code-test.sh | 548 ++++++++++++ .../java/com/mydreamjob/auth/AuthService.java | 3 +- .../auth/JwtAuthenticationFilter.java | 18 +- .../auth/SmsVerificationService.java | 35 +- .../auth/sms/MockSmsCodeSender.java | 23 + .../mydreamjob/auth/sms/SmsCodeSender.java | 24 + .../auth/sms/SmsSenderConfiguration.java | 37 + .../auth/sms/UnavailableSmsCodeSender.java | 25 + .../com/mydreamjob/config/SecurityConfig.java | 3 +- .../mydreamjob/controller/AuthController.java | 2 +- .../com/mydreamjob/job/JobController.java | 27 +- .../security/CurrentUserAccessor.java | 82 ++ ...AuthenticationBoundaryIntegrationTest.java | 431 ++++++++++ ...msEmptyPropertyStartupIntegrationTest.java | 79 ++ .../auth/SmsSenderConfigurationTest.java | 230 +++++ ...vailableSenderRollbackIntegrationTest.java | 115 +++ .../auth/SmsVerificationSecurityTest.java | 332 ++++++++ .../auth/SmsVerificationServiceTest.java | 4 + .../security/CurrentUserAccessorTest.java | 152 ++++ 30 files changed, 4054 insertions(+), 83 deletions(-) create mode 100644 docs/s01-independent-mysql-acceptance-20260911.md create mode 100644 docs/s01-independent-mysql-acceptance-20260914.md create mode 100644 docs/s01-remediation-f1f2f3-20260911.md create mode 100644 docs/tests/smoke-test-schema-contract-test.sh create mode 100644 docs/tests/smoke-test-sms-code-test.sh create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/MockSmsCodeSender.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsEmptyPropertyStartupIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSenderConfigurationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsUnavailableSenderRollbackIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/security/CurrentUserAccessorTest.java diff --git a/docs/acceptance-week1.md b/docs/acceptance-week1.md index 14e6355..09a0544 100644 --- a/docs/acceptance-week1.md +++ b/docs/acceptance-week1.md @@ -91,7 +91,7 @@ curl http://localhost:8080/api/v1/info # 发送验证码 curl -X POST "http://localhost:8080/api/v1/auth/sms/send?phone=13800000001&purpose=login" -# Mock 登录(从日志获取验证码) +# Mock 登录(验证码不输出到日志;本地按 docs/frontend-integration.md §3 受控取得) curl -X POST http://localhost:8080/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"phone":"13800000001","code":"<验证码>"}' @@ -145,11 +145,11 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev \ | STUDENT | 13800000002 | 学生测试 | | COMPANY | 13800000003 | 企业测试 | -> 注: 使用 Mock 短信验证码登录,验证码从应用日志中获取 +> 注: 使用 Mock 短信验证码登录。应用日志**不再输出验证码**,本地按 `docs/frontend-integration.md` §3 的受控方式取得(需 `SMS_MOCK_ENABLED=true` 且 `ALLOW_TEST_OTP_DB_READ=true`);生产环境禁止查询验证码表。 ## 已知限制 -1. **短信验证码 Mock**: 验证码打印到日志,不发送真实短信 +1. **短信验证码 Mock**: 应用日志**不输出验证码**,不发送真实短信;本地取码见 `docs/frontend-integration.md` §3(验证码当前仍为数据库明文存储) 2. **业务模块**: 岗位、投递、聊天、评分等模块仅定义接口骨架 3. **RabbitMQ 监听**: 队列已定义但未实现消费者(Week 2+) 4. **Actuator 健康**: RabbitMQ 健康检查未配置(仅 ping) diff --git a/docs/api-v0.1.md b/docs/api-v0.1.md index 2eecb67..8892115 100644 --- a/docs/api-v0.1.md +++ b/docs/api-v0.1.md @@ -24,7 +24,7 @@ Query: phone=13800000001, purpose=login **限制:** - 60秒内同一手机号只能发送1次 - 5次错误后锁定5分钟 -- Mock环境验证码打印到日志 +- Mock 环境验证码**不输出到日志**;本地按 `docs/frontend-integration.md` §3 的受控方式取得(生产禁止查表) ### 2. Mock 登录 ``` @@ -171,7 +171,7 @@ http://localhost:8080/v3/api-docs ## 已知限制 (Week 1) -1. 短信验证码为 Mock 实现,验证码打印到日志,不发送真实短信 +1. 短信验证码为 Mock 实现,应用日志**不输出验证码**,不发送真实短信;未接入真实供应商时发码返回“服务不可用” 2. 无企业注册/认证流程 3. 无岗位、投递、聊天、评分等业务功能 4. 无 Token 刷新接口(Week 2+) diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 8de2df2..a7f2a0c 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -34,17 +34,22 @@ mvn spring-boot:run -Dspring-boot.run.profiles=dev 健康检查:`http://127.0.0.1:8080/api/v1/health` 返回 `status: UP`。 -## 3. Mock 验证码日志位置 +## 3. 本地获取测试验证码(不通过日志) -开发环境短信为 Mock,验证码打印在**服务控制台/stdout 日志**中: +**应用日志不会输出验证码**,也不得从控制台 / stdout / 日志文件中读取验证码。 -``` -MOCK SMS CODE: phone=13800000002, purpose=login, code=483920 -``` +本地 dev/test 联调请使用受控方式取得验证码: + +1. 启动应用时显式开启 Mock 短信:`SMS_MOCK_ENABLED=true`(即 `app.sms.mock.enabled=true`) +2. 显式授权本次执行读取验证码表:`ALLOW_TEST_OTP_DB_READ=true` +3. 通过受控测试脚本或数据库内部只读查询取得验证码;脚本内部取码,不向终端展示 + +`docs/smoke-test.sh` 已按上述双重保护实现;两项条件任一不满足即输出 `test OTP retrieval is disabled` 并安全失败。 -- 运行 `mvn spring-boot:run` → 看终端输出 -- 后台运行 → 看重定向的日志文件(如 `logs/mydreamjob.log`) -- 每次发码后取**最新一条** `code=<6位数字>` +**约束:** +- 生产环境**禁止**查询验证码表辅助登录 +- 当前尚未接入真实短信供应商;`app.sms.mock.enabled` 为 `false`、缺失、空值或非法值时,发码返回“服务不可用”,绝不返回虚假成功 +- 验证码当前仍以**明文**存储于 `sms_verification_codes`,属已知遗留风险(未加密、未哈希) ## 4. Apifox 导入 OpenAPI @@ -141,7 +146,7 @@ Authorization: Bearer **登录方式**(开发环境 Mock): 1. `POST /auth/sms/send?phone=<手机号>` 发送验证码 -2. 验证码打印在服务日志:`MOCK SMS CODE: ... code=xxxxxx` +2. 验证码**不再输出到日志**;本地按 §3 的受控方式取得(生产禁止查表) 3. `POST /auth/login` 携带 `{phone, code, purpose:"login"}` 获取 token ## 7. 典型联调流程 diff --git a/docs/offer-api.md b/docs/offer-api.md index f455bfd..4ef02bf 100644 --- a/docs/offer-api.md +++ b/docs/offer-api.md @@ -259,7 +259,7 @@ POST /offers/{id}/remind > 现网 OpenAPI 亦可访问 `GET /api/v1/v3/api-docs` 导出后导入 Apifox。 -测试账号(Mock 短信验证码,验证码见应用日志): +测试账号(Mock 短信验证码:应用日志不再输出,本地按 `docs/frontend-integration.md` §3 的受控方式取得;生产禁止查表): | 角色 | 手机号 | 用途 | |---|---|---| diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index cecf596..5dcdd2f 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -1445,8 +1445,8 @@ "tags": [ "认证" ], - "summary": "发送短信验证码(Mock)", - "description": "开发环境 Mock:验证码会打印到日志", + "summary": "发送短信验证码", + "description": "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。", "operationId": "sendSmsCode", "parameters": [ { diff --git a/docs/resume-api.md b/docs/resume-api.md index 97cdf4b..e27a598 100644 --- a/docs/resume-api.md +++ b/docs/resume-api.md @@ -182,7 +182,7 @@ GET /resume-export-tasks/{taskId}/download 前置:启动基础设施 → 启动应用 → 导入 `docs/openapi-rc.json`(或 `GET /api/v1/v3/api-docs`)。 -1. 登录学生账号(Mock 验证码,见日志)。 +1. 登录学生账号(Mock 验证码:日志不再输出,本地按 `docs/frontend-integration.md` §3 的受控方式取得;生产禁止查表)。 2. `POST /profile/me/resume-versions`(multipart:`file` + `name=秋招版`)→ 200,`versionNo=1`。 3. 再次上传 → `versionNo=2`;设置 `name=社招版`。 4. `GET /profile/me/resume-versions` → 列表含两个版本。 diff --git a/docs/s01-independent-mysql-acceptance-20260911.md b/docs/s01-independent-mysql-acceptance-20260911.md new file mode 100644 index 0000000..97669e3 --- /dev/null +++ b/docs/s01-independent-mysql-acceptance-20260911.md @@ -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: ` → **F5(既有行为,见第 12 节)** | + +**审计表(audit_logs)**:完整手机号命中 **0**、非法 principal 命中 **0**、Token 命中 **0**、脱敏手机号命中 **1**(唯一 SMS_SEND 明细 `phone=199****9999`)✅ + +**允许项**:脱敏手机号、固定事件名、requestId、固定 purpose 均正常。 + +--- + +## 10. 运行时 OpenAPI(阶段 I) + +| 项 | 值 | +|---|---| +| 访问地址 | `GET /api/v1/v3/api-docs`(应用 `context-path=/api/v1`;裸 `/v3/api-docs` 返回 404 属预期) | +| HTTP | 200 | +| 运行时 paths / operations | **173 / 211** | +| 静态 paths / operations | 173 / 211 | +| 一致性 | **PASS** | +| 短信相关路径 | 仅 `/auth/sms/send`(无新增调试/取码接口) | +| `/jobs` 路径数 | 15 | +| `/auth/sms/send` 描述 | 已不再声称验证码输出到日志/控制台 ✅ | + +--- + +## 11. 文档一致性核查(阶段 13)—— **FAIL** + +**仍指导从日志读取验证码的文档(精确文件:行号)**: + +| 文件 | 行号 | 内容(已脱敏) | 性质 | +|---|---|---|---| +| `docs/acceptance-week1.md` | 94 | `# Mock 登录(从日志获取验证码)` | 历史验收记录 | +| `docs/acceptance-week1.md` | 148 | 注: 验证码从应用日志中获取 | 历史验收记录 | +| `docs/acceptance-week1.md` | 152 | 验证码打印到日志,不发送真实短信 | 历史验收记录 | +| `docs/api-v0.1.md` | 27 | Mock 环境验证码打印到日志 | 历史 API 文档 | +| `docs/api-v0.1.md` | 174 | 短信验证码为 Mock 实现,验证码打印到日志 | 历史 API 文档 | +| `docs/frontend-integration.md` | 37,39,42,144 | "Mock 验证码日志位置"、"打印在服务控制台/stdout 日志中"、旧示例行 `MOCK SMS CODE: phone=138****0002, … code=******` | **前端接入文档(在途修改文件,本轮不可改)** | +| `docs/offer-api.md` | 262 | 测试账号(Mock 验证码,见日志) | 活文档 | +| `docs/resume-api.md` | 189 | 登录学生账号(Mock 验证码,见日志) | **在途修改文件** | +| **`docs/smoke-test.sh`** | **64-71** | **`sms_code()`:`grep -a "MOCK SMS CODE.*phone=$PHONE" $log \| … \| grep -oE 'code=[0-9]{6}'`** | **git 跟踪的既有可执行脚本(见 F1)** | + +`README.md:167` 表述正确(禁止写入敏感信息),**不计入违规**;`docs/openapi-rc.json:1449` 已修正 ✅。 + +--- + +## 12. RabbitMQ 与外围终态(阶段 14) + +| 项 | 实测值 | +|---|---| +| 队列 | `queue.sms.verification`、`queue.notification`、`queue.audit`、`queue.notification.dlq` | +| ready / unacked | **全部 0 / 0** | +| mock=false 失败后 notifications 增量 | **0** | +| mock=false 失败后 SMS_SEND 审计增量 | **0**(无虚假发送成功审计) | +| 匿名岗位写请求副作用 | jobs/notifications/audit 均无新增 | +| 无与 S01 无关的表变化 | ✅(仅本轮验收夹具,已精确清理) | +| 容器终态 | 3 容器仍 Up(healthy);**未停止 Docker** | + +--- + +## 13. PASS / FAIL / SKIP 清单 + +| # | 验收项 | 结果 | +|---|---|---| +| A1 | 验证码/手机号不进入日志与审计 | **PASS** | +| A2 | SMS 发送器装配(true/false/缺失) | **PASS** | +| A2′ | 空值边界下的 Bean 唯一性 | **FAIL**(F3) | +| A3 | mock=false 失败事务回滚(真实 MySQL) | **PASS**(F4 为非阻断观察) | +| A4 | CurrentUserAccessor 边界 | **PASS** | +| A5 | JwtAuthenticationFilter 边界 | **PASS** | +| A6 | /jobs 匹配顺序与权限矩阵 | **PASS** | +| A7 | 无 permitAll 可达的直接身份解析 | **PASS**(存量 43 处均不可匿名到达) | +| A8 | OpenAPI 与响应兼容 | **PASS** | +| B | 自动化复验(40/214/1658/clean verify) | **PASS** | +| C | Docker/MySQL/Flyway | **PASS** | +| D | 三种配置启动 | **PASS**(D1/D2/D3a)| +| E | 真实 MySQL 回滚 + mock=true 链路 | **PASS** | +| F | 真实 HTTP 权限矩阵 | **PASS** | +| G | 非法 Token/principal 黑盒 | **PASS** | +| H | 日志与审计脱敏(`password` 项除外) | **PASS** | +| I | 运行时 OpenAPI | **PASS** | +| 13 | 文档不再指导从日志读取验证码 | **FAIL**(F1、F2) | +| 14 | RabbitMQ 与外围绕 | **PASS** | +| — | SKIP | **0**(核心验收项无 SKIP) | + +**FAIL 计数:3**(F1、F2、F3) + +--- + +## 14. 缺陷详情(可复现) + +### F1(阻断)项目自带冒烟脚本因 S01 改动而失效 + +- **文件/方法**:`docs/smoke-test.sh` → `sms_code()`(第 64-71 行,git 跟踪,提交 `b01616d`) +- **缺陷**:该函数依赖被 S01 删除的日志行 `MOCK SMS CODE: phone=…, code=…` 提取验证码 +- **复现**: + ```bash + LOG=<本轮 mock=true 应用日志> + grep -a "MOCK SMS CODE.*phone=<验收手机号>" "$LOG" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2 + ``` +- **实际结果**:输出为空 → `SMS=""` → 后续登录步骤拿不到验证码,冒烟流程中断 +- **期望结果**:脚本应从数据库/受控夹具取得验证码(与 S01 新语义一致) +- **影响**:既有可执行验收工具**功能性回归**;S01 整改报告将其列为"文档遗留"但未识别此处为**可执行依赖** + +### F2(阻断,对应验收标准第 17 项)文档仍指导从日志读取验证码 + +- **文件/行号**:见第 11 节表格(`acceptance-week1.md:94,148,152`、`api-v0.1.md:27,174`、`frontend-integration.md:37,39,42,144`、`offer-api.md:262`、`resume-api.md:189`) +- **实际结果**:文档仍声称"验证码打印到日志/控制台"并指导据此取码 +- **期望结果**:明确"日志不再输出验证码";dev/test 通过**受控测试夹具或数据库内部验收方式**取码;禁止生产查询验证码表完成登录;说明 `mock=false` 且无真实供应商时返回服务不可用;真实短信供应商待单独接入 +- **约束**:`docs/frontend-integration.md`、`docs/resume-api.md` 属**在途修改文件**,本轮不得改动 + +### F3(非阻断但违反 A2 明确要求)空值配置导致启动失败 + +- **文件**:`auth/sms/MockSmsCodeSender.java`(`@ConditionalOnProperty(name="app.sms.mock.enabled", havingValue="true")`)与 `auth/sms/UnavailableSmsCodeSender.java`(`havingValue="false", matchIfMissing=true`) +- **缺陷**:属性**存在但为空值**(如 env 中 `SMS_MOCK_ENABLED=`)时,两个条件都不匹配 → **0 个 `SmsCodeSender` Bean** +- **复现**:`java -jar target/mydreamjob-server-*.jar --app.sms.mock.enabled=` (其余同 D2) +- **实际结果**: + ``` + APPLICATION FAILED TO START + Parameter 3 of constructor in com.mydreamjob.auth.SmsVerificationService required + a bean of type 'com.mydreamjob.auth.sms.SmsCodeSender' that could not be found. + ``` +- **期望结果**:任何取值(含空值/空白)都应至少装配一个实现,绝不因缺少 Bean 以模糊 Spring Bean 错误启动失败(原工作令第 11 条) +- **影响**:部署可用性风险(空 env 变量在生产会致全站不可启动);未造成数据泄露 + +### F4(观察,非阻断)失败后残留限流键 + +- 现象:mock=false 发送失败后 `sms:rate:` 残留(TTL 60s),60s 内重试返回 `code=400 sms rate limited` 而非 `sms sender unavailable` +- 定性:**既有排序**(限流键在发送前写入),S01 未改变该顺序;MySQL 无残留有效验证码;不属冻结阻断项 +- 建议:后续轮次评估失败时释放限流键或调整写入顺序 + +### F5(观察,非阻断,既有行为)Spring Boot 默认安全口令入日志 + +- 现象:日志出现 `Using generated security password: ` +- 定性:Spring Boot `UserDetailsServiceAutoConfiguration` 默认行为;**经历史日志比对确认 S01 之前即存在**(`mydreamjob-server/logs/backend-startup.log` 等) +- 可利用性:应用未启用 httpBasic/formLogin,该口令无 HTTP 认证入口消费 → 不可用于登录 +- 建议:后续轮次排除 `UserDetailsServiceAutoConfiguration` 或显式声明 `AuthenticationManager` + +--- + +## 15. 剩余风险 + +1. **F1/F2 未修复前,开发/测试人员仍会按文档从日志找验证码 → 必然失败并误判"系统故障"** +2. 存量 43 处直接身份解析(均不可匿名到达,但缺乏统一 `CurrentUserAccessor` 收口);`DeletionGuardInterceptor` 对 20 位数字溢出仍可能 500 +3. 验证码仍以明文存储于 `sms_verification_codes.code`(本轮未改表结构,属已知遗留) +4. 真实短信供应商未接入;生产 `mock=false` 下所有发码请求均安全失败(设计如此,但需产品确认上线策略) +5. 安全层 401 响应不含 `requestId`(既有 `AuthenticationEntryPoint` 固定载荷),与业务层错误响应格式不一致,不利于链路追踪 +6. 本轮验收脚本依赖 Git Bash 环境变量语义(vhost 转换问题已规避并记录) + +--- + +## 16. 是否允许冻结 S01 + +**不允许冻结。** + +自动化指标(1658/1658、clean verify、静态与运行时 OpenAPI 173/211)全部达成,核心安全目标(日志脱敏、`SMS_MOCK_ENABLED` 生效、匿名 POST /jobs 401、非法 subject 401 不回显、真实 MySQL 回滚)**均已独立复验通过**;但存在 3 项 FAIL(F1 可执行脚本回归、F2 文档违规指导、F3 空值启动失败),其中 F2 直接命中验收标准第 17 项,F1 为可执行功能回归。 + +--- + +## 17. S01 下一轮定向整改指令 + +### R1 —— 修复冒烟脚本取码方式(对应 F1) + +- **文件**:`docs/smoke-test.sh` +- **方法**:`sms_code()` +- **错误数据/逻辑**:`grep -a "MOCK SMS CODE.*phone=$PHONE" "$log_path" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2` +- **复现请求**:`bash docs/smoke-test.sh`(mock=true 环境,日志中已无该行) +- **实际结果**:返回空字符串,登录步骤失败 +- **期望结果**:改为受控来源取码,二选一—— + 1. 数据库内部查询(推荐,仅限本地/测试): + `SELECT code FROM sms_verification_codes WHERE phone=? AND purpose='login' AND used=0 ORDER BY id DESC LIMIT 1;` + 2. 或由测试夹具直接注入固定验证码记录后读取 +- **必须新增的失败测试**:新增独立脚本断言 `sms_code()` 在"日志不含 `MOCK SMS CODE`"时仍能返回 6 位码(当前实现必然失败) +- **禁止的绕过**:不得恢复 `MOCK SMS CODE` 日志行;不得让脚本读取生产日志取码 + +### R2 —— 修正文档中的取码指引(对应 F2,命中验收标准第 17 项) + +- **文件与行号**:`docs/acceptance-week1.md:94,148,152`;`docs/api-v0.1.md:27,174`;`docs/offer-api.md:262`;`docs/frontend-integration.md:37,39,42,144`;`docs/resume-api.md:189` +- **错误内容**:声称"验证码打印到日志/控制台"并指导据此取码(含旧示例行) +- **复现**:`rg -n "控制台|日志.*验证码|MOCK SMS|验证码.*日志|查看验证码" docs README.md` +- **实际结果**:上述 10 处仍存在 +- **期望结果**:统一替换为—— + - 日志不再输出验证码; + - dev/test 环境通过**受控测试夹具或数据库内部验收方式**取得验证码; + - **禁止生产环境查询验证码表完成登录**; + - `mock=false` 且未接入真实供应商时返回"服务不可用",绝不返回虚假成功; + - 真实短信供应商仍待单独接入。 +- **协调要求**:`docs/frontend-integration.md`、`docs/resume-api.md` 属**简历上传安全**在途修改文件,需与该项变更方**合并处理**,避免互相覆盖 +- **必须新增的失败测试**:新增文档门禁测试(或 CI 检查)断言 `docs/**` 与 `README.md` 不再匹配 `MOCK SMS CODE|验证码.*打印.*日志|控制台.*验证码|见日志.*验证码` +- **禁止的绕过**:不得删除或改写为同义表述;不得仅在 README 声明而保留违规正文 + +### R3 —— 空值配置不再导致启动失败(对应 F3) + +- **文件**:`auth/sms/MockSmsCodeSender.java`、`auth/sms/UnavailableSmsCodeSender.java`(必要时新增无条件兜底实现) +- **错误配置**:`app.sms.mock.enabled=`(存在但空) +- **复现请求**:`java -jar target/mydreamjob-server-0.0.1-SNAPSHOT.jar --app.sms.mock.enabled=`(DB/Redis/RabbitMQ 同 D2) +- **实际结果**:`APPLICATION FAILED TO START … required a bean of type 'SmsCodeSender' that could not be found` +- **期望结果**:启动成功并装配 `UnavailableSmsCodeSender`(空值、空白值、缺失、`false` 四者行为一致;仅显式 `true` 才装配 Mock) +- **必须新增的失败测试**(`SmsSenderConfigurationTest` 内新增,不改动既有断言): + - `emptyPropertyStillProvidesUnavailableSender` + - `blankPropertyStillProvidesUnavailableSender` + - `exactlyOneSenderBeanForEveryValueIncludingEmpty` +- **禁止的绕过**:不得让 Mock 成为 `matchIfMissing` 默认;不得用 `@Primary` 掩盖歧义;不得让应用以空 Bean 启动(必须真实拒绝发码,不得伪造成功) + +### R4 —— 非阻断建议(可与上轮合并,不单独阻断冻结) + +1. `DeletionGuardInterceptor:60` 增加溢出安全解析(复用 `CurrentUserAccessor` 语义) +2. 评估失败时释放 `sms:rate:` 限流键(F4) +3. 排除 `UserDetailsServiceAutoConfiguration` 或显式声明 `AuthenticationManager`(F5) +4. 统一为安全层 401 响应补充 `requestId`(便于链路追踪) + +--- + +## 18. 验收产物清单(`docs/acceptance/s01-20260911/`) + +| 文件 | 用途 | +|---|---| +| `launch.sh` | 三种配置启动器(true/false/MISSING/EMPTY) | +| `gen-token.js` | 验收专用 HS256 Token 离线生成(密钥仅取自环境变量,不落盘) | +| `matrix.sh` / `matrix-results.txt` | 阶段 F 权限矩阵脚本与结果 | +| `phase-g.sh` / `phase-g-results.txt` | 阶段 G 非法 Token 边界脚本与结果 | +| `snapshot-jobs.sql` / `jobs-before.txt` / `jobs-after.txt` / `jobs-before-g.txt` / `jobs-after-g.txt` | jobs 表完整字段快照与对比 | +| `snapshot-sms.sql` / `before-mockfalse.txt` / `after-mockfalse.txt` | 阶段 E 验证码表回滚对比 | +| `job-body.json` / `tamper-body.json` / `status-body.json` / `ok-body.json` | 请求体夹具(ASCII,避免 shell 编码问题) | +| `d1*/d2*/d3*` 日志 | 三次配置启动的应用日志与 stdout | +| `runtime-openapi.json` | 运行时 OpenAPI 快照 | +| `acc-fixture.txt` / `acc-prefix.txt` | 验收夹具 ID 记录(已清理) | +| `sensitive-markers.txt` | 敏感标记清单(内容未写入报告) | + +**验收数据清理**:按精确 ID 与前缀删除 users(7933-7937)/companies(22,23)/jobs(42,43)/sms_verification_codes(1),Redis 验收键已清理;**保留**本轮窗口内 2 条 audit_logs(append-only 合规记录,不做删除);`jobs` 总数回到基线 26。清理前后均确认目标归属,无模糊 DELETE。 + +--- + +**已复现具体缺陷,S01不可冻结;验收报告已包含下一轮定向整改指令。** diff --git a/docs/s01-independent-mysql-acceptance-20260914.md b/docs/s01-independent-mysql-acceptance-20260914.md new file mode 100644 index 0000000..86f2d58 --- /dev/null +++ b/docs/s01-independent-mysql-acceptance-20260914.md @@ -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),是否停止由负责人决定。 diff --git a/docs/s01-remediation-f1f2f3-20260911.md b/docs/s01-remediation-f1f2f3-20260911.md new file mode 100644 index 0000000..8a491e5 --- /dev/null +++ b/docs/s01-remediation-f1f2f3-20260911.md @@ -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 "` 的 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 = '' AND 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黑盒复验。** diff --git a/docs/smoke-test.sh b/docs/smoke-test.sh index b363088..7022457 100644 --- a/docs/smoke-test.sh +++ b/docs/smoke-test.sh @@ -1,20 +1,56 @@ #!/usr/bin/env bash + +# ── xtrace 防护(S01 第三轮 F4)─────────────────────────────── +# 必须在读取任何环境变量、组装任何请求之前关闭 xtrace。 +# 脚本是独立可执行程序,安全优先:全程不重新 set -x, +# 避免验证码 / 完整手机号 / LOGIN_BODY / 登录响应 / TOKEN / AUTH 进入 stderr。 +case "$-" in + *x*) set +x ;; +esac + set -u # MyDreamJob backend RC smoke test. -# Required tools: curl, grep, sed, head, tail. +# Required tools: curl, grep, sed, head, tail, docker(仅取测试验证码时)。 # Environment: -# BASE_URL API base URL (default: http://127.0.0.1:8080/api/v1) -# STUDENT_PHONE Fresh 11-digit phone; generated when omitted -# COMPANY_TOKEN Company JWT; company status step is SKIP when omitted -# SERVER_LOG Server log path used to read Mock SMS codes +# BASE_URL API base URL (default: http://127.0.0.1:8080/api/v1) +# STUDENT_PHONE Fresh 11-digit phone; generated when omitted +# COMPANY_TOKEN Company JWT; company status step is SKIP when omitted +# SMS_MOCK_ENABLED=true **必需**:声明被测应用以 app.sms.mock.enabled=true 运行 +# ALLOW_TEST_OTP_DB_READ=true **必需**:显式授权本次本地测试从数据库读取验证码 +# +# 安全说明(S01 定向整改 F1 / 第三轮 F4+F5): +# * 应用日志**不再**输出验证码,本脚本也**不得**从日志/控制台/stdout 读取验证码。 +# * 仅在 [SMS_MOCK_ENABLED=true] **且** [ALLOW_TEST_OTP_DB_READ=true] 时, +# 才通过容器内部只读 SQL 取得验证码;任一条件不满足立即安全失败。 +# * 验证码只保存在 shell 局部变量 / REPLY 约定中,不写日志、不写文件、不输出到终端。 +# * **xtrace 永远关闭**:即使以 `bash -x` 调用,脚本开头即 set +x 且全程不恢复。 +# * 数据库凭据通过容器进程环境 MYSQL_PWD 传递,**不进入 mysql 命令行参数**; +# 使用应用账号 MYSQL_USER(不使用 root,缺失即安全失败)。 +# * 不接入真实短信供应商;生产环境禁止使用该取码方式。 BASE_URL="${BASE_URL:-http://127.0.0.1:8080/api/v1}" -SERVER_LOG="${SERVER_LOG:-}" PASS=0 FAIL=0 SKIP=0 +# ── 凭据变量登记(退出时统一清理)───────────────────────────── +# 覆盖全部敏感变量:手机号、验证码、登录请求体、登录响应、JWT、Authorization 头。 +# 注意:不得在此处给 PHONE / STUDENT_PHONE / COMPANY_TOKEN 赋空值, +# 它们携带调用方传入值或由后续逻辑生成。 +SMS="" +OTP="" +REPLY="" +LOGIN_BODY="" +LOGIN="" +TOKEN="" +AUTH="" +cleanup_secrets() { + unset PHONE SMS OTP REPLY LOGIN_BODY LOGIN TOKEN AUTH 2>/dev/null || true + unset STUDENT_PHONE COMPANY_TOKEN 2>/dev/null || true +} +trap cleanup_secrets EXIT + if [ -n "${STUDENT_PHONE:-}" ]; then PHONE="$STUDENT_PHONE" else @@ -27,20 +63,113 @@ ok() { echo "[PASS] $1"; PASS=$((PASS + 1)); } fail() { echo "[FAIL] $1"; FAIL=$((FAIL + 1)); } skip() { echo "[SKIP] $1"; SKIP=$((SKIP + 1)); } -resolve_log() { - case "$SERVER_LOG" in - [A-Za-z]:/*) - local drive rest - drive="$(printf '%s' "${SERVER_LOG:0:1}" | tr '[:upper:]' '[:lower:]')" - rest="${SERVER_LOG:2}" - if [ -d /mnt/c ]; then - echo "/mnt/$drive$rest" - else - echo "/$drive$rest" - fi - ;; - *) echo "$SERVER_LOG" ;; +# 手机号脱敏(控制台输出不展示完整号码) +mask_phone() { + local p="${1:-}" + if [ "${#p}" -lt 7 ]; then + printf '%s' '***' + return + fi + printf '%s****%s' "${p:0:3}" "${p: -4}" +} + +# ── 布尔解析(大小写/空白不敏感,仅 true 为真)──────────────── +is_true() { + local v + v="$(printf '%s' "${1:-}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')" + [ "$v" = "true" ] +} + +# ── 取码双重保护 ───────────────────────────────────────────── +# 保护 1:被测应用必须显式处于 mock 短信模式 +require_mock_sms_mode() { + is_true "${SMS_MOCK_ENABLED:-}" +} + +# 保护 2:本次执行必须显式授权读取验证码表 +require_test_otp_db_read_permission() { + is_true "${ALLOW_TEST_OTP_DB_READ:-}" +} + +# 从项目实际 compose 文件解析 mysql 容器名(不硬编码、不假设) +# 使用 BASH_SOURCE 以便脚本被 `source` 时仍能正确定位自身目录 +compose_mysql_container() { + local self base compose + self="${BASH_SOURCE[0]:-$0}" + base="$(cd "$(dirname "$self")" && pwd)" + for compose in "$base/../backend-infra/compose.yaml" "$base/../backend-infra/docker-compose.yml"; do + [ -f "$compose" ] || continue + awk ' + /^[[:space:]]+mysql:[[:space:]]*$/ { in_mysql=1; next } + /^[[:space:]]+[A-Za-z0-9_-]+:[[:space:]]*$/ { in_mysql=0 } + in_mysql && /^[[:space:]]+container_name:[[:space:]]*/ { + sub(/^[[:space:]]*container_name:[[:space:]]*/, ""); print; exit + } + ' "$compose" + done +} + +# 容器内只读查询(F5:凭据经进程环境 MYSQL_PWD 传递,不进入 mysql argv;SQL 经 stdin) +# - 使用应用账号 MYSQL_USER(不使用 root;缺失即安全失败,不回退 root) +# - 仅允许 SELECT;查询失败返回非 0,由 sms_code() 输出固定安全错误 +otp_db_query() { + local phone="$1" purpose="$2" container + container="$(compose_mysql_container | head -n 1)" + [ -n "$container" ] || return 1 + + printf '%s\n' "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;" \ + | docker exec -i "$container" sh -c ' + # 应用账号凭据必需,缺失则安全失败(绝不回退 root) + if [ -z "${MYSQL_USER:-}" ] || [ -z "${MYSQL_PASSWORD:-}" ]; then + echo "db credentials unavailable" >&2 + exit 3 + fi + # 密码只经环境变量 MYSQL_PWD 传递,argv 中不出现 -p/--password + MYSQL_PWD="$MYSQL_PASSWORD" exec mysql \ + -u"$MYSQL_USER" \ + -N -B \ + "${MYSQL_DATABASE:-mydreamjob}"' 2>/dev/null \ + | head -n 1 +} + +# 取得验证码:成功时写入 REPLY;绝不打印到 stdout/stderr +sms_code() { + local disabled_msg="test OTP retrieval is disabled" + + if ! require_mock_sms_mode || ! require_test_otp_db_read_permission; then + echo "$disabled_msg" >&2 + return 1 + fi + + case "${PHONE:-}" in + *[!0-9]*|'') echo "$disabled_msg" >&2; return 1 ;; esac + [ "${#PHONE}" -eq 11 ] || { echo "$disabled_msg" >&2; return 1; } + + # xtrace 已在脚本开头全局关闭,这里无需(也不得)保存/恢复其状态 + local code="" qstatus=0 + IFS= read -r code < <(otp_db_query "$PHONE" "login") || qstatus=$? + + # 校验格式:当前验证码为 6 位数字(app.sms.mock.code-length / CODE_PATTERN) + local valid=1 + [ "$qstatus" -eq 0 ] || valid=0 + case "$code" in + [0-9][0-9][0-9][0-9][0-9][0-9]) : ;; + *) valid=0 ;; + esac + + if [ "$valid" -ne 1 ]; then + unset code + echo "test OTP retrieval failed" >&2 + return 1 + fi + + REPLY="$code" + unset code + return 0 } report() { @@ -53,6 +182,23 @@ report() { echo " http=$http_code ${biz_code:-code=N/A} ${request_id:-requestId=N/A}" } +# 仅接收可安全打印的三个字段,不接收任何响应体全文(用于登录等敏感响应) +report_fields() { + local http_code="$1" biz_code="$2" request_id="$3" + echo " http=$http_code ${biz_code:-code=N/A} ${request_id:-requestId=N/A}" +} + +# 从响应中只提取可安全打印字段(http / business code / requestId),不保留响应体 +extract_status_fields() { + local response="$1" + local http_code body biz_code request_id + http_code="$(printf '%s\n' "$response" | tail -n 1)" + body="$(printf '%s\n' "$response" | sed '$d')" + biz_code="$(printf '%s' "$body" | grep -oE '"code"[[:space:]]*:[[:space:]]*[0-9]+' | head -n 1 | grep -oE '[0-9]+' || true)" + request_id="$(printf '%s' "$body" | grep -oE '"requestId"[[:space:]]*:[[:space:]]*"[^"]*"' | head -n 1 || true)" + printf '%s\n%s\n%s' "$http_code" "$biz_code" "$request_id" +} + extract_token() { printf '%s' "$1" | sed -n 's/.*"accessToken"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1 } @@ -61,17 +207,16 @@ extract_id() { printf '%s' "$1" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -n 1 } -sms_code() { - local log_path - log_path="$(resolve_log)" - [ -n "$log_path" ] || return 1 - [ -f "$log_path" ] || return 1 - grep -a "MOCK SMS CODE.*phone=$PHONE" "$log_path" | tail -n 1 | grep -a -oE 'code=[0-9]{6}' | cut -d= -f2 -} +# ── 可测试性钩子 ─────────────────────────────────────────── +# 供 shell 单元测试加载函数(验证取码保护与泄漏防护)而不执行完整流程。 +# 正常运行(不设该变量)时完全不生效,不影响任何行为。 +if [ "${SMOKE_TEST_LIB_ONLY:-}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi echo "=== MyDreamJob backend smoke test ===" echo "BASE_URL=$BASE_URL" -echo "PHONE=$PHONE" +echo "PHONE=$(mask_phone "$PHONE")" say "GET /health" HEALTH="$(curl -sS -w '\n%{http_code}' "$BASE_URL/health" || true)" @@ -84,19 +229,33 @@ for attempt in 1 2 3; do curl -sS -X POST "$BASE_URL/auth/sms/send?phone=$PHONE&purpose=login" \ -H "X-Request-Id: smoke-sms-$attempt" >/dev/null 2>&1 || true sleep 2 - SMS="$(sms_code || true)" - [ -n "$SMS" ] && break + if sms_code; then + SMS="$REPLY" + unset REPLY + [ -n "$SMS" ] && break + fi done if [ -z "$SMS" ]; then - fail "cannot read Mock SMS code; set SERVER_LOG to the server log path" + fail "login skipped" + unset SMS TOKEN="" else - LOGIN="$(curl -sS -w '\n%{http_code}' -X POST "$BASE_URL/auth/login" \ + # 请求体经 stdin 传入(--data-binary @-),验证码不出现在进程参数中 + LOGIN_BODY="{\"phone\":\"$PHONE\",\"code\":\"$SMS\",\"purpose\":\"login\"}" + LOGIN="$(printf '%s' "$LOGIN_BODY" | curl -sS -w '\n%{http_code}' -X POST "$BASE_URL/auth/login" \ -H 'Content-Type: application/json' \ -H 'X-Request-Id: smoke-login-001' \ - -d "{\"phone\":\"$PHONE\",\"code\":\"$SMS\",\"purpose\":\"login\"}" || true)" - report "$LOGIN" + --data-binary @- || true)" + unset LOGIN_BODY + unset SMS + # 只保留可安全打印的三个字段;登录响应全文绝不交给打印函数,也绝不落盘 + LOGIN_FIELDS="$(extract_status_fields "$LOGIN")" TOKEN="$(extract_token "$LOGIN")" + unset LOGIN + report_fields "$(printf '%s' "$LOGIN_FIELDS" | sed -n '1p')" \ + "$(printf '%s' "$LOGIN_FIELDS" | sed -n '2p')" \ + "$(printf '%s' "$LOGIN_FIELDS" | sed -n '3p')" + unset LOGIN_FIELDS if [ -n "$TOKEN" ]; then ok "login"; else fail "login"; fi fi diff --git a/docs/tests/smoke-test-schema-contract-test.sh b/docs/tests/smoke-test-schema-contract-test.sh new file mode 100644 index 0000000..74a756d --- /dev/null +++ b/docs/tests/smoke-test-schema-contract-test.sh @@ -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 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 ] diff --git a/docs/tests/smoke-test-sms-code-test.sh b/docs/tests/smoke-test-sms-code-test.sh new file mode 100644 index 0000000..5a09f71 --- /dev/null +++ b/docs/tests/smoke-test-sms-code-test.sh @@ -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 ] diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java index b3a3bd1..66357a4 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthService.java @@ -57,7 +57,8 @@ public class AuthService { .verified(true) .build(); user = userRepository.save(user); - log.info("Auto-created user: id={}, phone={}", user.getId(), phone); + // 日志不得记录完整手机号 + log.info("Auto-created user: id={}", user.getId()); } else { user = userOpt.get(); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java index a24a4e9..ffbb21a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/JwtAuthenticationFilter.java @@ -34,13 +34,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { if (token != null && tokenProvider.validateToken(token)) { try { Long userId = tokenProvider.parseUserId(token); - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); - authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); - SecurityContextHolder.getContext().setAuthentication(authentication); - log.debug("Authenticated user {} from JWT", userId); + if (userId == null || userId <= 0) { + // 非法主体一律不建立认证;不回显 subject + log.warn("JWT authentication rejected: invalid subject"); + } else { + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("Authenticated user {} from JWT", userId); + } } catch (Exception e) { - log.warn("JWT authentication failed for token: {}", e.getMessage()); + // 固定安全文本:异常消息可能包含非法 principal 原文,一律不回显 + log.warn("JWT authentication rejected: unparsable subject"); } } filterChain.doFilter(request, response); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java index 4316d42..5910845 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/SmsVerificationService.java @@ -1,6 +1,7 @@ package com.mydreamjob.auth; import com.mydreamjob.audit.AuditService; +import com.mydreamjob.auth.sms.SmsCodeSender; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -18,8 +19,10 @@ import java.util.regex.Pattern; import java.util.concurrent.TimeUnit; /** - * Mock 短信验证码服务 - * 不接入真实短信网关,验证码直接生成并存入 Redis + * 短信验证码服务。 + * + *

验证码只写入短信验证码表,并通过 {@link SmsCodeSender} 投递; + * 任何级别的日志都不得包含验证码或完整手机号。

*/ @Slf4j @Service @@ -29,6 +32,7 @@ public class SmsVerificationService { private final SmsVerificationCodeRepository codeRepository; private final RedisTemplate redisTemplate; private final AuditService auditService; + private final SmsCodeSender smsCodeSender; @Value("${app.sms.mock.code-length:6}") private int codeLength; @@ -49,7 +53,10 @@ public class SmsVerificationService { private static final Pattern CODE_PATTERN = Pattern.compile("[0-9]{6}"); /** - * 发送验证码(Mock) + * 发送验证码。 + * + *

发送动作委派给 {@link SmsCodeSender}: + * {@code app.sms.mock.enabled=false} 或未配置时使用不可用实现,安全失败并回滚本次验证码落库。

*/ @Transactional public void sendCode(String phone, String purpose, @@ -59,14 +66,14 @@ public class SmsVerificationService { // 检查锁定 String lockoutKey = LOCKOUT_KEY_PREFIX + phone; if (redisTemplate.hasKey(lockoutKey)) { - log.warn("Phone {} is locked out", phone); + log.warn("SMS send rejected: phone locked out, phone={}", maskPhone(phone)); throw new BusinessException(ErrorCode.SMS_RATE_LIMITED); } // 检查频率限制(60秒内最多1次) String rateLimitKey = RATE_LIMIT_KEY_PREFIX + phone; if (redisTemplate.hasKey(rateLimitKey)) { - log.warn("Phone {} rate limited", phone); + log.warn("SMS send rejected: rate limited, phone={}", maskPhone(phone)); throw new BusinessException(ErrorCode.SMS_RATE_LIMITED); } @@ -89,14 +96,26 @@ public class SmsVerificationService { // 设置频率限制 redisTemplate.opsForValue().set(rateLimitKey, "1", 60, TimeUnit.SECONDS); - // Mock: 打印验证码到日志(不发送真实短信) - log.info("MOCK SMS CODE: phone={}, purpose={}, code={}", phone, purpose, code); + // 投递验证码:实现方保证不把验证码/完整手机号写入日志。 + // 不可用实现会在此抛出 SERVICE_UNAVAILABLE,令本次事务回滚(不产生虚假的“发送成功”)。 + smsCodeSender.sendVerificationCode(phone, purpose, code); + // 审计明细同样不得记录完整手机号 auditService.log(null, "SMS_SEND", "PHONE", null, - "Mock SMS sent to " + phone + " for " + purpose, + "SMS send accepted; phone=" + maskPhone(phone) + "; purpose=" + purpose, ipAddress, userAgent, requestId); } + /** + * 手机号脱敏:仅保留前 3 位与后 4 位,用于日志与审计明细。 + */ + private String maskPhone(String phone) { + if (phone == null || phone.length() < 7) { + return "***"; + } + return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } + /** * 验证验证码 * 按手机号+用途查询最新一条未使用记录,原子性标记已使用。 diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/MockSmsCodeSender.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/MockSmsCodeSender.java new file mode 100644 index 0000000..c2ba83d --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/MockSmsCodeSender.java @@ -0,0 +1,23 @@ +package com.mydreamjob.auth.sms; + +import lombok.extern.slf4j.Slf4j; + +/** + * Mock 短信发送实现。 + * + *

由 {@link SmsSenderConfiguration} 在 {@code app.sms.mock.enabled} 显式解析为 + * {@code true} 时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。

+ * + *

不接入真实短信网关,仅记录一条固定文本的发送事件。 + * 不得把验证码或完整手机号写入日志。

+ */ +@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)"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java new file mode 100644 index 0000000..41f1655 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsCodeSender.java @@ -0,0 +1,24 @@ +package com.mydreamjob.auth.sms; + +/** + * 短信验证码发送抽象。 + * + *

由 {@code app.sms.mock.enabled} 决定加载哪一个实现: + *

    + *
  • {@code true} → {@link MockSmsCodeSender}(本地/测试环境,不调用外部网关)
  • + *
  • {@code false} 或未配置 → {@link UnavailableSmsCodeSender}(安全失败,不伪造成功)
  • + *
+ * + *

实现方禁止把验证码或完整手机号写入任何级别的日志。 + */ +public interface SmsCodeSender { + + /** + * 发送验证码。 + * + * @param phone 接收手机号(实现方不得写入日志) + * @param purpose 验证码用途 + * @param code 验证码(实现方不得写入日志) + */ + void sendVerificationCode(String phone, String purpose, String code); +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java new file mode 100644 index 0000000..351a9d9 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/SmsSenderConfiguration.java @@ -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 方案)。 + * + *

由本配置类始终且仅产生一个 {@link SmsCodeSender},从而彻底避免 + * “两个互斥 {@code @ConditionalOnProperty} 在空值/空白值下都不匹配 → 0 个 Bean → + * 模糊 Spring 装配错误导致启动失败”的问题。

+ * + *

取值语义(大小写不敏感,两侧空白忽略):

+ *
    + *
  • {@code true} → {@link MockSmsCodeSender}
  • + *
  • {@code false} / 缺失 / 空字符串 / 仅空白 / 其他非法值 → {@link UnavailableSmsCodeSender}
  • + *
+ * + *

安全约束:只有显式 {@code true} 才启用 Mock;非法值一律安全回退到不可用实现, + * 既不启用 Mock,也不进入发送流程。本类不输出配置原值到日志。

+ */ +@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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java new file mode 100644 index 0000000..f0e5ee0 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/sms/UnavailableSmsCodeSender.java @@ -0,0 +1,25 @@ +package com.mydreamjob.auth.sms; + +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; + +/** + * 不可用短信发送实现。 + * + *

由 {@link SmsSenderConfiguration} 在 {@code app.sms.mock.enabled} 为 false、缺失、 + * 空值、仅空白或其他非法值时装配(不再使用条件注解,以保证任何取值下恰好一个 Bean)。

+ * + *

当前未接入真实短信网关,因此本实现安全失败: + * 抛出既有的“服务不可用”业务异常,绝不返回虚假的“发送成功”。 + * 异常消息为固定安全文本,不含手机号、验证码或任何配置细节。

+ */ +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); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index eb4f259..4f89c37 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -115,7 +115,8 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/jobs/*/personalized-review-score").authenticated() // 岗位公开查询 .requestMatchers("/jobs/public/**").permitAll() - .requestMatchers("/jobs").permitAll() + // 仅公开岗位列表为匿名可读;写操作必须命中下方的 /jobs/** → authenticated + .requestMatchers(HttpMethod.GET, "/jobs").permitAll() // 岗位搜索与筛选(公开) .requestMatchers(HttpMethod.GET, "/jobs/search").permitAll() .requestMatchers(HttpMethod.GET, "/jobs/filters").permitAll() diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java index c77d012..5d89002 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java @@ -38,7 +38,7 @@ public class AuthController { private final SmsVerificationService smsVerificationService; private final JwtTokenProvider jwtTokenProvider; - @Operation(summary = "发送短信验证码(Mock)", description = "开发环境 Mock:验证码会打印到日志") + @Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。") @PostMapping("/sms/send") public ResponseEntity>> sendSmsCode( @RequestParam @NotBlank(message = "phone is required") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java index d9ad7b6..826d96e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java @@ -1,12 +1,15 @@ package com.mydreamjob.job; import com.mydreamjob.common.api.ApiResponse; +import com.mydreamjob.common.exception.BusinessException; +import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.company.Company; import com.mydreamjob.company.CompanyService; import com.mydreamjob.job.dto.JobCreateRequest; import com.mydreamjob.job.dto.JobSearchItem; import com.mydreamjob.job.dto.JobStatusRequest; import com.mydreamjob.job.dto.JobUpdateRequest; +import com.mydreamjob.security.CurrentUserAccessor; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -14,8 +17,6 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.*; import java.util.HashMap; @@ -34,6 +35,7 @@ public class JobController { private final JobService jobService; private final CompanyService companyService; + private final CurrentUserAccessor currentUserAccessor; // ── 企业端:创建岗位 ──────────────────────────────────────── @@ -42,7 +44,7 @@ public class JobController { public ResponseEntity>> createJob( @Valid @RequestBody JobCreateRequest req, HttpServletRequest request) { - Long companyId = resolveCompanyId(request); + Long companyId = resolveCompanyId(); Map view = jobService.toView(jobService.createJob(companyId, req)); return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); } @@ -55,7 +57,7 @@ public class JobController { @PathVariable Long jobId, @Valid @RequestBody JobUpdateRequest req, HttpServletRequest request) { - Long companyId = resolveCompanyId(request); + Long companyId = resolveCompanyId(); Map view = jobService.toView(jobService.updateJob(companyId, jobId, req)); return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); } @@ -68,7 +70,7 @@ public class JobController { @PathVariable Long jobId, @Valid @RequestBody JobStatusRequest req, HttpServletRequest request) { - Long companyId = resolveCompanyId(request); + Long companyId = resolveCompanyId(); Map view = jobService.toView(jobService.transitionStatus(companyId, jobId, req)); return ResponseEntity.ok(ApiResponse.ok(view, getRequestId(request))); } @@ -80,7 +82,7 @@ public class JobController { public ResponseEntity>> getJob( @PathVariable Long jobId, HttpServletRequest request) { - Long companyId = resolveCompanyId(request); + Long companyId = resolveCompanyId(); // 验证归属后返回 jobService.assertOwnerOrThrow(companyId, jobId); Map view = jobService.toView(jobService.getJob(jobId)); @@ -163,17 +165,12 @@ public class JobController { // ── 辅助方法 ──────────────────────────────────────────────── - private Long resolveCompanyId(HttpServletRequest request) { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated()) { - throw new com.mydreamjob.common.exception.BusinessException( - com.mydreamjob.common.exception.ErrorCode.UNAUTHORIZED); - } - Long userId = Long.valueOf(auth.getName()); + private Long resolveCompanyId() { + // 身份只来自 JWT/SecurityContext;非法主体统一为未登录语义(401),不抛 NumberFormatException + Long userId = currentUserAccessor.requireUserId(); Company company = companyService.findCompanyByOwnerUserId(userId); if (company == null) { - throw new com.mydreamjob.common.exception.BusinessException( - com.mydreamjob.common.exception.ErrorCode.COMPANY_NOT_FOUND); + throw new BusinessException(ErrorCode.COMPANY_NOT_FOUND); } return company.getId(); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java b/mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java new file mode 100644 index 0000000..a47bfa7 --- /dev/null +++ b/mydreamjob-server/src/main/java/com/mydreamjob/security/CurrentUserAccessor.java @@ -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; + +/** + * 安全关键路径的当前用户解析器。 + * + *

身份只来自 JWT / SecurityContext,不接受任何请求参数传入的 userId。 + * 所有非法主体一律转换为既有的“未登录”语义({@link InsufficientAuthenticationException} + * 由 {@code GlobalExceptionHandler} 映射为 HTTP 401),从而避免 + * {@code NumberFormatException} 逃逸为 HTTP 500。

+ * + *

明确拒绝:Authentication 为 null、未认证、{@link AnonymousAuthenticationToken}、 + * 名称为空、名称为 anonymousUser、名称不是合法 Long、userId <= 0。

+ * + *

本类只负责身份解析,不承担角色校验与资源归属校验—— + * 那两类判断仍由既有业务逻辑负责。

+ */ +@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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java new file mode 100644 index 0000000..8424676 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/AuthenticationBoundaryIntegrationTest.java @@ -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)。 + * + *

覆盖任务书第三节与第九节 C 组: + * 匿名写请求必须是 401(修复前为 500),越权请求不得产生业务写入, + * 公开 GET 语义保持不变,且失败响应不得回显 anonymousUser、非法 principal 或内部类名。

+ */ +@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 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 appender; + private Logger appLogger; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations 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 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 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 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 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 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 before = snapshotJobs(); + + mockMvc.perform(delete("/jobs/" + jobId)) + .andExpect(status().isUnauthorized()); + + assertThat(snapshotJobs()).isEqualTo(before); + } + + @Test + void anonymousCannotPublishOrCloseJob() throws Exception { + List 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 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 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 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 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 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 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 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 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 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(); + } +} \ No newline at end of file diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsEmptyPropertyStartupIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsEmptyPropertyStartupIntegrationTest.java new file mode 100644 index 0000000..13db68f --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsEmptyPropertyStartupIntegrationTest.java @@ -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=} 空值下的 + * 全应用上下文启动。 + * + *

整改前该取值会使两个互斥 {@code @ConditionalOnProperty} 都不匹配 → 0 个 + * {@code SmsCodeSender} Bean → 应用以 + * {@code APPLICATION FAILED TO START ... required a bean of type 'SmsCodeSender'} + * 直接启动失败。

+ * + *

本测试以真实全量 Spring 上下文(H2 + Flyway)启动,证明:上下文启动成功、 + * {@link SmsVerificationService} 可注入、恰好一个 {@link SmsCodeSender}、 + * 类型为 {@link UnavailableSmsCodeSender},且调用发送时安全失败。

+ */ +@SpringBootTest(properties = {"app.sms.mock.enabled="}) +@ActiveProfiles("integration-test") +class SmsEmptyPropertyStartupIntegrationTest { + + private static final String PHONE = "19900005678"; + + @MockBean + private RedisTemplate 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())); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSenderConfigurationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSenderConfigurationTest.java new file mode 100644 index 0000000..fade500 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsSenderConfigurationTest.java @@ -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 / 缺失 / 空值 / 仅空白 / 大写 / 非法值下都必须装配 + * 恰好一个 {@link SmsCodeSender},且只有显式 true 才启用 Mock。 + * + *

全部用例使用真实 {@link ApplicationContextRunner} 装配(不直接 new 配置类、 + * 不测试字符串解析辅助函数、不使用 Mockito 验证)。

+ * + *

{@link SenderConsumerProbe} 是消费者探针:它以构造参数方式依赖 + * {@link SmsCodeSender},用于复现“构造注入时缺少 Bean”的原始缺陷场景; + * 它不生产 any SmsCodeSender,因此不会掩盖生产 Bean 缺失。

+ */ +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 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); + }); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsUnavailableSenderRollbackIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsUnavailableSenderRollbackIntegrationTest.java new file mode 100644 index 0000000..8f96813 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsUnavailableSenderRollbackIntegrationTest.java @@ -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 定向整改 —— 补充回归:不可用发送实现下的事务回滚。 + * + *

本测试使用真实 Spring 上下文 + 真实 H2 + Flyway + 真实 + * {@link SmsVerificationService} 与真实 {@code @Transactional} 代理, + * 并在本测试类中把 {@code app.sms.mock.enabled} 覆盖为 {@code false}, + * 因此容器装配的是 {@link UnavailableSmsCodeSender}。

+ * + *

测试类本身不加 {@code @Transactional},以保证 + * {@code sendCode} 的事务独立提交/回滚,从而真实验证“发送失败不残留可用验证码”。

+ */ +@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 redisTemplate; + + @Autowired + private ApplicationContext applicationContext; + + @Autowired + private SmsVerificationService smsVerificationService; + + @Autowired + private SmsVerificationCodeRepository codeRepository; + + @Autowired + private AuditLogRepository auditLogRepository; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + ValueOperations 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 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java new file mode 100644 index 0000000..bde98a2 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationSecurityTest.java @@ -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 短信安全:验证码与手机号不得泄露到日志。 + * + *

本测试不使用 Mockito 校验日志,而是挂载真实 Logback {@link ListAppender} + * 捕获实际日志事件后做字符串断言。

+ * + *

生产语义不动:长度、有效期、最大尝试次数、锁定时间、一次性消费保持不变。

+ */ +@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 redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Mock + private AuditService auditService; + + @Mock + private SmsCodeSender smsCodeSender; + + @InjectMocks + private SmsVerificationService service; + + private Logger rootLogger; + private Logger appLogger; + private ListAppender 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 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 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 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 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 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 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(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java index d3b689d..2953819 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/auth/SmsVerificationServiceTest.java @@ -1,6 +1,7 @@ package com.mydreamjob.auth; import com.mydreamjob.audit.AuditService; +import com.mydreamjob.auth.sms.SmsCodeSender; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import org.junit.jupiter.api.BeforeEach; @@ -35,6 +36,9 @@ class SmsVerificationServiceTest { @Mock private AuditService auditService; + @Mock + private SmsCodeSender smsCodeSender; + @InjectMocks private SmsVerificationService smsVerificationService; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/security/CurrentUserAccessorTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/security/CurrentUserAccessorTest.java new file mode 100644 index 0000000..a4cdc02 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/security/CurrentUserAccessorTest.java @@ -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 当前用户解析安全边界。 + * + *

覆盖任务书要求的 8 类输入,并额外覆盖 Long 溢出与“已认证但名称为 anonymousUser”的绕过尝试。 + * 所有非法输入必须转换为既有“未登录”语义({@link InsufficientAuthenticationException}, + * 由全局异常处理器映射为 401),且不回显非法 principal。

+ */ +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); + } +}