37 changed files with 2592 additions and 43 deletions
@ -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 版本号和新增错误码数字,不得修改既有迁移或占用已被其他并行模块使用的编号。 |
||||
|
|
||||
|
其余产品与安全决策已在本文固定。任何偏离必须先更新设计并记录原因,不能在实现过程中隐式改变。 |
||||
@ -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 格式或短信登录接口。提交前仍需完成全量测试、敏感扫描、暂存和审查。 |
||||
@ -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 与全量回归,可以进入独立复验后的精确暂存检查点。** |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -0,0 +1,3 @@ |
|||||
|
package com.mydreamjob.auth.dto; |
||||
|
|
||||
|
public record PasswordOperationResponse(boolean passwordConfigured, boolean reauthenticationRequired) {} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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) {} |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
@ -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) {} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
@ -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()); |
||||
|
} |
||||
|
} |
||||
|
Can't render this file because it has a wrong number of fields in line 4.
|
Loading…
Reference in new issue