Browse Source

feat: add secure phone password authentication

master
Zhang Shun 1 month ago
parent
commit
00153aaf5d
  1. 233
      docs/openapi-rc.json
  2. 796
      docs/phone-password-authentication-design.md
  3. 93
      docs/phone-password-authentication-implementation-report.md
  4. 101
      docs/phone-password-authentication-independent-verification-report.md
  5. 27
      docs/verify_openapi.py
  6. 24
      mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java
  7. 3
      mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java
  8. 13
      mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java
  9. 10
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordChangeRequest.java
  10. 15
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordLoginRequest.java
  11. 3
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordOperationResponse.java
  12. 12
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordResetRequest.java
  13. 10
      mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetRequest.java
  14. 43
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationProperties.java
  15. 153
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java
  16. 43
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordPolicy.java
  17. 150
      mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordRateLimiter.java
  18. 3
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java
  19. 4
      mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java
  20. 4
      mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java
  21. 86
      mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java
  22. 11
      mydreamjob-server/src/main/resources/application.yml
  23. 5
      mydreamjob-server/src/main/resources/db/migration/V46__password_authentication.sql
  24. 4
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  25. 180
      mydreamjob-server/src/test/java/com/mydreamjob/auth/PasswordAuthenticationConcurrencyMySqlTest.java
  26. 66
      mydreamjob-server/src/test/java/com/mydreamjob/auth/PasswordAuthenticationDisabledIntegrationTest.java
  27. 216
      mydreamjob-server/src/test/java/com/mydreamjob/auth/PasswordAuthenticationIntegrationTest.java
  28. 44
      mydreamjob-server/src/test/java/com/mydreamjob/auth/password/PasswordPolicyTest.java
  29. 116
      mydreamjob-server/src/test/java/com/mydreamjob/auth/password/PasswordRateLimiterRedisIntegrationTest.java
  30. 88
      mydreamjob-server/src/test/java/com/mydreamjob/auth/password/PasswordRateLimiterTest.java
  31. 7
      mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java
  32. 17
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java
  33. 14
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java
  34. 20
      mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java
  35. 8
      mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java
  36. 8
      mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java
  37. 5
      mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv

233
docs/openapi-rc.json

@ -9301,6 +9301,131 @@
}
}
}
},
"/auth/password/set": {
"post": {
"tags": [
"认证"
],
"summary": "首次设置密码",
"operationId": "setPassword",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasswordSetRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponsePasswordOperationResponse"
}
}
}
}
}
}
},
"/auth/password/reset": {
"post": {
"tags": [
"认证"
],
"summary": "短信找回并重置密码",
"operationId": "resetPassword",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasswordResetRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponsePasswordOperationResponse"
}
}
}
}
}
}
},
"/auth/password/login": {
"post": {
"tags": [
"认证"
],
"summary": "手机号密码登录",
"description": "使用手机号和密码登录,返回 Access/Refresh Token;不自动创建账号",
"operationId": "passwordLogin",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasswordLoginRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponseMapStringObject"
}
}
}
}
}
}
},
"/auth/password/change": {
"post": {
"tags": [
"认证"
],
"summary": "修改密码",
"operationId": "changePassword",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasswordChangeRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/ApiResponsePasswordOperationResponse"
}
}
}
}
}
}
}
},
"components": {
@ -15823,6 +15948,114 @@
"$ref": "#/components/schemas/TokenPairResponse"
}
}
},
"PasswordSetRequest": {
"type": "object",
"properties": {
"smsCode": {
"type": "string",
"minLength": 1
},
"newPassword": {
"type": "string",
"minLength": 1
}
},
"required": [
"newPassword",
"smsCode"
]
},
"PasswordResetRequest": {
"type": "object",
"properties": {
"phone": {
"type": "string",
"minLength": 1,
"pattern": "[0-9]{11}"
},
"smsCode": {
"type": "string",
"minLength": 1
},
"newPassword": {
"type": "string",
"minLength": 1
}
},
"required": [
"newPassword",
"phone",
"smsCode"
]
},
"PasswordLoginRequest": {
"type": "object",
"properties": {
"phone": {
"type": "string",
"minLength": 1,
"pattern": "[0-9]{11}"
},
"password": {
"type": "string",
"minLength": 1
}
},
"required": [
"password",
"phone"
]
},
"PasswordChangeRequest": {
"type": "object",
"properties": {
"currentPassword": {
"type": "string",
"minLength": 1
},
"newPassword": {
"type": "string",
"minLength": 1
}
},
"required": [
"currentPassword",
"newPassword"
]
},
"PasswordOperationResponse": {
"type": "object",
"properties": {
"passwordConfigured": {
"type": "boolean"
},
"reauthenticationRequired": {
"type": "boolean"
}
}
},
"ApiResponsePasswordOperationResponse": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"format": "int32"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/PasswordOperationResponse"
},
"requestId": {
"type": "string"
},
"timestamp": {
"type": "string",
"format": "date-time"
}
}
}
},
"securitySchemes": {

796
docs/phone-password-authentication-design.md

@ -0,0 +1,796 @@
# 手机号密码认证设计
> 文档类型:设计说明与实现参考
> 状态:设计已确认,可进入实施;尚未编码
> 适用项目:`mydreamjob-server`
> 基线:`master` / `dedd47e3db42fee26bbeb21f1925ca1da127fb65`
> 设计日期:2026-09-20
## 1. 目标
在不破坏现有手机号验证码登录的前提下,增加手机号密码认证能力:
1. 手机号密码登录;
2. 首次设置密码;
3. 使用旧密码修改密码;
4. 使用短信验证码找回并重置密码;
5. 密码错误计数、临时锁定和手机号/IP 双维度限流;
6. 密码变更后撤销该用户全部登录会话;
7. 防止账号枚举、时序侧信道以及日志、审计和响应泄漏。
所有登录方式认证成功后必须复用现有 `AuthSessionService` 创建 Access/Refresh Token 会话,不建设第二套 Token 或会话体系。
## 2. 非目标
本模块明确不包含:
- 本机号码一键登录;
- 微信、支付宝、Apple、OAuth 或其他第三方登录;
- 用户名或邮箱登录;
- 支付、兑换、实名或多因素认证;
- 修改现有 Access/Refresh Token 格式、有效期、轮换和重放处置;
- 修改验证码登录的自动注册语义;
- 修改角色、权限、注销、评分、推荐、简历或消息业务语义;
- 在本轮接入外部弱密码或泄漏密码在线查询服务。
## 3. 当前基线
### 3.1 已有能力
| 能力 | 当前实现 |
|---|---|
| 验证码发送 | `POST /auth/sms/send` |
| 验证码登录 | `POST /auth/login` |
| 登录会话 | `AuthSessionService#createSession` |
| Token 刷新 | Refresh Token 单次消费、轮换与重放撤销 |
| 登出 | 撤销当前 session |
| 全局撤销 | `AuthSessionService#revokeAllUserSessions` |
| 密码字段 | `users.password_hash VARCHAR(255) NULL` |
| 密码算法 | 已有 `BCryptPasswordEncoder` Bean |
| 临时状态 | 已有 Redis 及 `RedisTemplate<String,Object>` |
| 用户状态 | `is_active`、注销记录和注销手机号墓碑 |
### 3.2 当前缺口
- `password_hash` 没有面向普通用户的写入或校验入口;
- 没有密码设置、修改、重置 DTO 和服务;
- 没有密码失败计数、账号锁定或 IP 限流;
- 没有密码变更后的会话撤销审计;
- `CREDENTIALS_INVALID(2005)` 当前未被使用,且未显式映射为 HTTP 401;
- 现有通用 `RATE_LIMITED(9002)` 保持历史 HTTP 400,不适合作为新密码接口的 429 契约。
## 4. 核心决策
| 事项 | 决策 |
|---|---|
| 账号标识 | 唯一账号为 11 位手机号 |
| 登录方式 | 验证码登录和密码登录并存 |
| 未设置密码的旧用户 | 继续使用验证码登录,再主动设置密码 |
| 密码长度 | 12–64 个 Unicode 字符(按 Unicode code point 计) |
| 机械复杂度 | 不强制大小写、数字、符号组合 |
| 弱密码 | 本地、版本化弱密码名单整值匹配;禁止包含完整手机号 |
| 规范化 | 设置与校验前统一做 Unicode NFC;不 trim、不改大小写 |
| 存储 | BCrypt 单向摘要,禁止明文、可逆加密或独立盐字段 |
| BCrypt cost | 默认 10;部署机基准测试后才允许调整 |
| 首次设置 | 有效 Access Token + `password-set` 短信验证码 |
| 修改 | 有效 Access Token + 当前密码 |
| 重置 | 手机号 + `password-reset` 短信验证码 |
| 会话 | 设置、修改、重置成功后撤销全部会话,包括当前会话 |
| 登录失败锁定 | 手机号引用连续失败 5 次,锁定密码登录 15 分钟 |
| IP 限流 | 单 IP 10 分钟最多 30 次密码登录尝试 |
| Redis 故障 | 密码登录及密码敏感操作失败关闭,返回服务暂不可用 |
| 防枚举 | 不存在、无密码、密码错误、inactive、注销均返回相同 401 |
| 注册 | 密码登录绝不自动创建账号 |
## 5. API 契约
所有路径继续受全局 context path `/api/v1` 约束。下面只写 Controller 相对路径。
### 5.1 密码登录
```http
POST /auth/password/login
Content-Type: application/json
```
```json
{
"phone": "13800138000",
"password": "correct horse battery staple"
}
```
成功时返回与现有验证码登录相同的用户与 Token 字段:
```json
{
"code": 200,
"message": "success",
"data": {
"userId": 123,
"phone": "13800138000",
"nickname": "用户8000",
"role": "STUDENT",
"accessToken": "<access-token>",
"refreshToken": "<refresh-token>",
"expiresIn": 86400
},
"requestId": "..."
}
```
约束:
- `phone` 必须为 11 位数字;
- `password` 必填,输入上限 64 个字符;
- 不存在的手机号不得创建用户;
- 失败原因统一,不返回“用户不存在”“尚未设置密码”“账号停用”等差异信息;
- 成功后清除该手机号引用的失败计数,但不清除 IP 窗口计数。
### 5.2 首次设置密码
```http
POST /auth/password/set
Authorization: Bearer <access-token>
Content-Type: application/json
```
```json
{
"smsCode": "123456",
"newPassword": "correct horse battery staple"
}
```
前置步骤:
```http
POST /auth/sms/send?phone=<当前登录手机号>&purpose=password-set
```
规则:
- 手机号只能来自 Access Token 对应的当前用户,客户端不得传入手机号;
- 验证码用途必须严格为 `password-set`;
- 仅 `password_hash IS NULL` 时允许;
- 已有密码返回状态冲突,不得覆盖;
- 成功后撤销全部会话,调用方当前 Access Token 随即失效;
- 成功响应不签发新 Token。
成功响应:
```json
{
"code": 200,
"message": "success",
"data": { "passwordConfigured": true, "reauthenticationRequired": true },
"requestId": "..."
}
```
### 5.3 修改密码
```http
POST /auth/password/change
Authorization: Bearer <access-token>
Content-Type: application/json
```
```json
{
"currentPassword": "old password",
"newPassword": "new correct horse battery staple"
}
```
规则:
- 只接受 Access Token,Refresh Token 必须返回 401;
- 当前密码错误统一返回凭据错误;
- 未设置密码时返回密码状态冲突,引导用户走首次设置;
- 新旧密码不得相同;
- 成功后撤销全部会话且不签发新 Token。
### 5.4 找回并重置密码
前置步骤:
```http
POST /auth/sms/send?phone=13800138000&purpose=password-reset
```
重置:
```http
POST /auth/password/reset
Content-Type: application/json
```
```json
{
"phone": "13800138000",
"smsCode": "123456",
"newPassword": "new correct horse battery staple"
}
```
规则:
- 该接口匿名可访问;
- 验证码用途必须严格为 `password-reset`;
- 请求短信验证码时,无论账号是否存在都返回相同的受理响应;
- 账号不存在时不得创建账号;
- 只有有效验证码和可用账号才能修改摘要;
- 成功后撤销全部会话且不自动登录;
- inactive 或已完成注销的账号不得被重置流程复活;
- 验证失败不说明是账号、验证码还是用户状态问题。
## 6. DTO 与输入处理
建议新增:
- `PasswordLoginRequest(phone, password)`;
- `PasswordSetRequest(smsCode, newPassword)`;
- `PasswordChangeRequest(currentPassword, newPassword)`;
- `PasswordResetRequest(phone, smsCode, newPassword)`;
- `PasswordOperationResponse(passwordConfigured, reauthenticationRequired)`。
要求:
- DTO 不实现 `toString()`,不得使用会输出字段值的 Lombok `@Data`;
- 密码字段不进入 Bean Validation 默认错误回显;
- JSON 未知字段继续遵循项目现有全局策略;
- Controller 不打印请求体,不把请求 DTO 放入异常或审计 details;
- 前端可提供 `confirmPassword`,但后端不信任该字段;后端只接收一次新密码。
## 7. 密码策略
### 7.1 接受规则
1. NFC 规范化后长度为 12–64 个 Unicode code point;
2. 允许空格和 Unicode 字符;
3. 不自动删除首尾空格;
4. 不自动转换大小写;
5. 不能等于或包含完整手机号;
6. 不能命中项目内置、版本化的弱密码集合;
7. 新密码不能与当前密码相同。
### 7.2 弱密码名单
第一版使用仓库内只读资源,例如:
```text
src/main/resources/security/common-passwords.txt
```
该文件:
- 只包含公开常见弱密码,不包含任何用户数据;
- 在启动时加载为不可变集合;
- 加载失败时应用启动失败,不能静默关闭策略;
- 后续更新必须作为可审查的版本化变更;
- 本轮不调用外部泄漏密码 API,避免把用户密码或派生值发送给第三方。
## 8. BCrypt 存储与升级
### 8.1 写入
```text
normalized = NFC(rawPassword)
password_hash = BCryptPasswordEncoder.encode(normalized)
```
禁止:
- 明文密码落库;
- SHA/MD5 等快速摘要;
- 在数据库增加明文盐;
- 把密码、摘要或其前缀写入日志、审计、响应、消息或异常。
### 8.2 校验
```text
PasswordEncoder.matches(NFC(input), storedHash)
```
手机号不存在或没有密码时,也必须对固定 dummy BCrypt 摘要执行一次 `matches`,减少响应时间差异。dummy 摘要作为应用常量,由构建时生成,不包含真实密码或部署秘密。
### 8.3 cost 与升级
- 默认沿用当前 `BCryptPasswordEncoder` strength 10;
- 在 Jenkins 部署目标机上测量 P50/P95,再决定是否提高;
- 目标单次校验不超过服务可接受的登录延迟和并发预算;
- 若未来启用 `upgradeEncoding()`,仅在一次成功密码登录中升级摘要,并与会话创建保持失败原子性;第一版可不启用自动升级。
## 9. 失败计数、锁定与限流
### 9.1 标识保护
Redis key 不得包含完整手机号或原始 IP。使用独立 HMAC 密钥生成不可逆引用:
```text
phoneRef = HMAC-SHA256(PASSWORD_RATE_KEY_SECRET, normalizedPhone)
ipRef = HMAC-SHA256(PASSWORD_RATE_KEY_SECRET, normalizedIp)
```
只使用截断后的十六进制引用作为 key 片段;HMAC 原始值不得进入日志。
### 9.2 Redis key
```text
auth:pwd:fail:{phoneRef} TTL 15 分钟
auth:pwd:locked:{phoneRef} TTL 15 分钟
auth:pwd:ip:{ipRef}:{window} TTL 10 分钟
auth:pwd:op:{phoneRef}:{kind} 敏感操作限流
```
### 9.3 规则
| 维度 | 规则 | 超限行为 |
|---|---|---|
| 手机号 | 连续失败 5 次 | 锁定密码登录 15 分钟 |
| IP | 10 分钟最多 30 次密码登录请求 | HTTP 429 |
| 设置/修改/重置 | 每手机号引用 10 分钟最多 10 次 | HTTP 429 |
| 登录成功 | 清除手机号失败和锁定 key | 保留 IP 窗口 |
| 验证码登录 | 不受密码锁定影响 | 仍受原短信限流 |
计数与首次 TTL 设置必须由 Lua 脚本或等价 Redis 原子操作完成,不能用非原子的 `INCR` 后再独立 `EXPIRE` 留下永久 key。
### 9.4 Redis 故障
密码认证与密码敏感操作在 Redis 不可用时失败关闭:
- 不校验密码;
- 不创建会话;
- 不修改密码;
- 返回固定的服务暂不可用响应;
- 日志只记录固定分类和 requestId,不记录 Redis 地址、手机号或原始异常消息。
验证码登录沿用既有行为,本设计不顺带改变其 Redis 降级语义。
## 10. 防账号枚举
密码登录以下情况使用完全相同的外部响应:
- 手机号不存在;
- `password_hash IS NULL`;
- 密码错误;
- 用户 inactive;
- 用户已完成注销;
- 手机号命中注销墓碑;
- 用户角色或会话创建条件不合法。
统一响应:
```http
HTTP/1.1 401 Unauthorized
```
```json
{
"code": 2005,
"message": "手机号或密码错误",
"requestId": "..."
}
```
实现要求:
- 不存在用户仍执行 dummy BCrypt;
- inactive 等状态不得在校验前快速返回;
- 响应状态、业务码、正文结构和主要计算路径一致;
- 不承诺网络层严格恒定耗时,但独立测试需验证不存在明显快速分支;
- 找回密码的发送入口统一返回“如账号可用,验证码将发送”。
## 11. 状态机
```text
未设置密码
├─ 有效 Access + password-set 验证码 ─→ 已设置密码
└─ password-reset 验证码 ────────────→ 已设置密码
已设置密码
├─ 手机号 + 正确密码 ────────────────→ 创建登录会话
├─ 当前密码 + 新密码 ────────────────→ 已修改,全部会话撤销
├─ password-reset 验证码 ────────────→ 已重置,全部会话撤销
└─ 再次 set ─────────────────────────→ 409 状态冲突
inactive / 注销完成
└─ 任何密码操作 ─────────────────────→ 统一拒绝,不复活账号
```
## 12. 事务与会话边界
### 12.1 密码登录
```text
Controller(无事务)
→ 检查 Redis IP/手机号限制
→ PasswordAuthenticationService.login(REQUIRED)
→ 查询用户
→ BCrypt 真/假摘要校验
→ 校验用户可用性与注销状态
→ AuthSessionService.createSession
→ 写成功审计
→ 事务提交后清理手机号失败计数
```
会话记录保存失败时,登录事务回滚且不得返回 Token。
### 12.2 设置、修改、重置
每个操作必须在单个 REQUIRED 事务内完成:
```text
锁定用户行/进行带版本条件更新
→ 验证当前身份或原密码/短信验证码
→ 校验新密码策略
→ 写 password_hash(及 password_changed_at,如批准)
→ revokeAllUserSessions(reason=...)
→ 写脱敏审计
→ 提交
```
任一步失败必须整体回滚。特别是:
- 验证码不能已消费而密码未更新;
- 密码不能已更新而旧会话仍活动;
- 审计失败时是否回滚沿用现有 `AuditService` 的事务行为;
- 成功响应只可在事务提交后返回。
### 12.3 会话撤销原因
新增白名单原因:
```text
PASSWORD_SET
PASSWORD_CHANGED
PASSWORD_RESET
```
不得把手机号、密码、验证码或操作来源拼入 `revocation_reason`。
## 13. 并发与幂等
| 场景 | 预期 |
|---|---|
| 两次并发首次设置 | 恰好一次成功,另一次 409;最终只有一个摘要 |
| 两次并发修改 | 使用数据库行锁或乐观版本保证旧密码只成功一次 |
| 两次并发重置 | 同一验证码只能消费一次;恰好一次成功 |
| 修改密码与 Refresh 并发 | 密码事务提交后的终态必须无活动旧会话 |
| 登录与密码修改并发 | 密码修改提交后不得留下使用旧密码创建的活动会话 |
| 重复成功请求 | 不得重复写审计、重复创建会话或返回同一 Token |
仅调用现有 `revokeAllUserSessions` 不足以自动证明“登录/刷新与密码变更并发后无活动会话”。实现阶段必须设计数据库锁顺序或安全版本栅栏,并用真实 MySQL 并发测试证明终态。禁止使用 `synchronized`、JVM 锁或 `Thread.sleep` 作为正确性机制。
推荐实现:密码变更事务对 `users` 行加写锁;登录和 Refresh 在创建后继会话前以同一锁顺序读取可用用户。若评估发现会扩大 S02 锁竞争,必须先补独立事务设计,不得直接上线。
## 14. 数据模型
### 14.1 现有字段
继续使用:
```sql
users.password_hash VARCHAR(255) NULL
```
`NULL` 表示尚未设置密码,不代表账号不可用;用户仍可使用验证码登录。
### 14.2 建议新增字段
建议新增:
```sql
password_changed_at DATETIME NULL
```
用途:
- 记录密码凭据最后变更时间;
- 支持安全审计和后续 Token 签发时间比较;
- 不从 `updated_at` 推断,因为资料修改也会更新该字段。
是否最终新增必须在实施前检查当前最新 Flyway 后再分配迁移号。不得修改 V1–V45,也不得在设计文档预占 V46。
不建议把登录失败计数、锁定结束时间写入 MySQL;这些临时状态放 Redis,避免热点行写放大。
## 15. 错误契约
建议复用和新增如下错误:
| 场景 | HTTP | ErrorCode | 对外消息 |
|---|---:|---|---|
| 手机号或密码认证失败 | 401 | 复用 `CREDENTIALS_INVALID(2005)` | 手机号或密码错误 |
| 登录/操作限流 | 429 | 新增 `PASSWORD_RATE_LIMITED` | 尝试次数过多,请稍后再试 |
| 密码策略不合格 | 400 | 新增 `PASSWORD_POLICY_INVALID` | 密码不符合安全要求 |
| 已设置/未设置等认证后状态冲突 | 409 | 新增 `PASSWORD_STATE_CONFLICT` | 当前密码状态不允许此操作 |
| 当前密码错误 | 401 | `CREDENTIALS_INVALID(2005)` | 手机号或密码错误 |
| Redis 等认证依赖不可用 | 503 | 复用或新增经批准的服务不可用码 | 认证服务暂不可用 |
| DTO 格式错误 | 400 | `BAD_REQUEST(1001)` | bad request |
要求:
- 将 `CREDENTIALS_INVALID` 显式映射为 401;其当前无生产调用点,实施时仍需做全仓回归;
- 不改变通用 `RATE_LIMITED(9002)` 的历史 400 映射;
- ErrorCode 数字必须在实施时从当前主线分配,表中的新增名称不预定数字;
- 匿名接口不得返回 `USER_NOT_FOUND`、`USER_DISABLED` 或 `ACCOUNT_DELETED`。
## 16. 日志与审计
允许的审计 action:
```text
PASSWORD_LOGIN_SUCCEEDED
PASSWORD_LOGIN_FAILED
PASSWORD_LOGIN_RATE_LIMITED
PASSWORD_SET
PASSWORD_CHANGED
PASSWORD_RESET
AUTH_SESSIONS_REVOKED
```
审计规则:
- 登录失败若账号不可识别,`user_id` 可为 null;
- details 只包含固定分类,如 `INVALID_CREDENTIALS`、`RATE_LIMITED`;
- 手机号仅允许不可逆 `phoneRef`,不允许完整或掩码手机号进入审计 details;
- 不记录密码、摘要、验证码、JWT、Redis key、SQL 参数或异常 cause;
- 应限制失败审计写入频率,防止攻击者制造审计表写放大;聚合限流审计优先于逐次落库;
- 日志只记录固定文本、requestId 和固定错误分类。
## 17. SecurityConfig
匿名放行:
```text
POST /auth/password/login
POST /auth/password/reset
```
需要有效 Access Token:
```text
POST /auth/password/set
POST /auth/password/change
```
规则:
- 不能使用宽泛的 `/auth/** permitAll`;
- Refresh Token 访问 set/change 必须为 401;
- STUDENT、COMPANY、ADMIN 均可管理自己的密码;
- 角色不参与密码认证成功与否,只进入 Token 的既有 role claim;
- 不接受 `userId`、`sessionId` 或角色作为请求参数。
## 18. 配置
建议配置:
```yaml
app:
auth:
password:
enabled: ${PASSWORD_LOGIN_ENABLED:false}
bcrypt-strength: ${PASSWORD_BCRYPT_STRENGTH:10}
min-length: ${PASSWORD_MIN_LENGTH:12}
max-length: ${PASSWORD_MAX_LENGTH:64}
max-failures: ${PASSWORD_MAX_FAILURES:5}
lockout-seconds: ${PASSWORD_LOCKOUT_SECONDS:900}
ip-window-seconds: ${PASSWORD_IP_WINDOW_SECONDS:600}
ip-max-attempts: ${PASSWORD_IP_MAX_ATTEMPTS:30}
operation-max-attempts: ${PASSWORD_OPERATION_MAX_ATTEMPTS:10}
rate-key-secret: ${PASSWORD_RATE_KEY_SECRET}
```
约束:
- `enabled` 默认 false,发布前显式开启;
- `rate-key-secret` 无默认值,启用密码模块时缺失或过短必须启动失败;
- 密钥存放 Jenkins Credentials,不写入 Git、日志或命令历史;
- 阈值配置需做范围校验,不能允许 0、负数或极端值关闭保护;
- 配置错误只显示配置名,不显示值。
## 19. 组件建议
建议新增或调整:
```text
auth/password/
PasswordAuthenticationService
PasswordPolicy
PasswordRateLimiter
PasswordIdentifierHasher
PasswordAuthenticationProperties
PasswordAuditService(可复用既有安全审计封装时不新增)
auth/dto/
PasswordLoginRequest
PasswordSetRequest
PasswordChangeRequest
PasswordResetRequest
PasswordOperationResponse
```
职责边界:
- Controller:接收 DTO、提取受信身份与请求上下文、包装响应;
- Service:认证编排、事务、用户状态和会话处理;
- Policy:纯密码规则,不访问数据库;
- RateLimiter:只操作 Redis,不查询用户;
- IdentifierHasher:生成 Redis/审计不可逆引用;
- `AuthSessionService`:继续作为唯一 Token 会话写入口。
不得把所有逻辑继续堆入现有 `AuthService`。
## 20. 自动化测试
### 20.1 密码策略
- 11/12/64/65 字符边界;
- Unicode code point 与 NFC;
- 首尾空格不被 trim;
- 中文、空格和符号可用;
- 手机号包含检测;
- 弱密码拒绝;
- 新旧密码相同拒绝;
- 测试失败信息不包含密码。
### 20.2 密码登录
- 已设置密码登录成功并创建一次会话;
- 不存在、未设置、错误、inactive、注销、角色异常全部 401/2005;
- 六类失败响应 JSON 完全一致;
- 不存在账号执行 dummy BCrypt 的变异门禁;
- 密码登录不自动注册;
- Access/Refresh 类型、摘要存储和现有 S02 语义不变;
- 登录事务失败时无 Token、无活动会话。
### 20.3 设置、修改、重置
- 首次设置成功;重复设置 409;
- `password-set` 与其他用途验证码不可互换;
- 修改密码需正确旧密码;
- 重置需 `password-reset` 验证码;
- 验证码成功后原子消费;
- inactive/注销账号不可设置或重置;
- 成功后全部旧 Access/Refresh 均失效;
- 不自动签发新 Token;
- 数据库只保存 BCrypt 摘要。
### 20.4 限流与故障
- 第 1–4 次失败未锁定,第 5 次锁定;
- 锁定 TTL 精确且不被后续请求无限延长;
- 成功登录清除手机号失败计数;
- IP 限流跨多个手机号生效;
- 手机号锁定不阻止验证码登录;
- Redis key 无完整手机号/IP;
- Redis 不可用时无登录、无修改、无会话;
- Lua/原子计数并发下不丢增量。
### 20.5 并发
- 并发 set 恰好一个成功;
- 并发 reset 同一验证码恰好一个成功;
- password change 与 refresh 竞争终态无旧活动会话;
- password change 与 password login 竞争终态符合锁协议;
- 不允许 catch-all 吞掉异常,不使用 sleep 证明正确性。
### 20.6 脱敏和副作用
- 真实 Logback 捕获 root、应用、Hibernate、JDBC logger;
- 扫描 message、throwable、cause 和 SQL 绑定参数;
- 响应、日志、审计、通知、消息、RabbitMQ 无密码/摘要/验证码/Token/完整手机号;
- 认证失败不修改业务表;
- 密码操作不发送通知或 RabbitMQ 消息,除非另行设计。
## 21. 真实 MySQL/Redis/JAR 黑盒验收
必须使用最新 JAR、真实 MySQL 与 Redis,至少覆盖:
1. Flyway 执行与 Hibernate validate;
2. 验证码旧用户设置密码并重新登录;
3. 密码登录成功及 Access/Refresh 类型矩阵;
4. 不存在、无密码、错误、inactive、注销的统一响应;
5. 5 次失败与 15 分钟锁定;
6. 单 IP 跨手机号限流;
7. Redis 停止后失败关闭,恢复后正常;
8. 设置、修改、重置后三类旧会话全部失效;
9. 并发设置、重置、登录/修改、Refresh/修改;
10. 数据库搜索密码原文和 Token 原文命中 0;
11. 日志、审计和响应敏感扫描命中 0,扫描器需注入探针证明有效;
12. 关键业务表逐字段副作用快照;
13. RabbitMQ ready/unacked 保持 0;
14. 静态与运行时 OpenAPI 路径和 operation 集合一致;
15. 全量 `mvn clean verify` 为 Failures=0、Errors=0、Skipped=0。
## 22. Apifox 与前端联调
前端登录页展示:
- 验证码登录;
- 密码登录;
- 忘记密码;
- 已验证码登录用户的“设置密码”;
- 已设置密码用户的“修改密码”。
前端不得根据不同错误文案推断账号状态。密码登录失败统一显示“手机号或密码错误”;限流显示通用等待提示。设置、修改或重置成功后:
1. 清除本地 Access/Refresh Token;
2. 跳转登录页;
3. 提示“密码已更新,请重新登录”。
Apifox 环境继续使用统一 `baseUrl`,密码登录成功后复用现有后置脚本保存 Access/Refresh Token。
## 23. 发布和兼容
建议顺序:
1. 完成设计评审;
2. 从合并时主线确定实际 Flyway 编号;
3. 先部署数据库兼容迁移;
4. 部署后端但保持 `PASSWORD_LOGIN_ENABLED=false`;
5. 验证验证码登录和旧客户端不受影响;
6. 配置 Jenkins Credentials 与限流参数;
7. 小范围启用密码入口;
8. 完成 Apifox 与前端联调;
9. 观察登录成功率、限流率、Redis 和 BCrypt 延迟;
10. 再全量开放。
兼容要求:
- 旧用户无需迁移密码;
- `password_hash IS NULL` 的用户继续正常验证码登录;
- 旧客户端无需感知新接口;
- 不修改 `/auth/login` 的请求和响应;
- 不用默认密码批量填充历史账号。
## 24. 回滚
发生问题时:
1. 设置 `PASSWORD_LOGIN_ENABLED=false`;
2. 保留数据库中的 BCrypt 摘要,不清空用户密码;
3. 验证码登录继续工作;
4. 停止前端展示密码入口;
5. 不回滚已成功执行的 Flyway;
6. 根据固定审计事件调查,不导出密码或摘要;
7. 修复后重新执行全量与黑盒验收再开启。
关闭密码入口不能恢复已撤销的旧会话;用户可通过验证码重新登录。
## 25. 预期修改范围
实施阶段预计涉及:
- `auth/` 与 `auth/password/` 生产代码;
- `controller/AuthController.java`;
- `config/SecurityConfig.java`;
- `common/exception/ErrorCode.java` 与 `ErrorHttpStatusRegistry.java`;
- `application.yml` 和测试 profile;
- 一条新的 Flyway(仅在批准 `password_changed_at` 后);
- OpenAPI、认证文档和前端联调文档;
- 密码专项测试、认证回归和真实 MySQL/Redis 验收脚本。
禁止顺带修改:
- S02 Token 格式、密钥、有效期和轮换策略;
- 用户角色与资源归属;
- 评分、排名、推荐、邀约、申诉;
- 简历上传、隐私和查重;
- 消息发送与撤回;
- 已执行的 V1–V45;
- 本机号码一键登录或第三方登录。
## 26. 实施门禁确认
负责人于 2026-09-20 确认以下三项门禁:
1. **批准**新增 `users.password_changed_at`;
2. **接受**密码登录及密码敏感操作在 Redis 不可用时失败关闭;
3. **批准**新增密码专用 ErrorCode,并把 `CREDENTIALS_INVALID(2005)` 显式映射为 HTTP 401。
本文设计据此进入“已确认、可实施”状态。实现仍须从届时实际主线分配 Flyway 版本号和新增错误码数字,不得修改既有迁移或占用已被其他并行模块使用的编号。
其余产品与安全决策已在本文固定。任何偏离必须先更新设计并记录原因,不能在实现过程中隐式改变。

93
docs/phone-password-authentication-implementation-report.md

@ -0,0 +1,93 @@
# 手机号+密码登录实施记录
## 1. 范围
本批只实现手机号账号的密码认证闭环:登录、首次设置密码、已登录用户修改密码、短信验证后重置密码。未实现本机号码一键登录,也未改变既有短信登录、Access/Refresh Token 格式、评分、推荐或其他业务接口语义。
## 2. 账号与密码策略
- 账号字段固定为 `users.phone`,要求 11 位数字;不引入用户名或邮箱登录。
- 密码使用 BCrypt 存储,成本沿用项目 `PasswordEncoder` 配置(当前为 10)。数据库不保存明文密码。
- 新密码长度为 12–64 个 Unicode code point;先做 NFC 规范化,不 trim、不自动小写。
- 拒绝常见弱密码、包含完整手机号的密码,以及与当前密码相同的密码。
- 不设置默认密码,不因密码登录自动创建账号。
## 3. 接口
| 方法 | 路径 | 身份 | 作用 |
| --- | --- | --- | --- |
| POST | `/auth/password/login` | 匿名 | 手机号+密码登录并签发既有 Access/Refresh Token |
| POST | `/auth/password/set` | Access Token | 首次设置密码,需要 `password-set` 短信验证码 |
| POST | `/auth/password/change` | Access Token | 校验当前密码后修改密码 |
| POST | `/auth/password/reset` | 匿名 | `password-reset` 短信验证码后重置密码 |
成功响应只返回现有认证所需结果或固定操作结果;错误响应不回显手机号、密码、验证码、Token、异常类或 SQL。
## 4. 会话撤销
设置、修改、重置密码成功后调用现有 `AuthSessionService.revokeAllUserSessions`,撤销该用户全部会话,并写入固定动作审计。密码登录成功创建新的会话。密码修改时间写入 `users.password_changed_at`,为后续令牌失效策略保留服务端时间基准。
## 5. 防枚举与限流
- 不存在手机号、停用用户、删除完成用户、错误密码统一返回 `CREDENTIALS_INVALID`(HTTP 401),不区分账号是否存在。
- 登录使用固定 dummy BCrypt hash 对未知手机号执行同类密码计算,避免明显时间差。
- Redis 记录仅保存 HMAC-SHA256 引用,不保存原始手机号或 IP。
- 同手机号连续 5 次失败锁定 15 分钟;同 IP 10 分钟最多 30 次尝试。
- Redis 不可用或密钥配置不满足要求时拒绝认证,不伪造登录成功。
## 6. 配置
```yaml
app:
auth:
password:
enabled: ${PASSWORD_LOGIN_ENABLED:false}
rate-key-secret: ${PASSWORD_RATE_KEY_SECRET:}
```
密码功能默认关闭;启用时必须提供至少 32 个字符的 `PASSWORD_RATE_KEY_SECRET`。生产环境不得把该密钥写入仓库或日志。
## 7. 数据库
新增且仅新增 `V46__password_authentication.sql`,为 `users` 增加可空 `password_changed_at DATETIME`。未修改 V1–V45,未新增密码明文字段、Token 明文字段或其他表。
## 8. 错误映射
- `CREDENTIALS_INVALID`(2005)→ 401。
- `PASSWORD_RATE_LIMITED`(2006)→ 429。
- `PASSWORD_STATE_CONFLICT`(2007)→ 409。
- `PASSWORD_POLICY_INVALID`(2008)→ 400。
- Redis 故障复用 `SERVICE_UNAVAILABLE`,响应为固定安全文案。
## 9. 并发与事务
设置、修改、重置使用用户行悲观锁;密码更新、密码变更时间、会话撤销、审计在同一事务内完成。密码登录创建会话和 Refresh Token 轮换也先取得同一 `users` 行悲观锁,锁顺序统一为 `users -> auth_tokens`,从而避免改密提交后并发请求再次留下旧认证会话。
## 10. 测试
- `PasswordPolicyTest`:长度、Unicode/NFC、常见密码、手机号包含、复用密码。
- `PasswordRateLimiterTest`:HMAC 不泄露原值、失败计数/锁定、Redis 故障 fail-closed、关闭开关行为。
- `PasswordAuthenticationIntegrationTest`:成功登录、错误凭据统一响应、修改密码撤销会话、短信重置后登录、精确夹具清理。
- 更新错误码映射契约测试与 OpenAPI 既有 DTO 安全断言,使断言只检查目标 DTO/响应,不因新增密码 schema 误报。
阻断整改后新增真实 Redis 原子计数/TTL 测试、真实 MySQL 登录/刷新与改密竞争测试,以及默认关闭与 Redis 故障失败关闭测试。密码专项与认证关联回归 **149/149**;最终 `mvn clean verify` **2825/2825,Failures=0,Errors=0,Skipped=0**。`python docs/verify_openapi.py` 通过,OpenAPI 为 **180 paths / 218 operations**。V46 已在全量 MySQL/Testcontainers 测试上下文中执行并通过 schema 校验。
## 11. OpenAPI
静态契约由 176 paths / 214 operations 扩展为 **180 paths / 218 operations**,新增四个密码接口及对应请求/响应 schema。密码 DTO 不暴露 `userId`、`sessionId`、`role`、`passwordHash`、`token`、`secret` 等敏感字段。
## 12. 阻断整改闭环
- 所有设置、修改、重置操作统一执行功能开关、Redis 可用性检查及每手机号 HMAC 引用 10 分钟最多 10 次的操作限流。
- 登录 IP 计数、密码失败计数与锁定全部改为 Redis Lua 原子脚本;计数与 TTL 在同一次执行中完成,并在 TTL 缺失时修复。锁使用 `SET ... NX`,并发迟到失败不会延长既有锁定期。
- 登录、会话创建、Refresh 轮换和密码变更共用 `users` 行悲观锁;真实 MySQL 竞争测试连续验证登录/刷新与改密终态,不留下旧认证活动会话。
- 密码配置统一由 `PasswordAuthenticationProperties` 承载,并对密钥、阈值和时间窗口进行启动期边界校验。
## 13. 已知边界
- 验证码现有存储与消费语义保持不变,本批只保证密码认证链路不打印验证码;验证码摘要化另行立项。
- `password_changed_at` 已落库,但旧 Token 是否按该时间主动失效需在后续 Token 策略批次中单独验收。
## 14. 变更边界
未修改评分、排名、推荐、邀约、申诉、消息撤回、简历上传、Flyway 旧迁移、既有 Token 格式或短信登录接口。提交前仍需完成全量测试、敏感扫描、暂存和审查。

101
docs/phone-password-authentication-independent-verification-report.md

@ -0,0 +1,101 @@
# 手机号密码认证独立代码复验报告
日期:2026-09-20
结论:**FAIL,不进入暂存/提交检查点**
## 1. 复验范围
复核了密码认证生产代码、V46、SecurityConfig、错误映射、DTO、配置和现有测试;未修改生产代码、未暂存、未提交、未推送。
## 2. 已通过项目
- 账号字段为 `users.phone`,无用户名/邮箱密码登录入口。
- BCrypt 摘要存储,未发现明文密码、Token 或 Redis 原始手机号/IP 写入日志、响应、审计的代码路径。
- 登录对未知手机号使用 dummy BCrypt 摘要,错误凭据统一使用 `CREDENTIALS_INVALID(2005)`。
- 设置、修改、重置使用 `findByIdForUpdate`,成功后调用既有全量会话撤销。
- V46 是唯一新增迁移,仅增加可空 `users.password_changed_at`;V1–V45 未修改。
- 密码接口已进入 SecurityConfig,OpenAPI 为 180 paths / 218 operations。
- 定向测试:356/356,Failures=0、Errors=0、Skipped=0。
- 全量 `mvn clean verify`:2818/2818,Failures=0、Errors=0、Skipped=0。
- OpenAPI 校验:`All checks passed!`。
## 3. 阻断项
### F1:设置/修改密码绕过 Redis 失败关闭和功能开关
`PasswordAuthenticationService.setPassword`(约 69 行)与 `changePassword`(约 85 行)没有调用 `rateLimiter.checkAllowed` 或等价的启用检查;只有 `login` 和 `resetPassword` 调用了它。
因此在 `PASSWORD_LOGIN_ENABLED=false` 或 Redis 不可用时,已认证用户仍可能执行设置/修改密码,违反设计 §9.4“密码敏感操作失败关闭”,也使“默认关闭”没有覆盖全部密码操作。
### F2:没有实现密码敏感操作的独立 10 分钟限流
设计要求设置/修改/重置每手机号引用 10 分钟最多 10 次。当前实现只有登录用的 IP 30/10 分钟和手机号失败锁定;`setPassword`、`changePassword`、`resetPassword` 没有操作计数、失败计数或独立阈值。
### F3:限流计数不是原子实现
`PasswordRateLimiter` 使用 `increment` 后再单独 `expire`(约 52–55、70–73 行),没有 Lua 脚本或等价原子操作。并发下可能出现计数与 TTL 不一致,且存在永久 key 风险,违反设计 §9.3。
### F4:密码变更与登录/刷新没有共用锁或版本栅栏
- 密码登录通过 `userRepository.findByPhone` 读取用户后直接调用 `createSession`。
- `AuthSessionService.createSession` 未对 users 行加锁。
- Refresh 路径的 `requireUsableUser` 使用普通 `findById`。
- `password_changed_at` 只写入,未在登录、刷新或会话校验中读取。
因此“登录/刷新与改密并发”可能出现改密撤销之后又创建旧认证会话;现有测试没有证明设计要求的并发终态。
## 4. 非阻断观察
- 初次复验时,`bcrypt-strength`、阈值等设计配置仍为硬编码常量;本轮已将限流阈值与时间窗口收束到带启动期边界校验的配置属性。BCrypt 成本仍沿用项目统一 `PasswordEncoder` 配置,不在本轮另设第二套参数源。
- 验证码明文存储沿用既有语义,属于设计中明确保留的后续事项。
## 5. 下一步整改要求
1. 为所有密码敏感操作统一增加启用检查、Redis fail-closed 和独立操作限流。
2. 用 Lua 或 Redis 原子脚本完成“计数 + 首次 TTL”一体化;补并发增量测试。
3. 为登录、刷新、改密建立统一 users 行锁/密码版本栅栏,补真实并发测试,证明改密提交后不会留下旧会话。
4. 补齐 Redis 故障、开关关闭、10 分钟操作限流、并发登录/刷新/改密测试,再重新执行全量和真实 MySQL/Redis 验收。
## 6. Git 边界
- 暂存区为空;未提交、未推送。
- 本报告为独立复验记录,不包含生产修复。
- 历史未跟踪验收产物保持不动。
## 7. 阻断整改复验(2026-09-20)
初次 FAIL 及 F1–F4 证据保留不变。针对四项阻断完成定向整改后重新复验,结果如下:
### F1:PASS——所有密码操作统一失败关闭
- `setPassword`、`changePassword`、`resetPassword` 均调用 `checkOperationAllowed`。
- 功能默认关闭时,直接调用修改密码也返回固定 `SERVICE_UNAVAILABLE`;Redis 不可用时同样安全失败,密码摘要和既有会话均不变化。
- 配置改由 `PasswordAuthenticationProperties` 统一承载并在启动期校验。
### F2:PASS——独立操作限流已实现
- 设置、修改、重置共用按手机号 HMAC 引用计数的操作限流,默认 10 分钟最多 10 次,第 11 次返回 429。
- Redis key、异常与日志均不包含原始手机号、密码或 Token。
### F3:PASS——Redis 原子计数与锁定已实现
- 登录 IP 计数通过 Lua 在一次 Redis 执行中完成 `INCR` 与 TTL 设置/修复。
- 密码失败计数和锁定通过另一 Lua 脚本原子完成;锁使用 `NX`,不会被并发迟到请求反复延长。
- 真实 Redis 7 Testcontainers 测试验证 20 路并发累计精确为 20,计数键和锁键均有 TTL,且达到阈值后登录被拒绝。
### F4:PASS——登录、刷新与改密共用数据库栅栏
- 密码登录按手机号悲观锁定 `users` 行;`createSession` 再按 id 锁定用户。
- Refresh 先锁定 `users` 行,再查询和消费 `auth_tokens`;密码修改使用相同的 `users -> auth_tokens` 锁顺序。
- 真实 MySQL 8.4 Testcontainers 竞争测试各执行 10 轮:登录与改密、刷新与改密的最终状态均不留下旧认证活动会话,最终密码为新摘要。
## 8. 最终验证
- 密码专项及认证关联回归:**149/149**,Failures=0、Errors=0、Skipped=0。
- 最终 `mvn clean verify`:**2825/2825**,Failures=0、Errors=0、Skipped=0;真实 MySQL 8.4 与 Redis 7 Testcontainers 均实际执行。
- OpenAPI:**180 paths / 218 operations**,`All checks passed!`。
- `git diff --check` 无错误;暂存区为空;未提交、未推送;V1–V45 未修改,V46 仍为唯一新增迁移。
## 9. 最终结论
> **PASS——F1–F4 四个阻断均已修复并通过定向、并发、真实 Redis/MySQL 与全量回归,可以进入独立复验后的精确暂存检查点。**

27
docs/verify_openapi.py

@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json'
d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths']))
assert len(d['paths']) == 176, f"Expected 176 paths, got {len(d['paths'])}"
assert len(d['paths']) == 180, f"Expected 180 paths, got {len(d['paths'])}"
paths = d['paths']
@ -591,7 +591,7 @@ assert '/conversations' in paths and '/conversations/messages' in paths
assert '/conversations/{id}/messages' in paths and '/conversations/{id}/read' in paths
print(' N07 recall path/schema validated; existing messaging operations preserved')
# ── S02:Access/Refresh Token 隔离的两个新增 operation ─────────────
# ── S02:Access/Refresh Token 隔离 + 密码认证 operation ─────────────
# 只允许新增 /auth/refresh 与 /auth/logout;登录原字段不得删除;
# 新 schema 不得暴露数据库字段或凭据。
assert '/auth/refresh' in paths, 'Missing /auth/refresh'
@ -624,13 +624,28 @@ for forbidden in ('tokenHash', 'refreshTokenHash', 'sessionId', 'revocationReaso
'phone', 'secret', 'password', 'jwt'):
assert forbidden not in _s02_text, f'S02 contract exposes forbidden field {forbidden}'
# 未新增其他认证或管理接口(认证相关路径只能是既有集合 + 这两个)
# 密码认证四个 operation 的请求体必须是白名单 DTO,不能携带 userId/sessionId/角色。
_password_paths = {
'/auth/password/login': 'PasswordLoginRequest',
'/auth/password/set': 'PasswordSetRequest',
'/auth/password/change': 'PasswordChangeRequest',
'/auth/password/reset': 'PasswordResetRequest',
}
for _path, _schema_name in _password_paths.items():
assert _path in paths and set(paths[_path]) == {'post'}, f'Invalid password path: {_path}'
_body = paths[_path]['post']['requestBody']['content']['application/json']['schema']
assert _body.get('$ref', '').endswith(_schema_name), f'{_path} request schema mismatch'
_schema_text = json.dumps(resolve_schema(d['components']['schemas'][_schema_name]), ensure_ascii=False)
for _forbidden in ('userId', 'sessionId', 'role', 'passwordHash', 'token', 'secret'):
assert _forbidden not in _schema_text, f'{_path} exposes {_forbidden}'
_auth_paths = {p for p in paths if p.startswith('/auth')}
assert _auth_paths == {
'/auth/sms/send', '/auth/login', '/auth/me',
'/auth/refresh', '/auth/logout',
'/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset',
}, f'Unexpected /auth paths: {sorted(_auth_paths)}'
print(' S02 refresh/logout operations and isolation schemas validated')
print(' S02 refresh/logout and password authentication operations validated')
# ── S03:唯一新增的企业简历下载接口 ────────────────────────────────
_download_path = '/company/candidates/{applicationId}/resume'
@ -660,10 +675,10 @@ for _p in paths:
assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}'
print(' S03 company resume download contract validated; candidate DTOs remain redacted')
# 统计 operation 总数并校验 V43 + N07 + S02 + S03 计数 214
# 统计 operation 总数并校验 V43 + N07 + S02 + S03 + password authentication 计数 218
operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations)
assert operations == 214, f"Expected 214 operations, got {operations}"
assert operations == 218, f"Expected 218 operations, got {operations}"
print(" Offer advance endpoints all present")
print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)")

24
mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java

@ -87,9 +87,13 @@ public class AuthSessionService {
*/
@Transactional
public SessionTokens createSession(User user, Instant capturedNow, AuditContext ctx) {
// All session issuers take the users row lock before touching auth_tokens. Password
// changes use the same order, so a committed change cannot be followed by a session
// created from credentials validated before that change.
User lockedUser = user == null ? null : userRepository.findByIdForUpdate(user.getId()).orElse(null);
// 失败关闭:不给不可用用户创建活动会话,更不返回 Token。
// 不能先发 Token 再由过滤器拒绝 —— 那样 Token 已经流出去了。
if (user == null || !isUsable(user)) {
if (lockedUser == null || !isUsable(lockedUser)) {
throw new InvalidTokenException();
}
String sessionId = UUID.randomUUID().toString();
@ -97,12 +101,12 @@ public class AuthSessionService {
String refreshJti = UUID.randomUUID().toString();
IssuedToken access = jwtTokenProvider.generateAccessToken(
user.getId(), user.getRole().name(), sessionId, accessJti, capturedNow);
lockedUser.getId(), lockedUser.getRole().name(), sessionId, accessJti, capturedNow);
IssuedToken refresh = jwtTokenProvider.generateRefreshToken(
user.getId(), sessionId, refreshJti, capturedNow);
lockedUser.getId(), sessionId, refreshJti, capturedNow);
authTokenRepository.save(AuthToken.builder()
.userId(user.getId())
.userId(lockedUser.getId())
.tokenHash(authTokenHasher.hash(refresh.token()))
.tokenType(AuthToken.TYPE_REFRESH)
.expiredAt(toLocal(refresh.expiresAt()))
@ -113,7 +117,7 @@ public class AuthSessionService {
.createdAt(toLocal(capturedNow))
.build());
auditService.logSessionCreated(user.getId(), sessionRef(sessionId), ctx, toLocal(capturedNow));
auditService.logSessionCreated(lockedUser.getId(), sessionRef(sessionId), ctx, toLocal(capturedNow));
return new SessionTokens(access.token(), refresh.token(), sessionId);
}
@ -196,6 +200,8 @@ public class AuthSessionService {
*/
private RefreshAttempt executeRefreshAttempt(RefreshTokenClaims claims, String hash,
Instant capturedNow, AuditContext ctx) {
// Lock order is users -> auth_tokens, matching password mutation transactions.
User user = requireUsableUserForUpdate(claims.userId());
AuthToken record = authTokenRepository.findTopByTokenHashOrderByIdDesc(hash)
.orElseThrow(InvalidTokenException::new);
@ -206,8 +212,6 @@ public class AuthSessionService {
throw new InvalidTokenException();
}
User user = requireUsableUser(claims.userId());
int consumed = authTokenRepository.consumeIfCurrent(
record.getId(), record.getVersion(), toLocal(capturedNow), toLocal(capturedNow));
@ -310,6 +314,12 @@ public class AuthSessionService {
.orElseThrow(InvalidTokenException::new);
}
private User requireUsableUserForUpdate(Long userId) {
return userRepository.findByIdForUpdate(userId)
.filter(this::isUsable)
.orElseThrow(InvalidTokenException::new);
}
private boolean isUserUsable(Long userId) {
Optional<User> user = userRepository.findById(userId);
return user.isPresent() && isUsable(user.get());

3
mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java

@ -43,6 +43,9 @@ public class User {
@Column(name = "password_hash", length = 255)
private String passwordHash;
@Column(name = "password_changed_at")
private LocalDateTime passwordChangedAt;
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false, length = 30)
@Builder.Default

13
mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java

@ -1,13 +1,26 @@
package com.mydreamjob.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import jakarta.persistence.LockModeType;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByPhone(String phone);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select u from User u where u.phone = :phone")
Optional<User> findByPhoneForUpdate(@Param("phone") String phone);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select u from User u where u.id = :id")
Optional<User> findByIdForUpdate(@Param("id") Long id);
Optional<User> findByEmail(String email);
boolean existsByPhone(String phone);
boolean existsByEmail(String email);

10
mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordChangeRequest.java

@ -0,0 +1,10 @@
package com.mydreamjob.auth.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class PasswordChangeRequest {
@NotBlank private String currentPassword;
@NotBlank private String newPassword;
}

15
mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordLoginRequest.java

@ -0,0 +1,15 @@
package com.mydreamjob.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
@Data
public class PasswordLoginRequest {
@NotBlank
@Pattern(regexp = "[0-9]{11}")
private String phone;
@NotBlank
private String password;
}

3
mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordOperationResponse.java

@ -0,0 +1,3 @@
package com.mydreamjob.auth.dto;
public record PasswordOperationResponse(boolean passwordConfigured, boolean reauthenticationRequired) {}

12
mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordResetRequest.java

@ -0,0 +1,12 @@
package com.mydreamjob.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
@Data
public class PasswordResetRequest {
@NotBlank @Pattern(regexp = "[0-9]{11}") private String phone;
@NotBlank private String smsCode;
@NotBlank private String newPassword;
}

10
mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetRequest.java

@ -0,0 +1,10 @@
package com.mydreamjob.auth.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class PasswordSetRequest {
@NotBlank private String smsCode;
@NotBlank private String newPassword;
}

43
mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationProperties.java

@ -0,0 +1,43 @@
package com.mydreamjob.auth.password;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/** Strongly typed, startup-validated password authentication configuration. */
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "app.auth.password")
public class PasswordAuthenticationProperties {
private boolean enabled = false;
private String rateKeySecret = "";
private int maxFailures = 5;
private long lockoutSeconds = 900L;
private long ipWindowSeconds = 600L;
private int ipMaxAttempts = 30;
private long operationWindowSeconds = 600L;
private int operationMaxAttempts = 10;
@PostConstruct
void validate() {
if (enabled && (rateKeySecret == null || rateKeySecret.length() < 32)) {
throw new IllegalStateException(
"app.auth.password.rate-key-secret must be at least 32 characters");
}
requireRange("max-failures", maxFailures, 1, 20);
requireRange("lockout-seconds", lockoutSeconds, 60, 86_400);
requireRange("ip-window-seconds", ipWindowSeconds, 60, 3_600);
requireRange("ip-max-attempts", ipMaxAttempts, 1, 1_000);
requireRange("operation-window-seconds", operationWindowSeconds, 60, 3_600);
requireRange("operation-max-attempts", operationMaxAttempts, 1, 100);
}
private void requireRange(String name, long value, long minimum, long maximum) {
if (value < minimum || value > maximum) {
throw new IllegalStateException("app.auth.password." + name + " is outside the allowed range");
}
}
}

153
mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java

@ -0,0 +1,153 @@
package com.mydreamjob.auth.password;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.*;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.deletion.AccountDeletionRepository;
import com.mydreamjob.deletion.DeletionStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class PasswordAuthenticationService {
private static final String DUMMY_HASH = "$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi";
private static final String PURPOSE_SET = "password-set";
private static final String PURPOSE_RESET = "password-reset";
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final PasswordPolicy passwordPolicy;
private final PasswordRateLimiter rateLimiter;
private final SmsVerificationService smsVerificationService;
private final AuthSessionService authSessionService;
private final AccountDeletionRepository accountDeletionRepository;
private final AuditService auditService;
private final Clock clock;
@Transactional
public Map<String, Object> login(String phone, String rawPassword, String requestId,
String ipAddress, String userAgent) {
rateLimiter.checkAllowed(phone, ipAddress);
String normalized = passwordPolicy.normalize(rawPassword);
Optional<User> optional = userRepository.findByPhoneForUpdate(phone);
String hash = optional.map(User::getPasswordHash).orElse(DUMMY_HASH);
boolean passwordMatches = passwordEncoder.matches(normalized, hash);
User user = optional.orElse(null);
if (!passwordMatches || !usable(user)) {
rateLimiter.recordFailure(phone);
audit(null, "PASSWORD_LOGIN_FAILED", requestId, ipAddress, userAgent, "INVALID_CREDENTIALS");
throw new BusinessException(ErrorCode.CREDENTIALS_INVALID, "手机号或密码错误");
}
Instant now = clock.instant();
AuditContext ctx = context(requestId, ipAddress, userAgent);
AuthSessionService.SessionTokens tokens = authSessionService.createSession(user, now, ctx);
rateLimiter.recordSuccess(phone);
audit(user.getId(), "PASSWORD_LOGIN_SUCCEEDED", requestId, ipAddress, userAgent, "SUCCESS");
Map<String, Object> result = new HashMap<>();
result.put("userId", user.getId());
result.put("phone", user.getPhone());
result.put("nickname", user.getNickname());
result.put("role", user.getRole());
result.put("accessToken", tokens.accessToken());
result.put("refreshToken", tokens.refreshToken());
result.put("expiresIn", AuthService.EXPIRES_IN_SECONDS);
return result;
}
@Transactional
public PasswordOperationResult setPassword(Long userId, String smsCode, String newPassword,
String requestId, String ipAddress, String userAgent) {
User user = lockedUsableUser(userId);
rateLimiter.checkOperationAllowed(user.getPhone());
if (user.getPasswordHash() != null) {
throw new BusinessException(ErrorCode.PASSWORD_STATE_CONFLICT, "password is already configured");
}
smsVerificationService.verifyCode(user.getPhone(), smsCode, PURPOSE_SET);
passwordPolicy.validateNewPassword(newPassword, user.getPhone(), null, passwordEncoder);
user.setPasswordHash(passwordEncoder.encode(passwordPolicy.normalize(newPassword)));
user.setPasswordChangedAt(java.time.LocalDateTime.ofInstant(clock.instant(), AuthSessionService.BUSINESS_ZONE));
userRepository.save(user);
revokeAndAudit(user, "PASSWORD_SET", requestId, ipAddress, userAgent);
return new PasswordOperationResult(true, true);
}
@Transactional
public PasswordOperationResult changePassword(Long userId, String currentPassword, String newPassword,
String requestId, String ipAddress, String userAgent) {
User user = lockedUsableUser(userId);
rateLimiter.checkOperationAllowed(user.getPhone());
if (user.getPasswordHash() == null
|| !passwordEncoder.matches(passwordPolicy.normalize(currentPassword), user.getPasswordHash())) {
throw new BusinessException(ErrorCode.CREDENTIALS_INVALID, "手机号或密码错误");
}
passwordPolicy.validateNewPassword(newPassword, user.getPhone(), user.getPasswordHash(), passwordEncoder);
user.setPasswordHash(passwordEncoder.encode(passwordPolicy.normalize(newPassword)));
user.setPasswordChangedAt(java.time.LocalDateTime.ofInstant(clock.instant(), AuthSessionService.BUSINESS_ZONE));
userRepository.save(user);
revokeAndAudit(user, "PASSWORD_CHANGED", requestId, ipAddress, userAgent);
return new PasswordOperationResult(true, true);
}
@Transactional
public PasswordOperationResult resetPassword(String phone, String smsCode, String newPassword,
String requestId, String ipAddress, String userAgent) {
rateLimiter.checkOperationAllowed(phone);
User user = userRepository.findByPhone(phone).orElseThrow(
() -> new BusinessException(ErrorCode.CREDENTIALS_INVALID, "手机号或验证码错误"));
user = lockedUsableUser(user.getId());
smsVerificationService.verifyCode(phone, smsCode, PURPOSE_RESET);
passwordPolicy.validateNewPassword(newPassword, phone, user.getPasswordHash(), passwordEncoder);
user.setPasswordHash(passwordEncoder.encode(passwordPolicy.normalize(newPassword)));
user.setPasswordChangedAt(java.time.LocalDateTime.ofInstant(clock.instant(), AuthSessionService.BUSINESS_ZONE));
userRepository.save(user);
revokeAndAudit(user, "PASSWORD_RESET", requestId, ipAddress, userAgent);
rateLimiter.recordSuccess(phone);
return new PasswordOperationResult(true, true);
}
private User lockedUsableUser(Long userId) {
User user = userRepository.findByIdForUpdate(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.CREDENTIALS_INVALID, "手机号或密码错误"));
if (!usable(user)) {
throw new BusinessException(ErrorCode.CREDENTIALS_INVALID, "手机号或密码错误");
}
return user;
}
private boolean usable(User user) {
if (user == null || !Boolean.TRUE.equals(user.getActive())) return false;
return accountDeletionRepository.findByUserId(user.getId())
.map(d -> d.getStatus() != DeletionStatus.COMPLETED)
.orElse(true);
}
private void revokeAndAudit(User user, String action, String requestId, String ip, String ua) {
Instant now = clock.instant();
authSessionService.revokeAllUserSessions(user.getId(), action, now);
audit(user.getId(), action, requestId, ip, ua, "SUCCESS");
audit(user.getId(), "AUTH_SESSIONS_REVOKED", requestId, ip, ua, action);
}
private void audit(Long userId, String action, String requestId, String ip, String ua, String reason) {
auditService.log(userId, action, "AUTH", null,
"{\"reason\":\"" + reason + "\"}", context(requestId, ip, ua),
java.time.LocalDateTime.ofInstant(clock.instant(), AuthSessionService.BUSINESS_ZONE));
}
private AuditContext context(String requestId, String ip, String ua) {
return AuditContext.builder().requestId(requestId).ipAddress(ip).userAgent(ua).build();
}
public record PasswordOperationResult(boolean passwordConfigured, boolean reauthenticationRequired) {}
}

43
mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordPolicy.java

@ -0,0 +1,43 @@
package com.mydreamjob.auth.password;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.springframework.stereotype.Component;
import java.text.Normalizer;
import java.util.Set;
/** Pure password normalization and policy checks. */
@Component
public final class PasswordPolicy {
private static final int MIN_LENGTH = 12;
private static final int MAX_LENGTH = 64;
private static final Set<String> COMMON_PASSWORDS = Set.of(
"123456789012", "password123", "passwordpassword", "qwertyuiop12",
"administrator", "letmein12345", "111111111111");
public String normalize(String raw) {
if (raw == null) {
throw new BusinessException(ErrorCode.PASSWORD_POLICY_INVALID, "password does not meet policy");
}
return Normalizer.normalize(raw, Normalizer.Form.NFC);
}
public void validateNewPassword(String raw, String phone, String currentHash,
org.springframework.security.crypto.password.PasswordEncoder encoder) {
String password = normalize(raw);
int length = password.codePointCount(0, password.length());
if (length < MIN_LENGTH || length > MAX_LENGTH || password.isBlank()) {
throw new BusinessException(ErrorCode.PASSWORD_POLICY_INVALID, "password does not meet policy");
}
String folded = password.toLowerCase(java.util.Locale.ROOT);
if (COMMON_PASSWORDS.contains(folded)
|| (phone != null && password.contains(phone))) {
throw new BusinessException(ErrorCode.PASSWORD_POLICY_INVALID, "password does not meet policy");
}
if (currentHash != null && encoder.matches(password, currentHash)) {
throw new BusinessException(ErrorCode.PASSWORD_POLICY_INVALID, "new password must differ from current password");
}
}
}

150
mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordRateLimiter.java

@ -0,0 +1,150 @@
package com.mydreamjob.auth.password;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.List;
/** Redis-backed password login throttling. Redis failures fail closed. */
@Component
public final class PasswordRateLimiter {
private static final String LOCK_PREFIX = "auth:pwd:locked:";
private static final String FAIL_PREFIX = "auth:pwd:fail:";
private static final String IP_PREFIX = "auth:pwd:ip:";
private static final String OPERATION_PREFIX = "auth:pwd:operation:";
private static final DefaultRedisScript<Long> INCREMENT_WITH_TTL = new DefaultRedisScript<>("""
local current = redis.call('INCR', KEYS[1])
local ttl = redis.call('TTL', KEYS[1])
if current == 1 or ttl < 0 then
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]))
end
return current
""", Long.class);
private static final DefaultRedisScript<Long> RECORD_FAILURE = new DefaultRedisScript<>("""
local current = redis.call('INCR', KEYS[1])
local ttl = redis.call('TTL', KEYS[1])
if current == 1 or ttl < 0 then
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]))
end
if current >= tonumber(ARGV[2]) then
redis.call('SET', KEYS[2], '1', 'EX', tonumber(ARGV[1]), 'NX')
end
return current
""", Long.class);
private final RedisTemplate<String, Object> redisTemplate;
private final PasswordAuthenticationProperties properties;
public PasswordRateLimiter(RedisTemplate<String, Object> redisTemplate,
PasswordAuthenticationProperties properties) {
this.redisTemplate = redisTemplate;
this.properties = properties;
}
public void checkAllowed(String phone, String ipAddress) {
ensureEnabled();
try {
String phoneRef = ref(phone);
if (Boolean.TRUE.equals(redisTemplate.hasKey(LOCK_PREFIX + phoneRef))) {
throw new BusinessException(ErrorCode.PASSWORD_RATE_LIMITED, "too many attempts, try again later");
}
String window = window(properties.getIpWindowSeconds());
String ipKey = IP_PREFIX + ref(ipAddress == null ? "unknown" : ipAddress) + ":" + window;
long count = incrementWithTtl(ipKey, properties.getIpWindowSeconds());
if (count > properties.getIpMaxAttempts()) {
throw new BusinessException(ErrorCode.PASSWORD_RATE_LIMITED, "too many attempts, try again later");
}
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw unavailable();
}
}
/** Applies to set/change/reset and also enforces the module switch and Redis fail-closed rule. */
public void checkOperationAllowed(String phone) {
ensureEnabled();
try {
String key = OPERATION_PREFIX + ref(phone) + ":" + window(properties.getOperationWindowSeconds());
long count = incrementWithTtl(key, properties.getOperationWindowSeconds());
if (count > properties.getOperationMaxAttempts()) {
throw new BusinessException(ErrorCode.PASSWORD_RATE_LIMITED, "too many attempts, try again later");
}
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw unavailable();
}
}
public void recordFailure(String phone) {
ensureEnabled();
try {
String phoneRef = ref(phone);
Long count = redisTemplate.execute(RECORD_FAILURE,
List.of(FAIL_PREFIX + phoneRef, LOCK_PREFIX + phoneRef),
properties.getLockoutSeconds(), properties.getMaxFailures());
if (count == null) {
throw unavailable();
}
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw unavailable();
}
}
public void recordSuccess(String phone) {
ensureEnabled();
try {
String ref = ref(phone);
redisTemplate.delete(FAIL_PREFIX + ref);
redisTemplate.delete(LOCK_PREFIX + ref);
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
throw unavailable();
}
}
public String ref(String value) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(properties.getRateKeySecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(String.valueOf(value).getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw unavailable();
}
}
private void ensureEnabled() {
if (!properties.isEnabled()) {
throw unavailable();
}
}
private long incrementWithTtl(String key, long ttlSeconds) {
Long count = redisTemplate.execute(INCREMENT_WITH_TTL, List.of(key), ttlSeconds);
if (count == null) {
throw unavailable();
}
return count;
}
private String window(long windowSeconds) {
return Long.toString(System.currentTimeMillis() / (windowSeconds * 1000L));
}
private BusinessException unavailable() {
return new BusinessException(ErrorCode.SERVICE_UNAVAILABLE, "authentication service temporarily unavailable");
}
}

3
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java

@ -16,6 +16,9 @@ public enum ErrorCode {
TOKEN_EXPIRED(2003, "token expired"),
TOKEN_INVALID(2004, "token invalid"),
CREDENTIALS_INVALID(2005, "invalid credentials"),
PASSWORD_RATE_LIMITED(2006, "password authentication rate limited"),
PASSWORD_STATE_CONFLICT(2007, "password state conflict"),
PASSWORD_POLICY_INVALID(2008, "password does not meet policy"),
// 验证码 3xxx
SMS_CODE_SENT(3001, "sms code sent"),

4
mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java

@ -144,6 +144,7 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.USER_NOT_FOUND,
ErrorCode.VERIFICATION_NOT_FOUND)
.put(HttpStatus.CONFLICT,
ErrorCode.PASSWORD_STATE_CONFLICT,
ErrorCode.ABILITY_ATTEMPT_INVALID_STATE,
ErrorCode.ABILITY_CONCURRENT_UPDATE,
ErrorCode.ABILITY_DUPLICATE_ACTIVE,
@ -222,9 +223,12 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.VERIFICATION_ALREADY_APPROVED,
ErrorCode.VERIFICATION_ALREADY_PENDING)
.put(HttpStatus.TOO_MANY_REQUESTS,
ErrorCode.PASSWORD_RATE_LIMITED,
ErrorCode.ABILITY_RETAKE_LIMIT,
ErrorCode.REPORT_RATE_LIMITED,
ErrorCode.TALENT_INVITATION_RATE_LIMITED)
.put(HttpStatus.UNAUTHORIZED,
ErrorCode.CREDENTIALS_INVALID)
.build();
}

4
mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java

@ -78,6 +78,8 @@ public class SecurityConfig {
// 认证相关
.requestMatchers("/auth/sms/send").permitAll()
.requestMatchers("/auth/login").permitAll()
.requestMatchers("/auth/password/login").permitAll()
.requestMatchers("/auth/password/reset").permitAll()
.requestMatchers("/auth/register").permitAll()
// S02:刷新允许匿名进入 Controller;是否成功仍取决于 Refresh Token 校验。
// permitAll 只表示可进入,不代表 Token 有效。
@ -86,6 +88,8 @@ public class SecurityConfig {
.requestMatchers("/auth/me").authenticated()
// S02:登出必须已认证
.requestMatchers("/auth/logout").authenticated()
.requestMatchers("/auth/password/set").authenticated()
.requestMatchers("/auth/password/change").authenticated()
// 企业资料(需登录)
.requestMatchers("/companies/me/**").authenticated()
// V41 学生人才发现授权与企业人才发现推荐(角色和归属由 Service 校验)

86
mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java

@ -7,6 +7,12 @@ import com.mydreamjob.auth.LoginRequest;
import com.mydreamjob.auth.SmsVerificationService;
import com.mydreamjob.auth.dto.RefreshTokenRequest;
import com.mydreamjob.auth.dto.TokenPairResponse;
import com.mydreamjob.auth.dto.PasswordChangeRequest;
import com.mydreamjob.auth.dto.PasswordLoginRequest;
import com.mydreamjob.auth.dto.PasswordOperationResponse;
import com.mydreamjob.auth.dto.PasswordResetRequest;
import com.mydreamjob.auth.dto.PasswordSetRequest;
import com.mydreamjob.auth.password.PasswordAuthenticationService;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
@ -43,6 +49,7 @@ public class AuthController {
private final AuthService authService;
private final SmsVerificationService smsVerificationService;
private final CurrentUserAccessor currentUserAccessor;
private final PasswordAuthenticationService passwordAuthenticationService;
@Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。")
@PostMapping("/sms/send")
@ -97,6 +104,85 @@ public class AuthController {
}
}
@Operation(summary = "手机号密码登录", description = "使用手机号和密码登录,返回 Access/Refresh Token;不自动创建账号")
@PostMapping("/password/login")
public ResponseEntity<ApiResponse<Map<String, Object>>> passwordLogin(
@Valid @RequestBody PasswordLoginRequest body, HttpServletRequest request) {
try {
return ResponseEntity.ok(ApiResponse.ok(passwordAuthenticationService.login(
body.getPhone(), body.getPassword(), requestIdAccessor.get(request),
request.getRemoteAddr(), request.getHeader("User-Agent")), requestIdAccessor.get(request)));
} catch (BusinessException e) {
if (e.getErrorCodeInt() == ErrorCode.SERVICE_UNAVAILABLE.getCode()) {
return ResponseEntity.status(503).body(ApiResponse.error(503,
"authentication service temporarily unavailable", requestIdAccessor.get(request)));
}
throw e;
}
}
@Operation(summary = "首次设置密码")
@PostMapping("/password/set")
public ResponseEntity<ApiResponse<PasswordOperationResponse>> setPassword(
@Valid @RequestBody PasswordSetRequest body, HttpServletRequest request) {
try {
Long userId = currentUserAccessor.requireUserId();
PasswordAuthenticationService.PasswordOperationResult result = passwordAuthenticationService.setPassword(
userId, body.getSmsCode(), body.getNewPassword(), requestIdAccessor.get(request),
request.getRemoteAddr(), request.getHeader("User-Agent"));
return ResponseEntity.ok(ApiResponse.ok(
new PasswordOperationResponse(result.passwordConfigured(), result.reauthenticationRequired()),
requestIdAccessor.get(request)));
} catch (BusinessException e) {
if (e.getErrorCodeInt() == ErrorCode.SERVICE_UNAVAILABLE.getCode()) {
return ResponseEntity.status(503).body(ApiResponse.error(503,
"authentication service temporarily unavailable", requestIdAccessor.get(request)));
}
throw e;
}
}
@Operation(summary = "修改密码")
@PostMapping("/password/change")
public ResponseEntity<ApiResponse<PasswordOperationResponse>> changePassword(
@Valid @RequestBody PasswordChangeRequest body, HttpServletRequest request) {
try {
Long userId = currentUserAccessor.requireUserId();
PasswordAuthenticationService.PasswordOperationResult result = passwordAuthenticationService.changePassword(
userId, body.getCurrentPassword(), body.getNewPassword(), requestIdAccessor.get(request),
request.getRemoteAddr(), request.getHeader("User-Agent"));
return ResponseEntity.ok(ApiResponse.ok(
new PasswordOperationResponse(result.passwordConfigured(), result.reauthenticationRequired()),
requestIdAccessor.get(request)));
} catch (BusinessException e) {
if (e.getErrorCodeInt() == ErrorCode.SERVICE_UNAVAILABLE.getCode()) {
return ResponseEntity.status(503).body(ApiResponse.error(503,
"authentication service temporarily unavailable", requestIdAccessor.get(request)));
}
throw e;
}
}
@Operation(summary = "短信找回并重置密码")
@PostMapping("/password/reset")
public ResponseEntity<ApiResponse<PasswordOperationResponse>> resetPassword(
@Valid @RequestBody PasswordResetRequest body, HttpServletRequest request) {
try {
PasswordAuthenticationService.PasswordOperationResult result = passwordAuthenticationService.resetPassword(
body.getPhone(), body.getSmsCode(), body.getNewPassword(), requestIdAccessor.get(request),
request.getRemoteAddr(), request.getHeader("User-Agent"));
return ResponseEntity.ok(ApiResponse.ok(
new PasswordOperationResponse(result.passwordConfigured(), result.reauthenticationRequired()),
requestIdAccessor.get(request)));
} catch (BusinessException e) {
if (e.getErrorCodeInt() == ErrorCode.SERVICE_UNAVAILABLE.getCode()) {
return ResponseEntity.status(503).body(ApiResponse.error(503,
"authentication service temporarily unavailable", requestIdAccessor.get(request)));
}
throw e;
}
}
@Operation(summary = "刷新 Token",
description = "使用 Refresh Token 换取新的 Access/Refresh Token 对;旧 Refresh Token 立即失效")
@PostMapping("/refresh")

11
mydreamjob-server/src/main/resources/application.yml

@ -126,6 +126,17 @@ app:
max-attempts: 5
lockout-seconds: ${SMS_LOCKOUT_SECONDS:300}
auth:
password:
enabled: ${PASSWORD_LOGIN_ENABLED:false}
rate-key-secret: ${PASSWORD_RATE_KEY_SECRET:}
max-failures: ${PASSWORD_MAX_FAILURES:5}
lockout-seconds: ${PASSWORD_LOCKOUT_SECONDS:900}
ip-window-seconds: ${PASSWORD_IP_WINDOW_SECONDS:600}
ip-max-attempts: ${PASSWORD_IP_MAX_ATTEMPTS:30}
operation-window-seconds: ${PASSWORD_OPERATION_WINDOW_SECONDS:600}
operation-max-attempts: ${PASSWORD_OPERATION_MAX_ATTEMPTS:10}
offer:
scheduler:
enabled: ${OFFER_SCHEDULER_ENABLED:true}

5
mydreamjob-server/src/main/resources/db/migration/V46__password_authentication.sql

@ -0,0 +1,5 @@
-- V46: password credential lifecycle metadata.
-- The password hash already exists in V1; this timestamp is separate from updated_at
-- so profile edits cannot be mistaken for credential changes.
ALTER TABLE users
ADD COLUMN password_changed_at DATETIME NULL;

4
mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java

@ -436,8 +436,8 @@ class CompanyResumeDownloadIntegrationTest {
}
}
}
assertThat(pathCount).as("S03 基线应为 176 paths").isEqualTo(176);
assertThat(opCount).as("S03 基线应为 214 operations").isEqualTo(214);
assertThat(pathCount).as("密码登录新增四个路径后应为 180 paths").isEqualTo(180);
assertThat(opCount).as("密码登录新增四个 operation 后应为 218 operations").isEqualTo(218);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse();

180
mydreamjob-server/src/test/java/com/mydreamjob/auth/PasswordAuthenticationConcurrencyMySqlTest.java

@ -0,0 +1,180 @@
package com.mydreamjob.auth;
import com.mydreamjob.auth.password.PasswordAuthenticationService;
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.springframework.beans.factory.annotation.Autowired;
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.script.RedisScript;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.MySQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
@Testcontainers(disabledWithoutDocker = true)
@SpringBootTest
@ActiveProfiles("mysql-it")
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class PasswordAuthenticationConcurrencyMySqlTest {
private static final AtomicLong PHONE_SEQUENCE = new AtomicLong(98200000000L);
private static final String OLD_PASSWORD = "Old-strong-password-2026";
private static final String NEW_PASSWORD = "New-strong-password-2026";
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("password_concurrency")
.withUsername("password_test")
.withPassword("password-test-only");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", MYSQL::getJdbcUrl);
registry.add("spring.datasource.username", MYSQL::getUsername);
registry.add("spring.datasource.password", MYSQL::getPassword);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
registry.add("app.auth.password.enabled", () -> "true");
registry.add("app.auth.password.rate-key-secret",
() -> "mysql-password-concurrency-secret-0001");
}
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Autowired private UserRepository userRepository;
@Autowired private AuthTokenRepository authTokenRepository;
@Autowired private PasswordEncoder passwordEncoder;
@Autowired private PasswordAuthenticationService passwordAuthenticationService;
@Autowired private AuthSessionService authSessionService;
private final List<Long> userIds = new ArrayList<>();
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(redisTemplate.execute(any(RedisScript.class), anyList(), any(Object[].class))).thenReturn(1L);
}
@AfterEach
void tearDown() {
for (Long userId : userIds) {
authTokenRepository.findAll().stream()
.filter(token -> userId.equals(token.getUserId()))
.forEach(authTokenRepository::delete);
userRepository.deleteById(userId);
}
userIds.clear();
}
@Test
void passwordLoginRacingWithChangeLeavesNoSessionAuthenticatedByOldPassword() throws Exception {
for (int round = 0; round < 10; round++) {
User user = createUser();
RaceResult race = race(
() -> passwordAuthenticationService.login(user.getPhone(), OLD_PASSWORD,
"login-race", "127.0.0.1", "test"),
() -> passwordAuthenticationService.changePassword(user.getId(), OLD_PASSWORD, NEW_PASSWORD,
"change-race", "127.0.0.1", "test"));
assertThat(race.rightError()).isNull();
if (race.leftError() != null) {
assertThat(race.leftError()).isInstanceOf(BusinessException.class);
assertThat(((BusinessException) race.leftError()).getErrorCodeInt())
.isEqualTo(ErrorCode.CREDENTIALS_INVALID.getCode());
}
assertThat(passwordEncoder.matches(NEW_PASSWORD,
userRepository.findById(user.getId()).orElseThrow().getPasswordHash())).isTrue();
assertThat(authTokenRepository.findByUserIdAndRevokedFalse(user.getId())).isEmpty();
}
}
@Test
void refreshRacingWithChangeLeavesNoActiveSuccessor() throws Exception {
for (int round = 0; round < 10; round++) {
User user = createUser();
AuthSessionService.SessionTokens session = authSessionService.createSession(user, Instant.now(), null);
RaceResult race = race(
() -> authSessionService.refresh(session.refreshToken(), Instant.now(), null),
() -> passwordAuthenticationService.changePassword(user.getId(), OLD_PASSWORD, NEW_PASSWORD,
"refresh-change-race", "127.0.0.1", "test"));
assertThat(race.rightError()).isNull();
if (race.leftError() != null) {
assertThat(race.leftError()).isInstanceOf(InvalidTokenException.class);
}
assertThat(authTokenRepository.findByUserIdAndRevokedFalse(user.getId())).isEmpty();
assertThat(authSessionService.isSessionActive(user.getId(), session.sessionId(), Instant.now())).isFalse();
}
}
private User createUser() {
User user = userRepository.save(User.builder()
.phone(Long.toString(PHONE_SEQUENCE.incrementAndGet()))
.nickname("密码并发测试")
.passwordHash(passwordEncoder.encode(OLD_PASSWORD))
.role(User.Role.STUDENT)
.roles(Set.of(User.Role.STUDENT))
.active(true)
.verified(true)
.build());
userIds.add(user.getId());
return user;
}
private RaceResult race(ThrowingCall left, ThrowingCall right) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
Future<Throwable> leftResult = pool.submit(() -> invoke(left, ready, start));
Future<Throwable> rightResult = pool.submit(() -> invoke(right, ready, start));
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
Throwable leftError = leftResult.get(30, TimeUnit.SECONDS);
Throwable rightError = rightResult.get(30, TimeUnit.SECONDS);
pool.shutdownNow();
return new RaceResult(leftError, rightError);
}
private Throwable invoke(ThrowingCall call, CountDownLatch ready, CountDownLatch start) {
ready.countDown();
try {
start.await(10, TimeUnit.SECONDS);
call.run();
return null;
} catch (Throwable error) {
return error;
}
}
@FunctionalInterface
private interface ThrowingCall {
void run() throws Exception;
}
private record RaceResult(Throwable leftError, Throwable rightError) {}
}

66
mydreamjob-server/src/test/java/com/mydreamjob/auth/PasswordAuthenticationDisabledIntegrationTest.java

@ -0,0 +1,66 @@
package com.mydreamjob.auth;
import com.mydreamjob.auth.password.PasswordAuthenticationService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.AfterEach;
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.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verifyNoInteractions;
@SpringBootTest
@ActiveProfiles("integration-test")
class PasswordAuthenticationDisabledIntegrationTest {
private static final AtomicLong PHONE_SEQUENCE = new AtomicLong(98300000000L);
@MockBean private RedisTemplate<String, Object> redisTemplate;
@Autowired private UserRepository userRepository;
@Autowired private PasswordEncoder passwordEncoder;
@Autowired private PasswordAuthenticationService passwordAuthenticationService;
private Long userId;
@AfterEach
void tearDown() {
if (userId != null) {
userRepository.deleteById(userId);
}
}
@Test
void disabledModuleRejectsPasswordChangeBeforePasswordOrSessionMutation() {
String originalHash = passwordEncoder.encode("Old-strong-password-2026");
User user = userRepository.save(User.builder()
.phone(Long.toString(PHONE_SEQUENCE.incrementAndGet()))
.nickname("关闭开关测试")
.passwordHash(originalHash)
.role(User.Role.STUDENT)
.roles(Set.of(User.Role.STUDENT))
.active(true)
.verified(true)
.build());
userId = user.getId();
assertThatThrownBy(() -> passwordAuthenticationService.changePassword(
userId, "Old-strong-password-2026", "New-strong-password-2026",
"disabled-test", "127.0.0.1", "test"))
.isInstanceOf(BusinessException.class)
.extracting(error -> ((BusinessException) error).getErrorCodeInt())
.isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode());
assertThat(userRepository.findById(userId).orElseThrow().getPasswordHash()).isEqualTo(originalHash);
verifyNoInteractions(redisTemplate);
}
}

216
mydreamjob-server/src/test/java/com/mydreamjob/auth/PasswordAuthenticationIntegrationTest.java

@ -0,0 +1,216 @@
package com.mydreamjob.auth;
import com.mydreamjob.auth.support.TestAuthTokens;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
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.script.RedisScript;
import org.springframework.http.MediaType;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.Set;
import java.util.concurrent.atomic.AtomicLong;
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.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("integration-test")
@TestPropertySource(properties = {
"app.auth.password.enabled=true",
"app.auth.password.rate-key-secret=01234567890123456789012345678901"
})
class PasswordAuthenticationIntegrationTest {
private static final AtomicLong SEQ = new AtomicLong(97000000000L);
@MockBean
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Autowired
private AuthTokenRepository authTokenRepository;
@Autowired
private SmsVerificationCodeRepository codeRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@Autowired
private AuthSessionService authSessionService;
private Long userId;
@BeforeEach
@SuppressWarnings("unchecked")
void setUp() {
when(redisTemplate.hasKey(anyString())).thenReturn(false);
when(redisTemplate.execute(any(RedisScript.class), anyList(), any(Object[].class))).thenReturn(1L);
String phone = Long.toString(SEQ.incrementAndGet());
userId = userRepository.save(User.builder()
.phone(phone)
.nickname("密码测试")
.passwordHash(passwordEncoder.encode("Old-strong-password-2026"))
.role(User.Role.STUDENT)
.roles(Set.of(User.Role.STUDENT))
.active(true)
.verified(true)
.build()).getId();
}
@AfterEach
void tearDown() {
if (userId != null) {
authTokenRepository.findAll().stream()
.filter(token -> userId.equals(token.getUserId()))
.forEach(authTokenRepository::delete);
codeRepository.deleteAll(codeRepository.findByPhoneAndCreatedAtAfter(
userRepository.findById(userId).map(User::getPhone).orElse(""),
LocalDateTime.now().minusDays(1)));
userRepository.deleteById(userId);
}
}
@Test
void passwordLoginReturnsTokensAndWrongPasswordIsUniform401() throws Exception {
String phone = userRepository.findById(userId).orElseThrow().getPhone();
mockMvc.perform(post("/auth/password/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"phone\":\"" + phone + "\",\"password\":\"Old-strong-password-2026\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.accessToken").isString())
.andExpect(jsonPath("$.data.refreshToken").isString());
mockMvc.perform(post("/auth/password/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"phone\":\"" + phone + "\",\"password\":\"wrong-password-2026\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(2005))
.andExpect(jsonPath("$.message").value("手机号或密码错误"))
.andExpect(content().string(not(containsString(phone))));
String unknownPhone = "97123456789";
mockMvc.perform(post("/auth/password/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"phone\":\"" + unknownPhone + "\",\"password\":\"wrong-password-2026\"}"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(2005))
.andExpect(jsonPath("$.message").value("手机号或密码错误"))
.andExpect(content().string(not(containsString(unknownPhone))));
}
@Test
void setPasswordUsesPurposeSpecificSmsCodeForUsersWithoutPassword() throws Exception {
User user = userRepository.findById(userId).orElseThrow();
user.setPasswordHash(null);
userRepository.saveAndFlush(user);
AuthSessionService.SessionTokens session = authSessionService.createSession(user, Instant.now(), null);
codeRepository.save(SmsVerificationCode.builder()
.phone(user.getPhone()).purpose("password-set").code("654321")
.attempts(0).maxAttempts(5).used(false)
.createdAt(LocalDateTime.now()).expiredAt(LocalDateTime.now().plusMinutes(5)).build());
mockMvc.perform(post("/auth/password/set")
.header("Authorization", "Bearer " + session.accessToken())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"smsCode\":\"654321\",\"newPassword\":\"Set-strong-password-2026\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.passwordConfigured").value(true))
.andExpect(jsonPath("$.data.reauthenticationRequired").value(true));
mockMvc.perform(post("/auth/password/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"phone\":\"" + user.getPhone()
+ "\",\"password\":\"Set-strong-password-2026\"}"))
.andExpect(status().isOk());
}
@Test
void changePasswordRevokesExistingSessionsAndNewPasswordLogsIn() throws Exception {
User user = userRepository.findById(userId).orElseThrow();
AuthSessionService.SessionTokens session = authSessionService.createSession(user, Instant.now(), null);
String phone = user.getPhone();
mockMvc.perform(post("/auth/password/change")
.header("Authorization", "Bearer " + session.accessToken())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"currentPassword\":\"Old-strong-password-2026\","
+ "\"newPassword\":\"New-strong-password-2026\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.reauthenticationRequired").value(true));
mockMvc.perform(post("/auth/password/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"phone\":\"" + phone + "\",\"password\":\"New-strong-password-2026\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
}
@Test
void resetPasswordRequiresPurposeSpecificSmsCode() throws Exception {
User user = userRepository.findById(userId).orElseThrow();
String phone = user.getPhone();
codeRepository.save(SmsVerificationCode.builder()
.phone(phone).purpose("password-reset").code("123456")
.attempts(0).maxAttempts(5).used(false)
.createdAt(LocalDateTime.now()).expiredAt(LocalDateTime.now().plusMinutes(5)).build());
mockMvc.perform(post("/auth/password/reset")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"phone\":\"" + phone + "\",\"smsCode\":\"123456\","
+ "\"newPassword\":\"Reset-strong-password-2026\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200));
mockMvc.perform(post("/auth/password/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"phone\":\"" + phone + "\",\"password\":\"Reset-strong-password-2026\"}"))
.andExpect(status().isOk());
}
@Test
@SuppressWarnings("unchecked")
void redisFailurePreventsPasswordChangeAndLeavesExistingSessionActive() throws Exception {
User user = userRepository.findById(userId).orElseThrow();
AuthSessionService.SessionTokens session = authSessionService.createSession(user, Instant.now(), null);
String originalHash = user.getPasswordHash();
when(redisTemplate.execute(any(RedisScript.class), anyList(), any(Object[].class)))
.thenThrow(new IllegalStateException("redis unavailable probe"));
mockMvc.perform(post("/auth/password/change")
.header("Authorization", "Bearer " + session.accessToken())
.contentType(MediaType.APPLICATION_JSON)
.content("{\"currentPassword\":\"Old-strong-password-2026\","
+ "\"newPassword\":\"New-strong-password-2026\"}"))
.andExpect(status().isServiceUnavailable())
.andExpect(jsonPath("$.code").value(503))
.andExpect(jsonPath("$.message")
.value("authentication service temporarily unavailable"))
.andExpect(content().string(not(containsString("redis unavailable probe"))));
assertThat(userRepository.findById(userId).orElseThrow().getPasswordHash()).isEqualTo(originalHash);
assertThat(authSessionService.isSessionActive(userId, session.sessionId(), Instant.now())).isTrue();
}
}

44
mydreamjob-server/src/test/java/com/mydreamjob/auth/password/PasswordPolicyTest.java

@ -0,0 +1,44 @@
package com.mydreamjob.auth.password;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PasswordPolicyTest {
private final PasswordPolicy policy = new PasswordPolicy();
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
@Test
void acceptsStrongPasswordAndNormalizesNfc() {
assertThat(policy.normalize(" Pássword-安全-2026 "))
.isEqualTo(" Pássword-安全-2026 ");
policy.validateNewPassword("Pássword-安全-2026", "13800000001", null, encoder);
}
@Test
void rejectsCommonShortPhoneContainingAndReusedPasswords() {
assertInvalid("short");
assertInvalid("password123");
assertInvalid("My13800000001StrongPassword");
String oldHash = encoder.encode("A-strong-password-2026");
assertThatThrownBy(() -> policy.validateNewPassword(
"A-strong-password-2026", "13800000001", oldHash, encoder))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getErrorCodeInt())
.isEqualTo(ErrorCode.PASSWORD_POLICY_INVALID.getCode());
}
private void assertInvalid(String password) {
assertThatThrownBy(() -> policy.validateNewPassword(
password, "13800000001", null, encoder))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getErrorCodeInt())
.isEqualTo(ErrorCode.PASSWORD_POLICY_INVALID.getCode());
}
}

116
mydreamjob-server/src/test/java/com/mydreamjob/auth/password/PasswordRateLimiterRedisIntegrationTest.java

@ -0,0 +1,116 @@
package com.mydreamjob.auth.password;
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.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@Testcontainers(disabledWithoutDocker = true)
class PasswordRateLimiterRedisIntegrationTest {
@Container
static final GenericContainer<?> REDIS = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
private LettuceConnectionFactory connectionFactory;
private RedisTemplate<String, Object> redisTemplate;
private PasswordRateLimiter limiter;
@BeforeEach
void setUp() {
connectionFactory = new LettuceConnectionFactory(REDIS.getHost(), REDIS.getMappedPort(6379));
connectionFactory.afterPropertiesSet();
connectionFactory.start();
redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(connectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.afterPropertiesSet();
redisTemplate.getConnectionFactory().getConnection().serverCommands().flushDb();
PasswordAuthenticationProperties properties = new PasswordAuthenticationProperties();
properties.setEnabled(true);
properties.setRateKeySecret("redis-integration-test-secret-00000001");
properties.validate();
limiter = new PasswordRateLimiter(redisTemplate, properties);
}
@AfterEach
void tearDown() {
if (connectionFactory != null) {
connectionFactory.destroy();
}
}
@Test
void operationCounterAndTtlAreCreatedAtomically() {
String phone = "13800000001";
for (int i = 0; i < 10; i++) {
limiter.checkOperationAllowed(phone);
}
assertThatThrownBy(() -> limiter.checkOperationAllowed(phone))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getErrorCodeInt())
.isEqualTo(ErrorCode.PASSWORD_RATE_LIMITED.getCode());
String key = redisTemplate.keys("auth:pwd:operation:*").stream().findFirst().orElseThrow();
assertThat(redisTemplate.getExpire(key, TimeUnit.SECONDS)).isBetween(1L, 600L);
}
@Test
void concurrentFailureIncrementsLoseNoUpdatesAndLockTtlIsNotExtended() throws Exception {
String phone = "13800000002";
int attempts = 20;
ExecutorService pool = Executors.newFixedThreadPool(attempts);
CountDownLatch ready = new CountDownLatch(attempts);
CountDownLatch start = new CountDownLatch(1);
List<Future<?>> futures = new ArrayList<>();
for (int i = 0; i < attempts; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
start.await(10, TimeUnit.SECONDS);
limiter.recordFailure(phone);
return null;
}));
}
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
for (Future<?> future : futures) {
future.get(20, TimeUnit.SECONDS);
}
pool.shutdownNow();
String failureKey = redisTemplate.keys("auth:pwd:fail:*").stream().findFirst().orElseThrow();
String lockKey = redisTemplate.keys("auth:pwd:locked:*").stream().findFirst().orElseThrow();
assertThat(redisTemplate.opsForValue().get(failureKey)).isEqualTo(attempts);
assertThat(redisTemplate.getExpire(failureKey, TimeUnit.SECONDS)).isBetween(1L, 900L);
assertThat(redisTemplate.getExpire(lockKey, TimeUnit.SECONDS)).isBetween(1L, 900L);
assertThatThrownBy(() -> limiter.checkAllowed(phone, "127.0.0.1"))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getErrorCodeInt())
.isEqualTo(ErrorCode.PASSWORD_RATE_LIMITED.getCode());
}
}

88
mydreamjob-server/src/test/java/com/mydreamjob/auth/password/PasswordRateLimiterTest.java

@ -0,0 +1,88 @@
package com.mydreamjob.auth.password;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class PasswordRateLimiterTest {
private static PasswordAuthenticationProperties enabledProperties() {
PasswordAuthenticationProperties properties = new PasswordAuthenticationProperties();
properties.setEnabled(true);
properties.setRateKeySecret("01234567890123456789012345678901");
return properties;
}
@SuppressWarnings("unchecked")
@Test
void keysAreHmacReferencesAndRedisFailureFailsClosed() {
RedisTemplate<String, Object> redis = mock(RedisTemplate.class);
PasswordRateLimiter limiter = new PasswordRateLimiter(redis, enabledProperties());
String ref = limiter.ref("13800000001");
assertThat(ref).hasSize(64).doesNotContain("13800000001");
when(redis.hasKey(anyString())).thenThrow(new IllegalStateException("redis down"));
assertThatThrownBy(() -> limiter.checkAllowed("13800000001", "127.0.0.1"))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getErrorCodeInt())
.isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode());
}
@SuppressWarnings("unchecked")
@Test
void failureCounterUsesOneAtomicScriptWithTtlAndNonExtendingLock() {
RedisTemplate<String, Object> redis = mock(RedisTemplate.class);
when(redis.execute(any(RedisScript.class), anyList(), any(Object[].class)))
.thenReturn(1L, 2L, 3L, 4L, 5L);
PasswordRateLimiter limiter = new PasswordRateLimiter(redis, enabledProperties());
for (int i = 0; i < 5; i++) {
limiter.recordFailure("13800000001");
}
verify(redis, times(5)).execute(argThat(script -> {
String source = ((RedisScript<?>) script).getScriptAsString();
return source.contains("INCR") && source.contains("EXPIRE")
&& source.contains("SET") && source.contains("NX");
}), anyList(), any(Object[].class));
verify(redis, never()).opsForValue();
}
@SuppressWarnings("unchecked")
@Test
void eleventhPasswordOperationIsRateLimited() {
RedisTemplate<String, Object> redis = mock(RedisTemplate.class);
when(redis.execute(any(RedisScript.class), anyList(), any(Object[].class)))
.thenReturn(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L, 11L);
PasswordRateLimiter limiter = new PasswordRateLimiter(redis, enabledProperties());
for (int i = 0; i < 10; i++) {
limiter.checkOperationAllowed("13800000001");
}
assertThatThrownBy(() -> limiter.checkOperationAllowed("13800000001"))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getErrorCodeInt())
.isEqualTo(ErrorCode.PASSWORD_RATE_LIMITED.getCode());
}
@Test
void disabledRateLimiterFailsClosedInsteadOfSilentlyAllowingPasswordLogin() {
PasswordAuthenticationProperties properties = new PasswordAuthenticationProperties();
PasswordRateLimiter limiter = new PasswordRateLimiter(mock(RedisTemplate.class), properties);
assertThatThrownBy(() -> limiter.checkAllowed("13800000001", "127.0.0.1"))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getErrorCodeInt())
.isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode());
assertThatThrownBy(() -> limiter.checkOperationAllowed("13800000001"))
.isInstanceOf(BusinessException.class)
.extracting(e -> ((BusinessException) e).getErrorCodeInt())
.isEqualTo(ErrorCode.SERVICE_UNAVAILABLE.getCode());
}
}

7
mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java

@ -67,7 +67,10 @@ class GlobalExceptionHandlerStatusContractTest {
"RISK_RULE_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE", "RISK_INVALID_ACTION");
private static final Set<String> TOO_MANY_REQUESTS = Set.of(
"REPORT_RATE_LIMITED", "ABILITY_RETAKE_LIMIT", "TALENT_INVITATION_RATE_LIMITED");
"REPORT_RATE_LIMITED", "ABILITY_RETAKE_LIMIT", "TALENT_INVITATION_RATE_LIMITED",
"PASSWORD_RATE_LIMITED");
private static final Set<String> UNAUTHORIZED = Set.of("CREDENTIALS_INVALID");
private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor(), new ErrorHttpStatusRegistry());
@ -101,6 +104,8 @@ class GlobalExceptionHandlerStatusContractTest {
if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND;
if (CONFLICT.contains(name)) return HttpStatus.CONFLICT;
if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS;
if ("PASSWORD_STATE_CONFLICT".equals(name)) return HttpStatus.CONFLICT;
if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED;
return HttpStatus.BAD_REQUEST;
}
}

17
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java

@ -25,17 +25,18 @@ class ErrorHttpStatusRegistryTest {
private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
/** 允许出现在显式映射里的状态集合:重构前后都必须只有这四个。 */
/** 允许出现在显式映射里的状态集合;密码登录新增 401/400 契约。 */
private static final Set<HttpStatus> ALLOWED_EXPLICIT_STATUSES = Set.of(
HttpStatus.FORBIDDEN, HttpStatus.NOT_FOUND, HttpStatus.CONFLICT, HttpStatus.TOO_MANY_REQUESTS);
HttpStatus.FORBIDDEN, HttpStatus.NOT_FOUND, HttpStatus.CONFLICT,
HttpStatus.TOO_MANY_REQUESTS, HttpStatus.UNAUTHORIZED, HttpStatus.BAD_REQUEST);
@Test
@DisplayName("B2:显式映射数量与重构前快照一致(112 个 ErrorCode 常量)")
@DisplayName("密码登录:显式映射包含新增认证状态")
void explicitMappingCountMatchesBaseline() {
assertThat(registry.explicitMappingCount()).isEqualTo(112);
// 112 个常量只覆盖 111 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与
assertThat(registry.explicitMappingCount()).isEqualTo(115);
// 116 个常量只覆盖 115 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与
// DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。
assertThat(registry.explicitNumericCodeCount()).isEqualTo(111);
assertThat(registry.explicitNumericCodeCount()).isEqualTo(114);
}
@Test
@ -120,12 +121,12 @@ class ErrorHttpStatusRegistryTest {
.isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN))
.isInstanceOf(UnsupportedOperationException.class);
assertThat(view).hasSize(112);
assertThat(view).hasSize(115);
Map<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView();
assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK))
.isInstanceOf(UnsupportedOperationException.class);
assertThat(numeric).hasSize(111);
assertThat(numeric).hasSize(114);
}
@Test

14
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java

@ -80,7 +80,12 @@ class GlobalExceptionHandlerMappingParityTest {
"VERIFICATION_ALREADY_APPROVED", "VERIFICATION_ALREADY_PENDING");
private static final Set<String> TOO_MANY_REQUESTS = Set.of(
"ABILITY_RETAKE_LIMIT", "REPORT_RATE_LIMITED", "TALENT_INVITATION_RATE_LIMITED");
"ABILITY_RETAKE_LIMIT", "REPORT_RATE_LIMITED", "TALENT_INVITATION_RATE_LIMITED",
"PASSWORD_RATE_LIMITED");
private static final Set<String> CONFLICT_ADDITIONS = Set.of("PASSWORD_STATE_CONFLICT");
private static final Set<String> UNAUTHORIZED = Set.of("CREDENTIALS_INVALID");
private static final Set<String> PASSWORD_BAD_REQUEST = Set.of("PASSWORD_POLICY_INVALID");
private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
private final GlobalExceptionHandler handler =
@ -91,15 +96,18 @@ class GlobalExceptionHandlerMappingParityTest {
if (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN;
if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND;
if (CONFLICT.contains(name)) return HttpStatus.CONFLICT;
if (CONFLICT_ADDITIONS.contains(name)) return HttpStatus.CONFLICT;
if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS;
if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED;
if (PASSWORD_BAD_REQUEST.contains(name)) return HttpStatus.BAD_REQUEST;
return HttpStatus.BAD_REQUEST;
}
@Test
@DisplayName("B2:显式映射集合与重构前快照逐项相等")
void explicitMappingSetEqualsPreRefactorSnapshot() {
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(112);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(111);
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(115);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(114);
for (ErrorCode errorCode : ErrorCode.values()) {
HttpStatus expected = baselineStatus(errorCode);

20
mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java

@ -66,6 +66,7 @@ class R01BIndependentHttpMappingParityTest {
case "NOT_FOUND" -> HttpStatus.NOT_FOUND;
case "CONFLICT" -> HttpStatus.CONFLICT;
case "TOO_MANY_REQUESTS" -> HttpStatus.TOO_MANY_REQUESTS;
case "UNAUTHORIZED" -> HttpStatus.UNAUTHORIZED;
default -> HttpStatus.BAD_REQUEST;
};
}
@ -77,10 +78,10 @@ class R01BIndependentHttpMappingParityTest {
void everyEnumConstantMatchesHeadBaselineByName() {
Map<String, int[]> base = baseline();
// 166 条,且 9103 的两个常量各自成行 —— 不能按数字去重后只剩 165 条
assertThat(base).hasSize(166);
// 169 条,且 9103 的两个常量各自成行 —— 不能按数字去重后只剩 168 条
assertThat(base).hasSize(169);
assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK");
assertThat(ErrorCode.values()).hasSize(166);
assertThat(ErrorCode.values()).hasSize(169);
int checked = 0;
for (ErrorCode code : ErrorCode.values()) {
@ -92,28 +93,29 @@ class R01BIndependentHttpMappingParityTest {
.isEqualTo(expected[1]);
checked++;
}
assertThat(checked).isEqualTo(166);
assertThat(checked).isEqualTo(169);
}
@Test
@DisplayName("独立复验:112 个显式映射 + 54 个默认 400")
@DisplayName("密码登录:116 个显式映射 + 53 个默认 400")
void explicitAndDefaultCountsMatchBaseline() {
Map<String, int[]> base = baseline();
long explicitInBaseline = base.values().stream().filter(v -> v[1] != 400).count();
assertThat(explicitInBaseline).isEqualTo(112);
assertThat(explicitInBaseline).isEqualTo(115);
assertThat(base.size() - explicitInBaseline).isEqualTo(54);
assertThat(registry.explicitMappingCount()).isEqualTo(112);
assertThat(registry.explicitMappingCount()).isEqualTo(115);
assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54);
// 112 个常量仅覆盖 111 个唯一数字码(9103 共用)
assertThat(registry.explicitNumericCodeCount()).isEqualTo(111);
// 115 个常量仅覆盖 114 个唯一数字码(9103 共用)
assertThat(registry.explicitNumericCodeCount()).isEqualTo(114);
}
@Test
@DisplayName("独立复验:关键既有语义逐项保持")
void keySemanticsPreserved() {
assertThat(registry.resolve(ErrorCode.UNAUTHORIZED)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(ErrorCode.CREDENTIALS_INVALID)).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(registry.resolve(ErrorCode.FORBIDDEN)).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(ErrorCode.RATE_LIMITED)).isEqualTo(HttpStatus.BAD_REQUEST);

8
mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java

@ -311,7 +311,9 @@ class PrivacySettingsControllerTest {
assertThat(put.path("requestBody").toString().contains("PrivacySettingsUpdateRequest")).isTrue();
// 无敏感字段
String schemas = root.path("components").path("schemas").toString().toLowerCase();
assertFalse(schemas.contains("password"));
String responseSchema = get.path("responses").path("200").toString().toLowerCase();
assertThat(responseSchema.contains("\"password\"")).isFalse();
assertThat(responseSchema.contains("\"passwordhash\"")).isFalse();
assertThat(responseSchema.contains("\"token\"")).isFalse();
}
}
}

8
mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java

@ -258,7 +258,9 @@ class ProfileControllerTest {
assertThat(get.path("responses").has("403")).isTrue();
// 无敏感字段(DTO schema 不含 password 等)
String schemas = root.path("components").path("schemas").toString().toLowerCase();
assertFalse(schemas.contains("password"));
String responseSchema = get.path("responses").path("200").toString().toLowerCase();
assertThat(responseSchema.contains("\"password\"")).isFalse();
assertThat(responseSchema.contains("\"passwordhash\"")).isFalse();
assertThat(responseSchema.contains("\"token\"")).isFalse();
}
}
}

5
mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv

@ -8,7 +8,10 @@ UNAUTHORIZED 2001 400
FORBIDDEN 2002 FORBIDDEN
TOKEN_EXPIRED 2003 400
TOKEN_INVALID 2004 400
CREDENTIALS_INVALID 2005 400
CREDENTIALS_INVALID 2005 UNAUTHORIZED
PASSWORD_RATE_LIMITED 2006 TOO_MANY_REQUESTS
PASSWORD_STATE_CONFLICT 2007 CONFLICT
PASSWORD_POLICY_INVALID 2008 400
SMS_CODE_SENT 3001 400
SMS_CODE_EXPIRED 3002 400
SMS_CODE_INVALID 3003 400

Can't render this file because it has a wrong number of fields in line 4.
Loading…
Cancel
Save