--- req_id: REQ-USR-004 date: 2026-05-08 module: module_usr --- # Spec: REQ-USR-004 — 用户登录 ## 目标 用户在登录页选择所属公司(brand)、输入用户名和密码,完成身份认证后获取 JWT Access Token 及 Refresh Token,用于后续所有接口鉴权。多租户隔离:每次登录的用户查询限定在所选 brand 的 sBrandsId 范围内。 ## 输入 / 触发 **POST /api/auth/login**(公开接口,无需 Bearer Token) ```json { "brandNo": "string", // brand.sNo,前端版本下拉选中项 "username": "string", // usr_user.sUsername "password": "string" // 明文密码(HTTPS 传输),后端用 BCrypt 校验 } ``` **辅助:版本下拉数据** 前端需预先调用 `GET /api/auth/brands` 获取 brand 列表(sNo / sName),填充版本下拉框。默认选中 `sName = "标准版"` 对应的项(若无则选第一项)。此接口亦无需鉴权。 **POST /api/auth/refresh**(无需 Bearer Token,需 Refresh Token) ```json { "refreshToken": "string" } ``` ## 输出 / 结果 **登录成功(HTTP 200)** ```json { "code": 200, "message": "登录成功", "data": { "accessToken": "", "refreshToken": "", "expiresIn": 86400, "userInfo": { "userId": "string", // usr_user.sId "username": "string", // usr_user.sUsername "userType": "string", // 普通用户 | 超级管理员 "language": "string", // 中文 | 英文 | 繁体 "brandId": "string" // brand.sId,写入 token claims 用于后续多租户隔离 } } } ``` **失败(业务错误,HTTP 200 + code ≠ 0)** | code | message | 场景 | |---|---|---| | 40100 | 用户名或密码错误 | 密码错误 / 用户不存在 / brand 不存在(统一文案,防枚举) | | 40101 | 账号已被禁用,请联系管理员 | bIsDisabled = 1 | | 40102 | 账号已被锁定,请 {N} 分钟后重试 | tLockUntil > NOW() | | 40103 | Refresh Token 已失效,请重新登录 | refresh 接口 token 过期或篡改 | ## 业务规则 1. **多租户查询**:先 `SELECT * FROM brand WHERE sNo = ?` 得 `brandId = brand.sId`;再 `SELECT * FROM usr_user WHERE sUsername = ? AND sBrandsId = ?`(brandId)。brand 不存在或用户不存在统一返回 40100(防枚举)。 2. **密码校验**:`BCryptPasswordEncoder.matches(plainPassword, usr_user.sPasswordHash)`。 3. **禁用检查**:`bIsDisabled = 1` → 返回 40101,不更新失败计数。 4. **锁定检查**:`tLockUntil IS NOT NULL AND tLockUntil > NOW()` → 返回 40102,剩余分钟数 = `CEIL((tLockUntil - NOW()) / 60)`。 5. **失败计数**:密码错误时 `iLoginFailCount += 1`;达到 5 次时设 `tLockUntil = NOW() + 30 分钟`,同时返回 40102。 6. **登录成功**: - 重置 `iLoginFailCount = 0`,`tLockUntil = NULL` - 更新 `tLastLoginDate = NOW()` - 签发 Access Token(24h)+ Refresh Token(7d),均为 JWT,签名密钥来自 `JWT_SECRET`(从 `.env.local` 注入,`application.yml` 中 `${JWT_SECRET}`) 7. **JWT Claims**: - Access Token:`sub = usr_user.sId`,`username`,`userType`,`brandId = brand.sId`,`exp = now + 24h` - Refresh Token:`sub = usr_user.sId`,`brandId`,`type = refresh`,`exp = now + 7d` 8. **Token 刷新**(POST /api/auth/refresh):校验 Refresh Token 签名与 `type=refresh`;从 claims 读 `sub`(userId)和 `brandId` 重新查库确认用户仍有效;签发新 Access Token(24h);Refresh Token **不续期**(滑动窗口留给后续迭代)。 9. **版本下拉**(GET /api/auth/brands):`SELECT sNo, sName FROM brand ORDER BY sName`;无需分页(品牌数量通常极少)。 ## 边界与约束 - 所有接口均走 HTTPS(Nginx 层保障,后端不强制) - 登录日志(IP + 时间戳)通过 Spring Security 的 `AuthenticationSuccessHandler` / `AuthenticationFailureHandler` 写 Logback,不写业务表 - Refresh Token 无服务端存储(无状态 JWT);强制退出由后续迭代(引入 token 黑名单)实现;本期用 7 天过期控制 - 密码明文绝不落库,`sPasswordHash` 仅存 BCrypt 60 字符哈希 - Access Token 不含密码 hash,不含任何敏感字段 - 防暴力破解:5 次失败锁 30 分钟(数据库层,不依赖内存,重启后有效) - `GET /api/auth/brands` 结果可加短缓存(30s),减少登录页加载压力 ## 依赖的 schema 表 / 字段 **usr_user**(主要读写字段) | 字段 | 操作 | 说明 | |---|---|---| | `sUsername` | READ | 登录名匹配 | | `sBrandsId` | READ | 多租户范围限定 | | `sPasswordHash` | READ | BCrypt 校验 | | `bIsDisabled` | READ | 禁用检查 | | `tLockUntil` | READ / WRITE | 锁定截止时间 | | `iLoginFailCount` | READ / WRITE | 连续失败次数 | | `tLastLoginDate` | WRITE | 成功后更新 | | `sId` | READ | 写入 JWT sub | | `sUserType` | READ | 写入 JWT claims | | `sLanguage` | READ | 返回 userInfo | **brand** | 字段 | 操作 | 说明 | |---|---|---| | `sNo` | READ | 版本下拉匹配键 | | `sId` | READ | 写入 JWT brandId claim | | `sName` | READ | 版本下拉显示名 | ## 依赖的接口 - 自身即认证入口,无上游接口依赖 - 对外暴露:`POST /api/auth/login`、`POST /api/auth/refresh`、`GET /api/auth/brands`(均无鉴权) ## 验收标准 1. 正确 brandNo + username + password → HTTP 200,返回有效 accessToken,`POST /api/usr/users` 携带该 token 鉴权通过 2. 错误密码 → code=40100,且不暴露是用户名还是密码错了 3. 连续 5 次错误密码 → 第 5 次返回 code=40102(含剩余分钟数),后续在锁定期内再请求仍返回 40102 4. 锁定 30 分钟后自动解锁,正确凭据可再次登录 5. `bIsDisabled = 1` 的账号登录 → code=40101 6. brand.sNo 不存在 → code=40100(与密码错误同一文案) 7. 用同一账号、不同 brandId 登录 → 互不干扰(多租户隔离) 8. 有效 refreshToken → POST /api/auth/refresh 返回新 accessToken 9. 过期 / 伪造 refreshToken → code=40103 10. GET /api/auth/brands 返回所有 brand 列表(sNo + sName)