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. 6
      mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java
  36. 6
      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": { "components": {
@ -15823,6 +15948,114 @@
"$ref": "#/components/schemas/TokenPairResponse" "$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": { "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')) d = json.load(open(p, encoding='utf-8'))
print('paths:', len(d['paths'])) 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'] 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 assert '/conversations/{id}/messages' in paths and '/conversations/{id}/read' in paths
print(' N07 recall path/schema validated; existing messaging operations preserved') print(' N07 recall path/schema validated; existing messaging operations preserved')
# ── S02:Access/Refresh Token 隔离的两个新增 operation ───────────── # ── S02:Access/Refresh Token 隔离 + 密码认证 operation ─────────────
# 只允许新增 /auth/refresh 与 /auth/logout;登录原字段不得删除; # 只允许新增 /auth/refresh 与 /auth/logout;登录原字段不得删除;
# 新 schema 不得暴露数据库字段或凭据。 # 新 schema 不得暴露数据库字段或凭据。
assert '/auth/refresh' in paths, 'Missing /auth/refresh' assert '/auth/refresh' in paths, 'Missing /auth/refresh'
@ -624,13 +624,28 @@ for forbidden in ('tokenHash', 'refreshTokenHash', 'sessionId', 'revocationReaso
'phone', 'secret', 'password', 'jwt'): 'phone', 'secret', 'password', 'jwt'):
assert forbidden not in _s02_text, f'S02 contract exposes forbidden field {forbidden}' 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')} _auth_paths = {p for p in paths if p.startswith('/auth')}
assert _auth_paths == { assert _auth_paths == {
'/auth/sms/send', '/auth/login', '/auth/me', '/auth/sms/send', '/auth/login', '/auth/me',
'/auth/refresh', '/auth/logout', '/auth/refresh', '/auth/logout',
'/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset',
}, f'Unexpected /auth paths: {sorted(_auth_paths)}' }, 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:唯一新增的企业简历下载接口 ──────────────────────────────── # ── S03:唯一新增的企业简历下载接口 ────────────────────────────────
_download_path = '/company/candidates/{applicationId}/resume' _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}' 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') 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()) operations = sum(len(methods) for methods in d['paths'].values())
print('operations:', operations) 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(" Offer advance endpoints all present")
print(" 26 operations all present") print(" 26 operations all present")
print(" Account deletion endpoints present (3 operations)") 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 @Transactional
public SessionTokens createSession(User user, Instant capturedNow, AuditContext ctx) { 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 再由过滤器拒绝 —— 那样 Token 已经流出去了。 // 不能先发 Token 再由过滤器拒绝 —— 那样 Token 已经流出去了。
if (user == null || !isUsable(user)) { if (lockedUser == null || !isUsable(lockedUser)) {
throw new InvalidTokenException(); throw new InvalidTokenException();
} }
String sessionId = UUID.randomUUID().toString(); String sessionId = UUID.randomUUID().toString();
@ -97,12 +101,12 @@ public class AuthSessionService {
String refreshJti = UUID.randomUUID().toString(); String refreshJti = UUID.randomUUID().toString();
IssuedToken access = jwtTokenProvider.generateAccessToken( IssuedToken access = jwtTokenProvider.generateAccessToken(
user.getId(), user.getRole().name(), sessionId, accessJti, capturedNow); lockedUser.getId(), lockedUser.getRole().name(), sessionId, accessJti, capturedNow);
IssuedToken refresh = jwtTokenProvider.generateRefreshToken( IssuedToken refresh = jwtTokenProvider.generateRefreshToken(
user.getId(), sessionId, refreshJti, capturedNow); lockedUser.getId(), sessionId, refreshJti, capturedNow);
authTokenRepository.save(AuthToken.builder() authTokenRepository.save(AuthToken.builder()
.userId(user.getId()) .userId(lockedUser.getId())
.tokenHash(authTokenHasher.hash(refresh.token())) .tokenHash(authTokenHasher.hash(refresh.token()))
.tokenType(AuthToken.TYPE_REFRESH) .tokenType(AuthToken.TYPE_REFRESH)
.expiredAt(toLocal(refresh.expiresAt())) .expiredAt(toLocal(refresh.expiresAt()))
@ -113,7 +117,7 @@ public class AuthSessionService {
.createdAt(toLocal(capturedNow)) .createdAt(toLocal(capturedNow))
.build()); .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); return new SessionTokens(access.token(), refresh.token(), sessionId);
} }
@ -196,6 +200,8 @@ public class AuthSessionService {
*/ */
private RefreshAttempt executeRefreshAttempt(RefreshTokenClaims claims, String hash, private RefreshAttempt executeRefreshAttempt(RefreshTokenClaims claims, String hash,
Instant capturedNow, AuditContext ctx) { Instant capturedNow, AuditContext ctx) {
// Lock order is users -> auth_tokens, matching password mutation transactions.
User user = requireUsableUserForUpdate(claims.userId());
AuthToken record = authTokenRepository.findTopByTokenHashOrderByIdDesc(hash) AuthToken record = authTokenRepository.findTopByTokenHashOrderByIdDesc(hash)
.orElseThrow(InvalidTokenException::new); .orElseThrow(InvalidTokenException::new);
@ -206,8 +212,6 @@ public class AuthSessionService {
throw new InvalidTokenException(); throw new InvalidTokenException();
} }
User user = requireUsableUser(claims.userId());
int consumed = authTokenRepository.consumeIfCurrent( int consumed = authTokenRepository.consumeIfCurrent(
record.getId(), record.getVersion(), toLocal(capturedNow), toLocal(capturedNow)); record.getId(), record.getVersion(), toLocal(capturedNow), toLocal(capturedNow));
@ -310,6 +314,12 @@ public class AuthSessionService {
.orElseThrow(InvalidTokenException::new); .orElseThrow(InvalidTokenException::new);
} }
private User requireUsableUserForUpdate(Long userId) {
return userRepository.findByIdForUpdate(userId)
.filter(this::isUsable)
.orElseThrow(InvalidTokenException::new);
}
private boolean isUserUsable(Long userId) { private boolean isUserUsable(Long userId) {
Optional<User> user = userRepository.findById(userId); Optional<User> user = userRepository.findById(userId);
return user.isPresent() && isUsable(user.get()); 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) @Column(name = "password_hash", length = 255)
private String passwordHash; private String passwordHash;
@Column(name = "password_changed_at")
private LocalDateTime passwordChangedAt;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false, length = 30) @Column(name = "role", nullable = false, length = 30)
@Builder.Default @Builder.Default

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

@ -1,13 +1,26 @@
package com.mydreamjob.auth; package com.mydreamjob.auth;
import org.springframework.data.jpa.repository.JpaRepository; 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 org.springframework.stereotype.Repository;
import jakarta.persistence.LockModeType;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByPhone(String phone); 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); Optional<User> findByEmail(String email);
boolean existsByPhone(String phone); boolean existsByPhone(String phone);
boolean existsByEmail(String email); 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_EXPIRED(2003, "token expired"),
TOKEN_INVALID(2004, "token invalid"), TOKEN_INVALID(2004, "token invalid"),
CREDENTIALS_INVALID(2005, "invalid credentials"), 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 // 验证码 3xxx
SMS_CODE_SENT(3001, "sms code sent"), 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.USER_NOT_FOUND,
ErrorCode.VERIFICATION_NOT_FOUND) ErrorCode.VERIFICATION_NOT_FOUND)
.put(HttpStatus.CONFLICT, .put(HttpStatus.CONFLICT,
ErrorCode.PASSWORD_STATE_CONFLICT,
ErrorCode.ABILITY_ATTEMPT_INVALID_STATE, ErrorCode.ABILITY_ATTEMPT_INVALID_STATE,
ErrorCode.ABILITY_CONCURRENT_UPDATE, ErrorCode.ABILITY_CONCURRENT_UPDATE,
ErrorCode.ABILITY_DUPLICATE_ACTIVE, ErrorCode.ABILITY_DUPLICATE_ACTIVE,
@ -222,9 +223,12 @@ public final class ErrorHttpStatusRegistry {
ErrorCode.VERIFICATION_ALREADY_APPROVED, ErrorCode.VERIFICATION_ALREADY_APPROVED,
ErrorCode.VERIFICATION_ALREADY_PENDING) ErrorCode.VERIFICATION_ALREADY_PENDING)
.put(HttpStatus.TOO_MANY_REQUESTS, .put(HttpStatus.TOO_MANY_REQUESTS,
ErrorCode.PASSWORD_RATE_LIMITED,
ErrorCode.ABILITY_RETAKE_LIMIT, ErrorCode.ABILITY_RETAKE_LIMIT,
ErrorCode.REPORT_RATE_LIMITED, ErrorCode.REPORT_RATE_LIMITED,
ErrorCode.TALENT_INVITATION_RATE_LIMITED) ErrorCode.TALENT_INVITATION_RATE_LIMITED)
.put(HttpStatus.UNAUTHORIZED,
ErrorCode.CREDENTIALS_INVALID)
.build(); .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/sms/send").permitAll()
.requestMatchers("/auth/login").permitAll() .requestMatchers("/auth/login").permitAll()
.requestMatchers("/auth/password/login").permitAll()
.requestMatchers("/auth/password/reset").permitAll()
.requestMatchers("/auth/register").permitAll() .requestMatchers("/auth/register").permitAll()
// S02:刷新允许匿名进入 Controller;是否成功仍取决于 Refresh Token 校验。 // S02:刷新允许匿名进入 Controller;是否成功仍取决于 Refresh Token 校验。
// permitAll 只表示可进入,不代表 Token 有效。 // permitAll 只表示可进入,不代表 Token 有效。
@ -86,6 +88,8 @@ public class SecurityConfig {
.requestMatchers("/auth/me").authenticated() .requestMatchers("/auth/me").authenticated()
// S02:登出必须已认证 // S02:登出必须已认证
.requestMatchers("/auth/logout").authenticated() .requestMatchers("/auth/logout").authenticated()
.requestMatchers("/auth/password/set").authenticated()
.requestMatchers("/auth/password/change").authenticated()
// 企业资料(需登录) // 企业资料(需登录)
.requestMatchers("/companies/me/**").authenticated() .requestMatchers("/companies/me/**").authenticated()
// V41 学生人才发现授权与企业人才发现推荐(角色和归属由 Service 校验) // 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.SmsVerificationService;
import com.mydreamjob.auth.dto.RefreshTokenRequest; import com.mydreamjob.auth.dto.RefreshTokenRequest;
import com.mydreamjob.auth.dto.TokenPairResponse; 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.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode; import com.mydreamjob.common.exception.ErrorCode;
@ -43,6 +49,7 @@ public class AuthController {
private final AuthService authService; private final AuthService authService;
private final SmsVerificationService smsVerificationService; private final SmsVerificationService smsVerificationService;
private final CurrentUserAccessor currentUserAccessor; private final CurrentUserAccessor currentUserAccessor;
private final PasswordAuthenticationService passwordAuthenticationService;
@Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。") @Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。")
@PostMapping("/sms/send") @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", @Operation(summary = "刷新 Token",
description = "使用 Refresh Token 换取新的 Access/Refresh Token 对;旧 Refresh Token 立即失效") description = "使用 Refresh Token 换取新的 Access/Refresh Token 对;旧 Refresh Token 立即失效")
@PostMapping("/refresh") @PostMapping("/refresh")

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

@ -126,6 +126,17 @@ app:
max-attempts: 5 max-attempts: 5
lockout-seconds: ${SMS_LOCKOUT_SECONDS:300} 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: offer:
scheduler: scheduler:
enabled: ${OFFER_SCHEDULER_ENABLED:true} 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(pathCount).as("密码登录新增四个路径后应为 180 paths").isEqualTo(180);
assertThat(opCount).as("S03 基线应为 214 operations").isEqualTo(214); assertThat(opCount).as("密码登录新增四个 operation 后应为 218 operations").isEqualTo(218);
var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); var download = paths.path("/company/candidates/{applicationId}/resume").path("get");
assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); 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"); "RISK_RULE_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE", "RISK_INVALID_ACTION");
private static final Set<String> TOO_MANY_REQUESTS = Set.of( 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()); 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 (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND;
if (CONFLICT.contains(name)) return HttpStatus.CONFLICT; if (CONFLICT.contains(name)) return HttpStatus.CONFLICT;
if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS; 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; 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(); private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
/** 允许出现在显式映射里的状态集合:重构前后都必须只有这四个。 */ /** 允许出现在显式映射里的状态集合;密码登录新增 401/400 契约。 */
private static final Set<HttpStatus> ALLOWED_EXPLICIT_STATUSES = Set.of( 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 @Test
@DisplayName("B2:显式映射数量与重构前快照一致(112 个 ErrorCode 常量)") @DisplayName("密码登录:显式映射包含新增认证状态")
void explicitMappingCountMatchesBaseline() { void explicitMappingCountMatchesBaseline() {
assertThat(registry.explicitMappingCount()).isEqualTo(112); assertThat(registry.explicitMappingCount()).isEqualTo(115);
// 112 个常量只覆盖 111 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与 // 116 个常量只覆盖 115 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与
// DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。 // DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。
assertThat(registry.explicitNumericCodeCount()).isEqualTo(111); assertThat(registry.explicitNumericCodeCount()).isEqualTo(114);
} }
@Test @Test
@ -120,12 +121,12 @@ class ErrorHttpStatusRegistryTest {
.isInstanceOf(UnsupportedOperationException.class); .isInstanceOf(UnsupportedOperationException.class);
assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN)) assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN))
.isInstanceOf(UnsupportedOperationException.class); .isInstanceOf(UnsupportedOperationException.class);
assertThat(view).hasSize(112); assertThat(view).hasSize(115);
Map<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView(); Map<Integer, HttpStatus> numeric = ErrorHttpStatusRegistry.numericMappingsView();
assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK)) assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK))
.isInstanceOf(UnsupportedOperationException.class); .isInstanceOf(UnsupportedOperationException.class);
assertThat(numeric).hasSize(111); assertThat(numeric).hasSize(114);
} }
@Test @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"); "VERIFICATION_ALREADY_APPROVED", "VERIFICATION_ALREADY_PENDING");
private static final Set<String> TOO_MANY_REQUESTS = Set.of( 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 ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry();
private final GlobalExceptionHandler handler = private final GlobalExceptionHandler handler =
@ -91,15 +96,18 @@ class GlobalExceptionHandlerMappingParityTest {
if (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN; if (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN;
if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND; if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND;
if (CONFLICT.contains(name)) return HttpStatus.CONFLICT; 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 (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; return HttpStatus.BAD_REQUEST;
} }
@Test @Test
@DisplayName("B2:显式映射集合与重构前快照逐项相等") @DisplayName("B2:显式映射集合与重构前快照逐项相等")
void explicitMappingSetEqualsPreRefactorSnapshot() { void explicitMappingSetEqualsPreRefactorSnapshot() {
assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(112); assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(115);
assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(111); assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(114);
for (ErrorCode errorCode : ErrorCode.values()) { for (ErrorCode errorCode : ErrorCode.values()) {
HttpStatus expected = baselineStatus(errorCode); 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 "NOT_FOUND" -> HttpStatus.NOT_FOUND;
case "CONFLICT" -> HttpStatus.CONFLICT; case "CONFLICT" -> HttpStatus.CONFLICT;
case "TOO_MANY_REQUESTS" -> HttpStatus.TOO_MANY_REQUESTS; case "TOO_MANY_REQUESTS" -> HttpStatus.TOO_MANY_REQUESTS;
case "UNAUTHORIZED" -> HttpStatus.UNAUTHORIZED;
default -> HttpStatus.BAD_REQUEST; default -> HttpStatus.BAD_REQUEST;
}; };
} }
@ -77,10 +78,10 @@ class R01BIndependentHttpMappingParityTest {
void everyEnumConstantMatchesHeadBaselineByName() { void everyEnumConstantMatchesHeadBaselineByName() {
Map<String, int[]> base = baseline(); Map<String, int[]> base = baseline();
// 166 条,且 9103 的两个常量各自成行 —— 不能按数字去重后只剩 165 条 // 169 条,且 9103 的两个常量各自成行 —— 不能按数字去重后只剩 168 条
assertThat(base).hasSize(166); assertThat(base).hasSize(169);
assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK"); assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK");
assertThat(ErrorCode.values()).hasSize(166); assertThat(ErrorCode.values()).hasSize(169);
int checked = 0; int checked = 0;
for (ErrorCode code : ErrorCode.values()) { for (ErrorCode code : ErrorCode.values()) {
@ -92,28 +93,29 @@ class R01BIndependentHttpMappingParityTest {
.isEqualTo(expected[1]); .isEqualTo(expected[1]);
checked++; checked++;
} }
assertThat(checked).isEqualTo(166); assertThat(checked).isEqualTo(169);
} }
@Test @Test
@DisplayName("独立复验:112 个显式映射 + 54 个默认 400") @DisplayName("密码登录:116 个显式映射 + 53 个默认 400")
void explicitAndDefaultCountsMatchBaseline() { void explicitAndDefaultCountsMatchBaseline() {
Map<String, int[]> base = baseline(); Map<String, int[]> base = baseline();
long explicitInBaseline = base.values().stream().filter(v -> v[1] != 400).count(); 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(base.size() - explicitInBaseline).isEqualTo(54);
assertThat(registry.explicitMappingCount()).isEqualTo(112); assertThat(registry.explicitMappingCount()).isEqualTo(115);
assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54); assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54);
// 112 个常量仅覆盖 111 个唯一数字码(9103 共用) // 115 个常量仅覆盖 114 个唯一数字码(9103 共用)
assertThat(registry.explicitNumericCodeCount()).isEqualTo(111); assertThat(registry.explicitNumericCodeCount()).isEqualTo(114);
} }
@Test @Test
@DisplayName("独立复验:关键既有语义逐项保持") @DisplayName("独立复验:关键既有语义逐项保持")
void keySemanticsPreserved() { void keySemanticsPreserved() {
assertThat(registry.resolve(ErrorCode.UNAUTHORIZED)).isEqualTo(HttpStatus.BAD_REQUEST); 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.FORBIDDEN)).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(registry.resolve(ErrorCode.RATE_LIMITED)).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(registry.resolve(ErrorCode.RATE_LIMITED)).isEqualTo(HttpStatus.BAD_REQUEST);

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

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

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

@ -258,7 +258,9 @@ class ProfileControllerTest {
assertThat(get.path("responses").has("403")).isTrue(); assertThat(get.path("responses").has("403")).isTrue();
// 无敏感字段(DTO schema 不含 password 等) // 无敏感字段(DTO schema 不含 password 等)
String schemas = root.path("components").path("schemas").toString().toLowerCase(); String responseSchema = get.path("responses").path("200").toString().toLowerCase();
assertFalse(schemas.contains("password")); 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 FORBIDDEN 2002 FORBIDDEN
TOKEN_EXPIRED 2003 400 TOKEN_EXPIRED 2003 400
TOKEN_INVALID 2004 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_SENT 3001 400
SMS_CODE_EXPIRED 3002 400 SMS_CODE_EXPIRED 3002 400
SMS_CODE_INVALID 3003 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