--- req_id: REQ-USR-004 date: 2026-05-06 module: module_usr --- # Spec: REQ-USR-004 — 用户登录 ## 目标 实现后端 `POST /api/auth/login` 接口:用户凭用户名 + 密码(+ 版本)完成身份认证,签发限时 JWT access token + 返回用户基本信息;连续失败时临时锁定账号;登录成功回写 `tLastLoginDate`。 ## 范围说明 本 REQ 是 module_usr 的最后一个 REQ,落地 JWT 签发 + 内存锁定计数 + tLastLoginDate 回写。**契约层面**,docs/02 § 三 与既有各 REQ Controller 注释提到「REQ-USR-004 完成后追加 @PreAuthorize + 切换 SecurityConfig 到 authenticated」。**本 REQ 仅签发 token**,SecurityConfig 保持 permitAll;将所有既有端点切换到强制鉴权 + 给每个 Controller 方法加 `@PreAuthorize` + 让现有 IT 携带 token 是另一个体量较大的清算工作(module_mod 4 端点 + module_usr 3 端点 + 全部 IT),作为已知技术债登记到模块完成报告 § ⑩,留给下一个 REQ 或独立 sweep 处理。 > 锁定失败计数:spec 写明用**内存** `ConcurrentHashMap` 保管;docs/03 § tUser 业务注记声明走 Redis,但本仓库 .env.local 未配 Redis 凭据、pom 也未引 spring-boot-starter-data-redis。本期保留内存实现并在 javadoc / 业务注记中注明"REQ-USR-XXX 引入 Redis 后替换"。 ## 输入 / 触发 **接口**:`POST /api/auth/login`,Content-Type `application/json`。**不需鉴权**(white-list in SecurityConfig)。 **Request body**(`LoginDTO`): | 字段 | 类型 | 必填 | 校验 | |---|---|---|---| | `sUserName` | String | 是 | 长度 1-50 | | `sPassword` | String | 是 | 长度 1-100(明文 HTTPS 传输;线下 prod 必须 HTTPS) | | `sVersion` | String | 是 | 枚举:`standard`(仅一个值;REQ 卡片表 1 写"标准版",spec 用 ASCII `standard` 作 SQL/code 友好) | ## 输出 / 结果 **HTTP 200,响应体**(成功): ```json { "code": 200, "message": "操作成功", "data": { "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTc0NjUyODYwMCwiZXhwIjoxNzQ2NTM1ODAwfQ...", "expiresIn": 7200, "user": { "iIncrement": 12, "sUserNo": "u001", "sUserName": "alice", "sUserType": "普通用户", "sLanguage": "zh" } }, "timestamp": 1746528600000 } ``` 新建 VO `LoginResultVO`:字段 `accessToken` / `expiresIn`(秒)/ `user`(嵌套 `LoginUserInfo`:iIncrement / sUserNo / sUserName / sUserType / sLanguage 5 字段)。 **不返回**:`sPasswordHash` / `iStaffId` / `bCanModifyDocs` / `tLastLoginDate` / 多租户字段 / 软删除三件套。`refreshToken` 暂不签发(spec § 范围说明,留作后续)。 ## 业务规则 1. **JWT 签发**:HS256,secret 来自 `.env.local` `JWT_SECRET`(已配置);`expiresIn = 7200`(2 小时);claims:`sub = sUserName`、`iat`、`exp`、`uid = iIncrement`、`type = sUserType`。 2. **密码校验**:`BCryptPasswordEncoder.matches(sPassword, user.sPasswordHash)`。复用 REQ-USR-001 引入的 PasswordConfig bean。 3. **目标用户存在 + 未软删**:`SELECT iIncrement, sUserName, sPasswordHash, sUserType, sLanguage, sUserNo, bDeleted FROM tUser WHERE sUserName = ? AND bDeleted = 0`。null → 失败计数 + `40101`(用户名或密码错误,不区分原因避免账号枚举);`bDeleted=1` 同样 40101。 4. **失败计数 + 锁定**:内存 `Map`(key=sUserName)。`FailRecord{count, firstFailAt, lockUntil}`。 - 每次密码错误 → `count++`,记 `firstFailAt`(首次失败时间,每个统计窗口)。 - `count >= 5` 且未锁定 → 设 `lockUntil = now + 15min`。 - 已锁定(`now < lockUntil`)→ 返回 `40301`(账号锁定)+ `data.cooldownSeconds = (lockUntil - now)`。 - 锁定到期(`now >= lockUntil`)→ reset `count=0`,正常进入校验流程。 - 登录成功 → `cache.remove(sUserName)`(清失败计数)。 5. **`sVersion` 校验**:当前仅支持 `standard`(DTO @Pattern + service 层防御)。其他值返回 `40010`。 6. **`tLastLoginDate` 回写**:成功签发 token 后用 `LambdaUpdateWrapper.set(tLastLoginDate, now)` 显式更新,避免 entity-driven update 触发 iStaffId.IGNORED 副作用。 7. **审计 / 日志**:登录失败 / 锁定 / 成功均 `log.info` 一条(含 sUserName + 客户端 IP,IP 由 controller 通过 `HttpServletRequest.getRemoteAddr()` 获取并传给 service;REQ 卡片 § 验收提到"防暴力破解"以日志为最低抑制)。 8. **响应格式**:成功 / 失败统一 `{code, message, data, timestamp}`;data 在失败时为 null(除 40301 外,含 `cooldownSeconds`)。 ## 边界与约束 ### 鉴权策略 - **本接口** `/api/auth/login`:white-list,permitAll(任何人可调)。 - **其他既有接口**:本 REQ 不切换为 authenticated,仍保留 permitAll。技术债登记在模块完成报告 § ⑩。 ### 错误码映射 | 场景 | 错误码 | ErrorCode 枚举 | |---|---|---| | 必填缺失 / 长度超限 / sVersion 非枚举 | 40010 | `PARAM_INVALID`(已存在) | | 用户名不存在 / 密码错误 / 用户已软删 | 40101 | `LOGIN_INVALID_CREDENTIALS`(**新增**) | | 账号被临时锁定 | 40301 | `LOGIN_ACCOUNT_LOCKED`(**新增**) | | 服务端兜底 | 50000 | `INTERNAL_ERROR` | ### Redis 缺位的妥协 `InMemoryLoginAttemptStore`(service 层 bean)实现 `LoginAttemptStore` 接口,提供 `recordFailure / clear / isLocked / cooldownSeconds`。后续 REQ 引入 Redis 时新增 `RedisLoginAttemptStore` impl,替换 bean,service 不变。 ### JWT 库选择 引入 `io.jsonwebtoken:jjwt-api / jjwt-impl / jjwt-jackson` 0.12.x(最新稳定)。这属 docs/04 § 零 已列「权限认证 Spring Security / JWT」的具体实现,不触发软规则 S1。 ### SecurityConfig 改动 `SecurityConfig` 在白名单中显式加 `requestMatchers("/api/auth/login").permitAll()`;其他 `anyRequest().permitAll()` 维持原状(与 module_mod / module_usr 已有端点兼容)。 ### 防暴力破解 仅做基础锁定(5 次错误 → 15 分钟锁定)。docs/04 § 1.6 提到 token 生命周期 / 刷新机制 / 密钥管理;本期完成 expires-in、未实施 refreshToken 流转,记入已知 gap。 ## 依赖的 schema 表 / 字段 **读表**:`tUser`(按 sUserName 查 + 校验密码) **写表**:`tUser`(仅写 `tLastLoginDate`;其他字段不动) | 字段 | 用途 | |---|---| | `sUserName` | 主索引(uk_user_name) | | `sPasswordHash` | BCrypt 校验 | | `bDeleted` | 过滤 | | `iIncrement` / `sUserNo` / `sUserType` / `sLanguage` | LoginUserInfo / JWT claims | | `tLastLoginDate` | 成功登录回写 | **索引利用**:`uk_user_name`(UNIQUE,单字段查询 O(1))。 **外键 / 关联表**:本接口不用。 ## 依赖的接口 无(独立鉴权入口)。 ## 验收标准 ### 功能正确性 1. **正向 — 凭据正确**:先用 REQ-USR-001 创建用户(默认密码 666666),POST /api/auth/login,返回 200 + accessToken(非空)+ expiresIn=7200 + user 含 5 字段;DB 中 tLastLoginDate 已写入。 2. **JWT 解析**:解析 accessToken(用 JWT_SECRET),断言 claims 含 `sub == sUserName`、`uid == iIncrement`、`type == sUserType`、`exp - iat == 7200`。 3. **凭据错误 — 用户名不存在**:返回 40101。 4. **凭据错误 — 密码错误**:返回 40101;DB tLastLoginDate 不更新;内存计数 +1。 5. **用户已软删**:先创建用户后置 bDeleted=1,登录返回 40101(不区分原因防账号枚举)。 6. **必填缺失 / sVersion 非枚举**:返回 40010。 7. **5 次密码错误后锁定**:连续 5 次错误密码登录同一用户名,第 5 次(含)开始返回 40301 + `data.cooldownSeconds > 0`;正确密码也返回 40301(锁定期内一律锁定)。 8. **锁定后正确密码仍 40301**:锁定状态下传正确密码也拒绝;登录失败和成功都不重置锁定到期时间。 9. **锁定到期后可登录**:直接修改内存 store 的 lockUntil 至过去,再用正确密码登录,返回 200。 10. **响应不含 sPasswordHash**:jsonPath 验证 `$.data.user.sPasswordHash` doesNotExist。 ### 接口契约一致性 - 响应格式 `{code, message, data, timestamp}`;data 嵌套 `{accessToken, expiresIn, user}`。 - 错误码 200 / 40010 / 40101 / 40301 / 50000。 - 不暴露 sPasswordHash;不回显堆栈。 ### 测试覆盖 - **单元测试** `LoginServiceImplTest`(mock UserMapper / PasswordEncoder / LoginAttemptStore / JwtTokenProvider): - login_validCredentials_returnsTokenAndClearsFailCount - login_userNotFound_returns40101_recordsFailure - login_userSoftDeleted_returns40101 - login_passwordMismatch_returns40101_recordsFailure - login_accountLocked_returns40301_withCooldown - login_5thFailureTriggersLock - login_successUpdatesTLastLoginDate - jwtTokenProvider_signAndParse_returnsClaims(独立 JwtTokenProvider 单元测试) - **集成测试** `LoginControllerIT`(@SpringBootTest @Transactional + 真实 PasswordEncoder + 真实 InMemoryLoginAttemptStore,用 @BeforeEach 重置 store): - login_validCredentials_returns200WithToken - login_jwtClaimsAreCorrect - login_invalidUsername_returns40101 - login_wrongPassword_returns40101 - login_softDeletedUser_returns40101 - login_missingPassword_returns40010 - login_invalidVersion_returns40010 - login_5thFailureLocks_returns40301 - login_responseExcludesSPasswordHash ### 代码与文档 - `// REQ-USR-004` 注释贴在 LoginController / LoginService / JwtTokenProvider / LoginAttemptStore / 新增 ErrorCode。 - 提交按 `feat(usr): REQ-USR-004` 规范。 - pom.xml 新增 jjwt 三件套(api/impl/jackson 0.12.x)。