diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 24e4e5e..260f842 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -9301,6 +9301,131 @@ } } } + }, + "/auth/password/set": { + "post": { + "tags": [ + "认证" + ], + "summary": "首次设置密码", + "operationId": "setPassword", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordSetRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePasswordOperationResponse" + } + } + } + } + } + } + }, + "/auth/password/reset": { + "post": { + "tags": [ + "认证" + ], + "summary": "短信找回并重置密码", + "operationId": "resetPassword", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordResetRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePasswordOperationResponse" + } + } + } + } + } + } + }, + "/auth/password/login": { + "post": { + "tags": [ + "认证" + ], + "summary": "手机号密码登录", + "description": "使用手机号和密码登录,返回 Access/Refresh Token;不自动创建账号", + "operationId": "passwordLogin", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordLoginRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponseMapStringObject" + } + } + } + } + } + } + }, + "/auth/password/change": { + "post": { + "tags": [ + "认证" + ], + "summary": "修改密码", + "operationId": "changePassword", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordChangeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePasswordOperationResponse" + } + } + } + } + } + } } }, "components": { @@ -15823,6 +15948,114 @@ "$ref": "#/components/schemas/TokenPairResponse" } } + }, + "PasswordSetRequest": { + "type": "object", + "properties": { + "smsCode": { + "type": "string", + "minLength": 1 + }, + "newPassword": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "newPassword", + "smsCode" + ] + }, + "PasswordResetRequest": { + "type": "object", + "properties": { + "phone": { + "type": "string", + "minLength": 1, + "pattern": "[0-9]{11}" + }, + "smsCode": { + "type": "string", + "minLength": 1 + }, + "newPassword": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "newPassword", + "phone", + "smsCode" + ] + }, + "PasswordLoginRequest": { + "type": "object", + "properties": { + "phone": { + "type": "string", + "minLength": 1, + "pattern": "[0-9]{11}" + }, + "password": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "password", + "phone" + ] + }, + "PasswordChangeRequest": { + "type": "object", + "properties": { + "currentPassword": { + "type": "string", + "minLength": 1 + }, + "newPassword": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "currentPassword", + "newPassword" + ] + }, + "PasswordOperationResponse": { + "type": "object", + "properties": { + "passwordConfigured": { + "type": "boolean" + }, + "reauthenticationRequired": { + "type": "boolean" + } + } + }, + "ApiResponsePasswordOperationResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/PasswordOperationResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } } }, "securitySchemes": { diff --git a/docs/phone-password-authentication-design.md b/docs/phone-password-authentication-design.md new file mode 100644 index 0000000..4238f96 --- /dev/null +++ b/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` | +| 用户状态 | `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": "", + "refreshToken": "", + "expiresIn": 86400 + }, + "requestId": "..." +} +``` + +约束: + +- `phone` 必须为 11 位数字; +- `password` 必填,输入上限 64 个字符; +- 不存在的手机号不得创建用户; +- 失败原因统一,不返回“用户不存在”“尚未设置密码”“账号停用”等差异信息; +- 成功后清除该手机号引用的失败计数,但不清除 IP 窗口计数。 + +### 5.2 首次设置密码 + +```http +POST /auth/password/set +Authorization: Bearer +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 +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 版本号和新增错误码数字,不得修改既有迁移或占用已被其他并行模块使用的编号。 + +其余产品与安全决策已在本文固定。任何偏离必须先更新设计并记录原因,不能在实现过程中隐式改变。 diff --git a/docs/phone-password-authentication-implementation-report.md b/docs/phone-password-authentication-implementation-report.md new file mode 100644 index 0000000..9b1f676 --- /dev/null +++ b/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 格式或短信登录接口。提交前仍需完成全量测试、敏感扫描、暂存和审查。 diff --git a/docs/phone-password-authentication-independent-verification-report.md b/docs/phone-password-authentication-independent-verification-report.md new file mode 100644 index 0000000..7216b6b --- /dev/null +++ b/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 与全量回归,可以进入独立复验后的精确暂存检查点。** diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 0baf84d..d16a3f1 100644 --- a/docs/verify_openapi.py +++ b/docs/verify_openapi.py @@ -6,7 +6,7 @@ p = PROJECT_ROOT / 'docs' / 'openapi-rc.json' d = json.load(open(p, encoding='utf-8')) print('paths:', len(d['paths'])) -assert len(d['paths']) == 176, f"Expected 176 paths, got {len(d['paths'])}" +assert len(d['paths']) == 180, f"Expected 180 paths, got {len(d['paths'])}" paths = d['paths'] @@ -591,7 +591,7 @@ assert '/conversations' in paths and '/conversations/messages' in paths assert '/conversations/{id}/messages' in paths and '/conversations/{id}/read' in paths print(' N07 recall path/schema validated; existing messaging operations preserved') -# ── S02:Access/Refresh Token 隔离的两个新增 operation ───────────── +# ── S02:Access/Refresh Token 隔离 + 密码认证 operation ───────────── # 只允许新增 /auth/refresh 与 /auth/logout;登录原字段不得删除; # 新 schema 不得暴露数据库字段或凭据。 assert '/auth/refresh' in paths, 'Missing /auth/refresh' @@ -624,13 +624,28 @@ for forbidden in ('tokenHash', 'refreshTokenHash', 'sessionId', 'revocationReaso 'phone', 'secret', 'password', 'jwt'): assert forbidden not in _s02_text, f'S02 contract exposes forbidden field {forbidden}' -# 未新增其他认证或管理接口(认证相关路径只能是既有集合 + 这两个) +# 密码认证四个 operation 的请求体必须是白名单 DTO,不能携带 userId/sessionId/角色。 +_password_paths = { + '/auth/password/login': 'PasswordLoginRequest', + '/auth/password/set': 'PasswordSetRequest', + '/auth/password/change': 'PasswordChangeRequest', + '/auth/password/reset': 'PasswordResetRequest', +} +for _path, _schema_name in _password_paths.items(): + assert _path in paths and set(paths[_path]) == {'post'}, f'Invalid password path: {_path}' + _body = paths[_path]['post']['requestBody']['content']['application/json']['schema'] + assert _body.get('$ref', '').endswith(_schema_name), f'{_path} request schema mismatch' + _schema_text = json.dumps(resolve_schema(d['components']['schemas'][_schema_name]), ensure_ascii=False) + for _forbidden in ('userId', 'sessionId', 'role', 'passwordHash', 'token', 'secret'): + assert _forbidden not in _schema_text, f'{_path} exposes {_forbidden}' + _auth_paths = {p for p in paths if p.startswith('/auth')} assert _auth_paths == { '/auth/sms/send', '/auth/login', '/auth/me', '/auth/refresh', '/auth/logout', + '/auth/password/login', '/auth/password/set', '/auth/password/change', '/auth/password/reset', }, f'Unexpected /auth paths: {sorted(_auth_paths)}' -print(' S02 refresh/logout operations and isolation schemas validated') +print(' S02 refresh/logout and password authentication operations validated') # ── S03:唯一新增的企业简历下载接口 ──────────────────────────────── _download_path = '/company/candidates/{applicationId}/resume' @@ -660,10 +675,10 @@ for _p in paths: assert not _p.startswith('/actuator'), f'Actuator must not appear in the business contract: {_p}' print(' S03 company resume download contract validated; candidate DTOs remain redacted') -# 统计 operation 总数并校验 V43 + N07 + S02 + S03 计数 214 +# 统计 operation 总数并校验 V43 + N07 + S02 + S03 + password authentication 计数 218 operations = sum(len(methods) for methods in d['paths'].values()) print('operations:', operations) -assert operations == 214, f"Expected 214 operations, got {operations}" +assert operations == 218, f"Expected 218 operations, got {operations}" print(" Offer advance endpoints all present") print(" 26 operations all present") print(" Account deletion endpoints present (3 operations)") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java index e0348d3..0b36c25 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/AuthSessionService.java @@ -87,9 +87,13 @@ public class AuthSessionService { */ @Transactional public SessionTokens createSession(User user, Instant capturedNow, AuditContext ctx) { + // All session issuers take the users row lock before touching auth_tokens. Password + // changes use the same order, so a committed change cannot be followed by a session + // created from credentials validated before that change. + User lockedUser = user == null ? null : userRepository.findByIdForUpdate(user.getId()).orElse(null); // 失败关闭:不给不可用用户创建活动会话,更不返回 Token。 // 不能先发 Token 再由过滤器拒绝 —— 那样 Token 已经流出去了。 - if (user == null || !isUsable(user)) { + if (lockedUser == null || !isUsable(lockedUser)) { throw new InvalidTokenException(); } String sessionId = UUID.randomUUID().toString(); @@ -97,12 +101,12 @@ public class AuthSessionService { String refreshJti = UUID.randomUUID().toString(); IssuedToken access = jwtTokenProvider.generateAccessToken( - user.getId(), user.getRole().name(), sessionId, accessJti, capturedNow); + lockedUser.getId(), lockedUser.getRole().name(), sessionId, accessJti, capturedNow); IssuedToken refresh = jwtTokenProvider.generateRefreshToken( - user.getId(), sessionId, refreshJti, capturedNow); + lockedUser.getId(), sessionId, refreshJti, capturedNow); authTokenRepository.save(AuthToken.builder() - .userId(user.getId()) + .userId(lockedUser.getId()) .tokenHash(authTokenHasher.hash(refresh.token())) .tokenType(AuthToken.TYPE_REFRESH) .expiredAt(toLocal(refresh.expiresAt())) @@ -113,7 +117,7 @@ public class AuthSessionService { .createdAt(toLocal(capturedNow)) .build()); - auditService.logSessionCreated(user.getId(), sessionRef(sessionId), ctx, toLocal(capturedNow)); + auditService.logSessionCreated(lockedUser.getId(), sessionRef(sessionId), ctx, toLocal(capturedNow)); return new SessionTokens(access.token(), refresh.token(), sessionId); } @@ -196,6 +200,8 @@ public class AuthSessionService { */ private RefreshAttempt executeRefreshAttempt(RefreshTokenClaims claims, String hash, Instant capturedNow, AuditContext ctx) { + // Lock order is users -> auth_tokens, matching password mutation transactions. + User user = requireUsableUserForUpdate(claims.userId()); AuthToken record = authTokenRepository.findTopByTokenHashOrderByIdDesc(hash) .orElseThrow(InvalidTokenException::new); @@ -206,8 +212,6 @@ public class AuthSessionService { throw new InvalidTokenException(); } - User user = requireUsableUser(claims.userId()); - int consumed = authTokenRepository.consumeIfCurrent( record.getId(), record.getVersion(), toLocal(capturedNow), toLocal(capturedNow)); @@ -310,6 +314,12 @@ public class AuthSessionService { .orElseThrow(InvalidTokenException::new); } + private User requireUsableUserForUpdate(Long userId) { + return userRepository.findByIdForUpdate(userId) + .filter(this::isUsable) + .orElseThrow(InvalidTokenException::new); + } + private boolean isUserUsable(Long userId) { Optional user = userRepository.findById(userId); return user.isPresent() && isUsable(user.get()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java index 2d4b57f..023e1d8 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/User.java @@ -43,6 +43,9 @@ public class User { @Column(name = "password_hash", length = 255) private String passwordHash; + @Column(name = "password_changed_at") + private LocalDateTime passwordChangedAt; + @Enumerated(EnumType.STRING) @Column(name = "role", nullable = false, length = 30) @Builder.Default diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java index e4de3b8..5525ae4 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/auth/UserRepository.java @@ -1,13 +1,26 @@ package com.mydreamjob.auth; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import jakarta.persistence.LockModeType; + import java.util.Optional; @Repository public interface UserRepository extends JpaRepository { Optional findByPhone(String phone); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select u from User u where u.phone = :phone") + Optional findByPhoneForUpdate(@Param("phone") String phone); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select u from User u where u.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); Optional findByEmail(String email); boolean existsByPhone(String phone); boolean existsByEmail(String email); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordChangeRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordChangeRequest.java new file mode 100644 index 0000000..572f976 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordLoginRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordLoginRequest.java new file mode 100644 index 0000000..8e516c2 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordOperationResponse.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordOperationResponse.java new file mode 100644 index 0000000..5963ffa --- /dev/null +++ b/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) {} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordResetRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordResetRequest.java new file mode 100644 index 0000000..26cc899 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/dto/PasswordSetRequest.java new file mode 100644 index 0000000..aa7f303 --- /dev/null +++ b/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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationProperties.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationProperties.java new file mode 100644 index 0000000..3782f22 --- /dev/null +++ b/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"); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordAuthenticationService.java new file mode 100644 index 0000000..96dd6cb --- /dev/null +++ b/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 login(String phone, String rawPassword, String requestId, + String ipAddress, String userAgent) { + rateLimiter.checkAllowed(phone, ipAddress); + String normalized = passwordPolicy.normalize(rawPassword); + Optional 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 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) {} +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordPolicy.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordPolicy.java new file mode 100644 index 0000000..c50bc75 --- /dev/null +++ b/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 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"); + } + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordRateLimiter.java b/mydreamjob-server/src/main/java/com/mydreamjob/auth/password/PasswordRateLimiter.java new file mode 100644 index 0000000..b189fee --- /dev/null +++ b/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 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 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 redisTemplate; + private final PasswordAuthenticationProperties properties; + + public PasswordRateLimiter(RedisTemplate 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"); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java index 09d6786..7f514ee 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorCode.java @@ -16,6 +16,9 @@ public enum ErrorCode { TOKEN_EXPIRED(2003, "token expired"), TOKEN_INVALID(2004, "token invalid"), CREDENTIALS_INVALID(2005, "invalid credentials"), + PASSWORD_RATE_LIMITED(2006, "password authentication rate limited"), + PASSWORD_STATE_CONFLICT(2007, "password state conflict"), + PASSWORD_POLICY_INVALID(2008, "password does not meet policy"), // 验证码 3xxx SMS_CODE_SENT(3001, "sms code sent"), diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java index 86adb78..0715134 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/common/exception/ErrorHttpStatusRegistry.java @@ -144,6 +144,7 @@ public final class ErrorHttpStatusRegistry { ErrorCode.USER_NOT_FOUND, ErrorCode.VERIFICATION_NOT_FOUND) .put(HttpStatus.CONFLICT, + ErrorCode.PASSWORD_STATE_CONFLICT, ErrorCode.ABILITY_ATTEMPT_INVALID_STATE, ErrorCode.ABILITY_CONCURRENT_UPDATE, ErrorCode.ABILITY_DUPLICATE_ACTIVE, @@ -222,9 +223,12 @@ public final class ErrorHttpStatusRegistry { ErrorCode.VERIFICATION_ALREADY_APPROVED, ErrorCode.VERIFICATION_ALREADY_PENDING) .put(HttpStatus.TOO_MANY_REQUESTS, + ErrorCode.PASSWORD_RATE_LIMITED, ErrorCode.ABILITY_RETAKE_LIMIT, ErrorCode.REPORT_RATE_LIMITED, ErrorCode.TALENT_INVITATION_RATE_LIMITED) + .put(HttpStatus.UNAUTHORIZED, + ErrorCode.CREDENTIALS_INVALID) .build(); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java index 14614b3..24bfefc 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/config/SecurityConfig.java @@ -78,6 +78,8 @@ public class SecurityConfig { // 认证相关 .requestMatchers("/auth/sms/send").permitAll() .requestMatchers("/auth/login").permitAll() + .requestMatchers("/auth/password/login").permitAll() + .requestMatchers("/auth/password/reset").permitAll() .requestMatchers("/auth/register").permitAll() // S02:刷新允许匿名进入 Controller;是否成功仍取决于 Refresh Token 校验。 // permitAll 只表示可进入,不代表 Token 有效。 @@ -86,6 +88,8 @@ public class SecurityConfig { .requestMatchers("/auth/me").authenticated() // S02:登出必须已认证 .requestMatchers("/auth/logout").authenticated() + .requestMatchers("/auth/password/set").authenticated() + .requestMatchers("/auth/password/change").authenticated() // 企业资料(需登录) .requestMatchers("/companies/me/**").authenticated() // V41 学生人才发现授权与企业人才发现推荐(角色和归属由 Service 校验) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java index 3da68e2..eda89f0 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/controller/AuthController.java @@ -7,6 +7,12 @@ import com.mydreamjob.auth.LoginRequest; import com.mydreamjob.auth.SmsVerificationService; import com.mydreamjob.auth.dto.RefreshTokenRequest; import com.mydreamjob.auth.dto.TokenPairResponse; +import com.mydreamjob.auth.dto.PasswordChangeRequest; +import com.mydreamjob.auth.dto.PasswordLoginRequest; +import com.mydreamjob.auth.dto.PasswordOperationResponse; +import com.mydreamjob.auth.dto.PasswordResetRequest; +import com.mydreamjob.auth.dto.PasswordSetRequest; +import com.mydreamjob.auth.password.PasswordAuthenticationService; import com.mydreamjob.common.api.ApiResponse; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; @@ -43,6 +49,7 @@ public class AuthController { private final AuthService authService; private final SmsVerificationService smsVerificationService; private final CurrentUserAccessor currentUserAccessor; + private final PasswordAuthenticationService passwordAuthenticationService; @Operation(summary = "发送短信验证码", description = "开发/测试使用 Mock 发送实现;生产环境未接入短信网关时返回服务不可用。验证码不会写入日志。") @PostMapping("/sms/send") @@ -97,6 +104,85 @@ public class AuthController { } } + @Operation(summary = "手机号密码登录", description = "使用手机号和密码登录,返回 Access/Refresh Token;不自动创建账号") + @PostMapping("/password/login") + public ResponseEntity>> 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> 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> 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> resetPassword( + @Valid @RequestBody PasswordResetRequest body, HttpServletRequest request) { + try { + PasswordAuthenticationService.PasswordOperationResult result = passwordAuthenticationService.resetPassword( + body.getPhone(), body.getSmsCode(), body.getNewPassword(), requestIdAccessor.get(request), + request.getRemoteAddr(), request.getHeader("User-Agent")); + return ResponseEntity.ok(ApiResponse.ok( + new PasswordOperationResponse(result.passwordConfigured(), result.reauthenticationRequired()), + requestIdAccessor.get(request))); + } catch (BusinessException e) { + if (e.getErrorCodeInt() == ErrorCode.SERVICE_UNAVAILABLE.getCode()) { + return ResponseEntity.status(503).body(ApiResponse.error(503, + "authentication service temporarily unavailable", requestIdAccessor.get(request))); + } + throw e; + } + } + @Operation(summary = "刷新 Token", description = "使用 Refresh Token 换取新的 Access/Refresh Token 对;旧 Refresh Token 立即失效") @PostMapping("/refresh") diff --git a/mydreamjob-server/src/main/resources/application.yml b/mydreamjob-server/src/main/resources/application.yml index 09211e7..8b69fea 100644 --- a/mydreamjob-server/src/main/resources/application.yml +++ b/mydreamjob-server/src/main/resources/application.yml @@ -126,6 +126,17 @@ app: max-attempts: 5 lockout-seconds: ${SMS_LOCKOUT_SECONDS:300} + auth: + password: + enabled: ${PASSWORD_LOGIN_ENABLED:false} + rate-key-secret: ${PASSWORD_RATE_KEY_SECRET:} + max-failures: ${PASSWORD_MAX_FAILURES:5} + lockout-seconds: ${PASSWORD_LOCKOUT_SECONDS:900} + ip-window-seconds: ${PASSWORD_IP_WINDOW_SECONDS:600} + ip-max-attempts: ${PASSWORD_IP_MAX_ATTEMPTS:30} + operation-window-seconds: ${PASSWORD_OPERATION_WINDOW_SECONDS:600} + operation-max-attempts: ${PASSWORD_OPERATION_MAX_ATTEMPTS:10} + offer: scheduler: enabled: ${OFFER_SCHEDULER_ENABLED:true} diff --git a/mydreamjob-server/src/main/resources/db/migration/V46__password_authentication.sql b/mydreamjob-server/src/main/resources/db/migration/V46__password_authentication.sql new file mode 100644 index 0000000..6c84c83 --- /dev/null +++ b/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; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java index d6e2518..4684593 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java @@ -436,8 +436,8 @@ class CompanyResumeDownloadIntegrationTest { } } } - assertThat(pathCount).as("S03 基线应为 176 paths").isEqualTo(176); - assertThat(opCount).as("S03 基线应为 214 operations").isEqualTo(214); + assertThat(pathCount).as("密码登录新增四个路径后应为 180 paths").isEqualTo(180); + assertThat(opCount).as("密码登录新增四个 operation 后应为 218 operations").isEqualTo(218); var download = paths.path("/company/candidates/{applicationId}/resume").path("get"); assertThat(download.isMissingNode()).as("必须存在企业简历下载 GET").isFalse(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/PasswordAuthenticationConcurrencyMySqlTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/PasswordAuthenticationConcurrencyMySqlTest.java new file mode 100644 index 0000000..d844d66 --- /dev/null +++ b/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 redisTemplate; + + @Autowired private UserRepository userRepository; + @Autowired private AuthTokenRepository authTokenRepository; + @Autowired private PasswordEncoder passwordEncoder; + @Autowired private PasswordAuthenticationService passwordAuthenticationService; + @Autowired private AuthSessionService authSessionService; + + private final List 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 leftResult = pool.submit(() -> invoke(left, ready, start)); + Future 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) {} +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/PasswordAuthenticationDisabledIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/PasswordAuthenticationDisabledIntegrationTest.java new file mode 100644 index 0000000..4cac722 --- /dev/null +++ b/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 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/PasswordAuthenticationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/PasswordAuthenticationIntegrationTest.java new file mode 100644 index 0000000..4aa95da --- /dev/null +++ b/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 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(); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/password/PasswordPolicyTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/password/PasswordPolicyTest.java new file mode 100644 index 0000000..7b179cd --- /dev/null +++ b/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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/password/PasswordRateLimiterRedisIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/password/PasswordRateLimiterRedisIntegrationTest.java new file mode 100644 index 0000000..6020af9 --- /dev/null +++ b/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 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> 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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/auth/password/PasswordRateLimiterTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/auth/password/PasswordRateLimiterTest.java new file mode 100644 index 0000000..9d9e059 --- /dev/null +++ b/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 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 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 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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java index 27112ca..cb0a054 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/exception/GlobalExceptionHandlerStatusContractTest.java @@ -67,7 +67,10 @@ class GlobalExceptionHandlerStatusContractTest { "RISK_RULE_CONCURRENT_UPDATE", "RISK_EVENT_CONCURRENT_UPDATE", "RISK_INVALID_ACTION"); private static final Set 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 UNAUTHORIZED = Set.of("CREDENTIALS_INVALID"); private final GlobalExceptionHandler handler = new GlobalExceptionHandler(new RequestIdAccessor(), new ErrorHttpStatusRegistry()); @@ -101,6 +104,8 @@ class GlobalExceptionHandlerStatusContractTest { if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND; if (CONFLICT.contains(name)) return HttpStatus.CONFLICT; if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS; + if ("PASSWORD_STATE_CONFLICT".equals(name)) return HttpStatus.CONFLICT; + if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED; return HttpStatus.BAD_REQUEST; } } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java index 7409157..c4d6535 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/ErrorHttpStatusRegistryTest.java @@ -25,17 +25,18 @@ class ErrorHttpStatusRegistryTest { private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); - /** 允许出现在显式映射里的状态集合:重构前后都必须只有这四个。 */ + /** 允许出现在显式映射里的状态集合;密码登录新增 401/400 契约。 */ private static final Set ALLOWED_EXPLICIT_STATUSES = Set.of( - HttpStatus.FORBIDDEN, HttpStatus.NOT_FOUND, HttpStatus.CONFLICT, HttpStatus.TOO_MANY_REQUESTS); + HttpStatus.FORBIDDEN, HttpStatus.NOT_FOUND, HttpStatus.CONFLICT, + HttpStatus.TOO_MANY_REQUESTS, HttpStatus.UNAUTHORIZED, HttpStatus.BAD_REQUEST); @Test - @DisplayName("B2:显式映射数量与重构前快照一致(112 个 ErrorCode 常量)") + @DisplayName("密码登录:显式映射包含新增认证状态") void explicitMappingCountMatchesBaseline() { - assertThat(registry.explicitMappingCount()).isEqualTo(112); - // 112 个常量只覆盖 111 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与 + assertThat(registry.explicitMappingCount()).isEqualTo(115); + // 116 个常量只覆盖 115 个唯一数字码:9103 被 REVIEW_ALREADY_DECIDED 与 // DUPLICATE_FEEDBACK 共用(既有事实,未修改 ErrorCode 数字)。 - assertThat(registry.explicitNumericCodeCount()).isEqualTo(111); + assertThat(registry.explicitNumericCodeCount()).isEqualTo(114); } @Test @@ -120,12 +121,12 @@ class ErrorHttpStatusRegistryTest { .isInstanceOf(UnsupportedOperationException.class); assertThatThrownBy(() -> view.remove(ErrorCode.FORBIDDEN)) .isInstanceOf(UnsupportedOperationException.class); - assertThat(view).hasSize(112); + assertThat(view).hasSize(115); Map numeric = ErrorHttpStatusRegistry.numericMappingsView(); assertThatThrownBy(() -> numeric.put(999999, HttpStatus.OK)) .isInstanceOf(UnsupportedOperationException.class); - assertThat(numeric).hasSize(111); + assertThat(numeric).hasSize(114); } @Test diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java index a92f1b9..b8f1abc 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/GlobalExceptionHandlerMappingParityTest.java @@ -80,7 +80,12 @@ class GlobalExceptionHandlerMappingParityTest { "VERIFICATION_ALREADY_APPROVED", "VERIFICATION_ALREADY_PENDING"); private static final Set 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 CONFLICT_ADDITIONS = Set.of("PASSWORD_STATE_CONFLICT"); + + private static final Set UNAUTHORIZED = Set.of("CREDENTIALS_INVALID"); + private static final Set PASSWORD_BAD_REQUEST = Set.of("PASSWORD_POLICY_INVALID"); private final ErrorHttpStatusRegistry registry = new ErrorHttpStatusRegistry(); private final GlobalExceptionHandler handler = @@ -91,15 +96,18 @@ class GlobalExceptionHandlerMappingParityTest { if (FORBIDDEN.contains(name)) return HttpStatus.FORBIDDEN; if (NOT_FOUND.contains(name)) return HttpStatus.NOT_FOUND; if (CONFLICT.contains(name)) return HttpStatus.CONFLICT; + if (CONFLICT_ADDITIONS.contains(name)) return HttpStatus.CONFLICT; if (TOO_MANY_REQUESTS.contains(name)) return HttpStatus.TOO_MANY_REQUESTS; + if (UNAUTHORIZED.contains(name)) return HttpStatus.UNAUTHORIZED; + if (PASSWORD_BAD_REQUEST.contains(name)) return HttpStatus.BAD_REQUEST; return HttpStatus.BAD_REQUEST; } @Test @DisplayName("B2:显式映射集合与重构前快照逐项相等") void explicitMappingSetEqualsPreRefactorSnapshot() { - assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(112); - assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(111); + assertThat(ErrorHttpStatusRegistry.explicitMappingsView()).hasSize(115); + assertThat(ErrorHttpStatusRegistry.numericMappingsView()).hasSize(114); for (ErrorCode errorCode : ErrorCode.values()) { HttpStatus expected = baselineStatus(errorCode); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java index cf19e9e..35affe3 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/common/r01b/R01BIndependentHttpMappingParityTest.java @@ -66,6 +66,7 @@ class R01BIndependentHttpMappingParityTest { case "NOT_FOUND" -> HttpStatus.NOT_FOUND; case "CONFLICT" -> HttpStatus.CONFLICT; case "TOO_MANY_REQUESTS" -> HttpStatus.TOO_MANY_REQUESTS; + case "UNAUTHORIZED" -> HttpStatus.UNAUTHORIZED; default -> HttpStatus.BAD_REQUEST; }; } @@ -77,10 +78,10 @@ class R01BIndependentHttpMappingParityTest { void everyEnumConstantMatchesHeadBaselineByName() { Map base = baseline(); - // 166 条,且 9103 的两个常量各自成行 —— 不能按数字去重后只剩 165 条 - assertThat(base).hasSize(166); + // 169 条,且 9103 的两个常量各自成行 —— 不能按数字去重后只剩 168 条 + assertThat(base).hasSize(169); assertThat(base).containsKeys("REVIEW_ALREADY_DECIDED", "DUPLICATE_FEEDBACK"); - assertThat(ErrorCode.values()).hasSize(166); + assertThat(ErrorCode.values()).hasSize(169); int checked = 0; for (ErrorCode code : ErrorCode.values()) { @@ -92,28 +93,29 @@ class R01BIndependentHttpMappingParityTest { .isEqualTo(expected[1]); checked++; } - assertThat(checked).isEqualTo(166); + assertThat(checked).isEqualTo(169); } @Test - @DisplayName("独立复验:112 个显式映射 + 54 个默认 400") + @DisplayName("密码登录:116 个显式映射 + 53 个默认 400") void explicitAndDefaultCountsMatchBaseline() { Map base = baseline(); long explicitInBaseline = base.values().stream().filter(v -> v[1] != 400).count(); - assertThat(explicitInBaseline).isEqualTo(112); + assertThat(explicitInBaseline).isEqualTo(115); assertThat(base.size() - explicitInBaseline).isEqualTo(54); - assertThat(registry.explicitMappingCount()).isEqualTo(112); + assertThat(registry.explicitMappingCount()).isEqualTo(115); assertThat(ErrorCode.values().length - registry.explicitMappingCount()).isEqualTo(54); - // 112 个常量仅覆盖 111 个唯一数字码(9103 共用) - assertThat(registry.explicitNumericCodeCount()).isEqualTo(111); + // 115 个常量仅覆盖 114 个唯一数字码(9103 共用) + assertThat(registry.explicitNumericCodeCount()).isEqualTo(114); } @Test @DisplayName("独立复验:关键既有语义逐项保持") void keySemanticsPreserved() { assertThat(registry.resolve(ErrorCode.UNAUTHORIZED)).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(registry.resolve(ErrorCode.CREDENTIALS_INVALID)).isEqualTo(HttpStatus.UNAUTHORIZED); assertThat(registry.resolve(ErrorCode.FORBIDDEN)).isEqualTo(HttpStatus.FORBIDDEN); assertThat(registry.resolve(ErrorCode.SERVICE_UNAVAILABLE)).isEqualTo(HttpStatus.BAD_REQUEST); assertThat(registry.resolve(ErrorCode.RATE_LIMITED)).isEqualTo(HttpStatus.BAD_REQUEST); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java index 18a984c..e71de14 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/privacy/PrivacySettingsControllerTest.java @@ -311,7 +311,9 @@ class PrivacySettingsControllerTest { assertThat(put.path("requestBody").toString().contains("PrivacySettingsUpdateRequest")).isTrue(); // 无敏感字段 - String schemas = root.path("components").path("schemas").toString().toLowerCase(); - assertFalse(schemas.contains("password")); + String responseSchema = get.path("responses").path("200").toString().toLowerCase(); + assertThat(responseSchema.contains("\"password\"")).isFalse(); + assertThat(responseSchema.contains("\"passwordhash\"")).isFalse(); + assertThat(responseSchema.contains("\"token\"")).isFalse(); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java index f568787..e24604a 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileControllerTest.java @@ -258,7 +258,9 @@ class ProfileControllerTest { assertThat(get.path("responses").has("403")).isTrue(); // 无敏感字段(DTO schema 不含 password 等) - String schemas = root.path("components").path("schemas").toString().toLowerCase(); - assertFalse(schemas.contains("password")); + String responseSchema = get.path("responses").path("200").toString().toLowerCase(); + assertThat(responseSchema.contains("\"password\"")).isFalse(); + assertThat(responseSchema.contains("\"passwordhash\"")).isFalse(); + assertThat(responseSchema.contains("\"token\"")).isFalse(); } -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv b/mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv index 7c6ad79..5e418ef 100644 --- a/mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv +++ b/mydreamjob-server/src/test/resources/r01b/error-code-http-status-baseline.tsv @@ -8,7 +8,10 @@ UNAUTHORIZED 2001 400 FORBIDDEN 2002 FORBIDDEN TOKEN_EXPIRED 2003 400 TOKEN_INVALID 2004 400 -CREDENTIALS_INVALID 2005 400 +CREDENTIALS_INVALID 2005 UNAUTHORIZED +PASSWORD_RATE_LIMITED 2006 TOO_MANY_REQUESTS +PASSWORD_STATE_CONFLICT 2007 CONFLICT +PASSWORD_POLICY_INVALID 2008 400 SMS_CODE_SENT 3001 400 SMS_CODE_EXPIRED 3002 400 SMS_CODE_INVALID 3003 400