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,响应体(成功):
{
"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 § 范围说明,留作后续)。
业务规则
-
JWT 签发:HS256,secret 来自
.env.localJWT_SECRET(已配置);expiresIn = 7200(2 小时);claims:sub = sUserName、iat、exp、uid = iIncrement、type = sUserType。 -
密码校验:
BCryptPasswordEncoder.matches(sPassword, user.sPasswordHash)。复用 REQ-USR-001 引入的 PasswordConfig bean。 -
目标用户存在 + 未软删:
SELECT iIncrement, sUserName, sPasswordHash, sUserType, sLanguage, sUserNo, bDeleted FROM tUser WHERE sUserName = ? AND bDeleted = 0。null → 失败计数 +40101(用户名或密码错误,不区分原因避免账号枚举);bDeleted=1同样 40101。 -
失败计数 + 锁定:内存
Map<String, FailRecord>(key=sUserName)。FailRecord{count, firstFailAt, lockUntil}。- 每次密码错误 →
count++,记firstFailAt(首次失败时间,每个统计窗口)。 -
count >= 5且未锁定 → 设lockUntil = now + 15min。 - 已锁定(
now < lockUntil)→ 返回40301(账号锁定)+data.cooldownSeconds = (lockUntil - now)。 - 锁定到期(
now >= lockUntil)→ resetcount=0,正常进入校验流程。 - 登录成功 →
cache.remove(sUserName)(清失败计数)。
- 每次密码错误 →
-
sVersion校验:当前仅支持standard(DTO @Pattern + service 层防御)。其他值返回40010。 -
tLastLoginDate回写:成功签发 token 后用LambdaUpdateWrapper.set(tLastLoginDate, now)显式更新,避免 entity-driven update 触发 iStaffId.IGNORED 副作用。 -
审计 / 日志:登录失败 / 锁定 / 成功均
log.info一条(含 sUserName + 客户端 IP,IP 由 controller 通过HttpServletRequest.getRemoteAddr()获取并传给 service;REQ 卡片 § 验收提到"防暴力破解"以日志为最低抑制)。 -
响应格式:成功 / 失败统一
{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))。
外键 / 关联表:本接口不用。
依赖的接口
无(独立鉴权入口)。
验收标准
功能正确性
- 正向 — 凭据正确:先用 REQ-USR-001 创建用户(默认密码 666666),POST /api/auth/login,返回 200 + accessToken(非空)+ expiresIn=7200 + user 含 5 字段;DB 中 tLastLoginDate 已写入。
-
JWT 解析:解析 accessToken(用 JWT_SECRET),断言 claims 含
sub == sUserName、uid == iIncrement、type == sUserType、exp - iat == 7200。 - 凭据错误 — 用户名不存在:返回 40101。
- 凭据错误 — 密码错误:返回 40101;DB tLastLoginDate 不更新;内存计数 +1。
- 用户已软删:先创建用户后置 bDeleted=1,登录返回 40101(不区分原因防账号枚举)。
- 必填缺失 / sVersion 非枚举:返回 40010。
-
5 次密码错误后锁定:连续 5 次错误密码登录同一用户名,第 5 次(含)开始返回 40301 +
data.cooldownSeconds > 0;正确密码也返回 40301(锁定期内一律锁定)。 - 锁定后正确密码仍 40301:锁定状态下传正确密码也拒绝;登录失败和成功都不重置锁定到期时间。
- 锁定到期后可登录:直接修改内存 store 的 lockUntil 至过去,再用正确密码登录,返回 200。
-
响应不含 sPasswordHash:jsonPath 验证
$.data.user.sPasswordHashdoesNotExist。
接口契约一致性
- 响应格式
{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): <subject> REQ-USR-004规范。 - pom.xml 新增 jjwt 三件套(api/impl/jackson 0.12.x)。