2026-05-06-REQ-USR-004.md 9.61 KB

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 bodyLoginDTO):

字段 类型 必填 校验
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 § 范围说明,留作后续)。

业务规则

  1. JWT 签发:HS256,secret 来自 .env.local JWT_SECRET(已配置);expiresIn = 7200(2 小时);claims:sub = sUserNameiatexpuid = iIncrementtype = 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<String, FailRecord>(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 == sUserNameuid == iIncrementtype == sUserTypeexp - 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): <subject> REQ-USR-004 规范。
  • pom.xml 新增 jjwt 三件套(api/impl/jackson 0.12.x)。