Commit 09cf72a4097579ef72d9e46e1f040edf41d6a103
1 parent
d439c0d9
docs(usr): review approval REQ-USR-004 round 2
Showing
4 changed files
with
462 additions
and
1 deletions
docs/08-模块任务管理.md
docs/superpowers/plans/2026-05-06-REQ-USR-004.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-004 | |
| 3 | +date: 2026-05-06 | |
| 4 | +spec_ref: docs/superpowers/specs/2026-05-06-REQ-USR-004.md | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# REQ-USR-004 用户登录 Implementation Plan | |
| 8 | + | |
| 9 | +> **Execution:** Parent skill `feature-tdd` executes this plan task-by-task. | |
| 10 | + | |
| 11 | +**Goal:** 实现 `POST /api/auth/login`:BCrypt 校验密码 → 5 次失败锁定(内存)→ 签发 HS256 JWT → 回写 tLastLoginDate。 | |
| 12 | + | |
| 13 | +**Architecture:** 引入 jjwt 0.12.x 签发 JWT;`LoginAttemptStore` 接口 + `InMemoryLoginAttemptStore` 实现(spec 注:Redis 替换为后续 REQ);`JwtTokenProvider` 封装 sign/parse;`LoginService` 协调 4 步逻辑;`SecurityConfig` 白名单 `/api/auth/login`。本 REQ 不切换其他端点为 authenticated(技术债登记)。 | |
| 14 | + | |
| 15 | +**Tech Stack:** 沿用 + 新增 jjwt-api/impl/jackson 0.12.x。 | |
| 16 | + | |
| 17 | +--- | |
| 18 | + | |
| 19 | +## Schema 改动 | |
| 20 | + | |
| 21 | +无。 | |
| 22 | + | |
| 23 | +## 文件变更清单 | |
| 24 | + | |
| 25 | +- 修改: `backend/pom.xml` — 追加 jjwt 三件套 | |
| 26 | +- 修改: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` — 追加 LOGIN_INVALID_CREDENTIALS(40101) / LOGIN_ACCOUNT_LOCKED(40301) | |
| 27 | +- 修改: `backend/src/main/java/com/xly/erp/config/SecurityConfig.java` — 白名单 `/api/auth/login` | |
| 28 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java` | |
| 29 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java`(含嵌套 LoginUserInfo) | |
| 30 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java` — sign/parse 封装 | |
| 31 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java` — 接口 | |
| 32 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java` — 实现 | |
| 33 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java` | |
| 34 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` | |
| 35 | +- 创建: `backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java` | |
| 36 | +- 修改: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` — 追加 2 个错误码断言 | |
| 37 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java` | |
| 38 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java` | |
| 39 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` | |
| 40 | +- 创建: `backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java` | |
| 41 | + | |
| 42 | +--- | |
| 43 | + | |
| 44 | +## 任务步骤 | |
| 45 | + | |
| 46 | +### Task 1: pom.xml + ErrorCode + DTO/VO + DTO Validation | |
| 47 | + | |
| 48 | +**Files:** | |
| 49 | +- Modify: `backend/pom.xml`(追加 jjwt-api / jjwt-impl / jjwt-jackson 0.12.6 三个 dependency) | |
| 50 | +- Modify: `backend/src/main/java/com/xly/erp/common/response/ErrorCode.java` | |
| 51 | +- Modify: `backend/src/test/java/com/xly/erp/common/response/ApiResponseTest.java` | |
| 52 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/dto/LoginDTO.java` | |
| 53 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/vo/LoginResultVO.java` | |
| 54 | +- Create: `backend/src/test/java/com/xly/erp/module/usr/dto/LoginDTOValidationTest.java` | |
| 55 | + | |
| 56 | +**API shape:** | |
| 57 | +- `LOGIN_INVALID_CREDENTIALS(40101, "用户名或密码错误")` | |
| 58 | +- `LOGIN_ACCOUNT_LOCKED(40301, "账号已临时锁定")` | |
| 59 | +- `LoginDTO`:`@NotBlank @Size(max=50) String sUserName` / `@NotBlank @Size(max=100) String sPassword` / `@NotBlank @Pattern(regexp="^standard$") String sVersion` | |
| 60 | +- `LoginResultVO`:`String accessToken` / `long expiresIn` / `LoginUserInfo user`(内嵌静态类 5 字段:iIncrement / sUserNo / sUserName / sUserType / sLanguage) | |
| 61 | + | |
| 62 | +- [ ] **Step 1.1 写失败测试** | |
| 63 | + - ApiResponseTest 追加:`assertThat(ErrorCode.LOGIN_INVALID_CREDENTIALS.getCode()).isEqualTo(40101);` + LOGIN_ACCOUNT_LOCKED 40301。 | |
| 64 | + - LoginDTOValidationTest 4 个用例:allValid / blankRequired / invalidVersion / overSized。 | |
| 65 | + - 子会话: FAIL(class 不存在) | |
| 66 | + | |
| 67 | +- [ ] **Step 1.2 实现** | |
| 68 | + - 在 pom.xml `<dependencies>` 追加: | |
| 69 | + ``` | |
| 70 | + io.jsonwebtoken:jjwt-api:0.12.6 | |
| 71 | + io.jsonwebtoken:jjwt-impl:0.12.6 (runtime) | |
| 72 | + io.jsonwebtoken:jjwt-jackson:0.12.6 (runtime) | |
| 73 | + ``` | |
| 74 | + - ErrorCode + DTO + VO + Validation | |
| 75 | + - 子会话: PASS | |
| 76 | + | |
| 77 | +- [ ] **Step 1.3 提交** | |
| 78 | + - `git commit -m "feat(usr): jjwt deps + login DTO/VO + error codes REQ-USR-004"` | |
| 79 | + | |
| 80 | +--- | |
| 81 | + | |
| 82 | +### Task 2: JwtTokenProvider + 单元测试 | |
| 83 | + | |
| 84 | +**Files:** | |
| 85 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/security/JwtTokenProvider.java` | |
| 86 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/security/JwtTokenProviderTest.java` | |
| 87 | + | |
| 88 | +**API shape:** | |
| 89 | +- `JwtTokenProvider` Spring `@Component`,构造注入 `@Value("${erp.jwt.secret}") String secret` + `@Value("${erp.jwt.expires-in-seconds}") long expiresIn`。 | |
| 90 | +- `String sign(int uid, String username, String userType)`:HS256,claims `sub=username` / `uid` / `type=userType` / `iat` / `exp = iat + expiresIn`。 | |
| 91 | +- `Claims parse(String token)`:验证签名 + 过期,抛 `JwtException` / `ExpiredJwtException`。 | |
| 92 | + | |
| 93 | +> secret 不足 256 bit 的处理:jjwt 0.12 要求 SecretKey 至少 256 bit。`JWT_SECRET` (.env.local) 当前已是 256 bit hex。Provider 用 `Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8))`。如不足直接抛 `WeakKeyException`(启动期暴露)。 | |
| 94 | + | |
| 95 | +- [ ] **Step 2.1 写失败测试** | |
| 96 | + - `JwtTokenProviderTest#signAndParse_returnsClaims`:注入 fake secret + expiresIn=7200,sign(uid=1, username=alice, type=普通用户) → parse → 断言 sub=alice / uid=1 / type=普通用户 / exp-iat=7200。 | |
| 97 | + - `JwtTokenProviderTest#parseExpiredToken_throwsExpiredJwtException`:mock Clock 或手工构造已过期 token(用 jjwt builder 设 exp=now-1)。 | |
| 98 | + - 测试方式:直接 `new JwtTokenProvider(secret, expiresIn)` 实例化,避免 SpringBootTest 开销。 | |
| 99 | + - 子会话: FAIL | |
| 100 | + | |
| 101 | +- [ ] **Step 2.2 实现 JwtTokenProvider** | |
| 102 | + - 子会话: PASS | |
| 103 | + | |
| 104 | +- [ ] **Step 2.3 提交** | |
| 105 | + - `git commit -m "feat(usr): JwtTokenProvider sign/parse REQ-USR-004"` | |
| 106 | + | |
| 107 | +--- | |
| 108 | + | |
| 109 | +### Task 3: LoginAttemptStore 接口 + InMemory 实现 | |
| 110 | + | |
| 111 | +**Files:** | |
| 112 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/security/LoginAttemptStore.java` — 接口 | |
| 113 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java` — 实现 | |
| 114 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStoreTest.java` | |
| 115 | + | |
| 116 | +**API shape:** | |
| 117 | +``` | |
| 118 | +interface LoginAttemptStore { | |
| 119 | + /** 已锁定且未到期 → 返回 cooldownSeconds(>0);未锁定或已到期 → 返回 0 */ | |
| 120 | + long cooldownSeconds(String username); | |
| 121 | + | |
| 122 | + /** 记录一次失败:count++;count==5 触发 15min 锁定 */ | |
| 123 | + void recordFailure(String username); | |
| 124 | + | |
| 125 | + /** 登录成功清空记录 */ | |
| 126 | + void clear(String username); | |
| 127 | +} | |
| 128 | + | |
| 129 | +@Component | |
| 130 | +class InMemoryLoginAttemptStore implements LoginAttemptStore { | |
| 131 | + private static final int LOCK_THRESHOLD = 5; | |
| 132 | + private static final Duration LOCK_DURATION = Duration.ofMinutes(15); | |
| 133 | + private final Map<String, FailRecord> store = new ConcurrentHashMap<>(); | |
| 134 | + // FailRecord{int count; Instant lockUntil}; 同步 access via map.compute | |
| 135 | +} | |
| 136 | +``` | |
| 137 | + | |
| 138 | +- [ ] **Step 3.1 写失败测试(4 个)** | |
| 139 | + - `cooldown_initial_returnsZero` | |
| 140 | + - `recordFailure_under5_doesNotLock` | |
| 141 | + - `recordFailure_at5_triggersLock_cooldownPositive` | |
| 142 | + - `clear_resetsCount` | |
| 143 | + - (不直接测"15min 后解锁"——time-based 测试用 Duration.ZERO mock 不实际可行;spec § 6 验收"锁定到期后可登录"由 LoginServiceImplTest 模拟 lockUntil 至过去验证) | |
| 144 | + - 子会话: FAIL | |
| 145 | + | |
| 146 | +- [ ] **Step 3.2 实现接口 + InMemory** | |
| 147 | + - 子会话: PASS | |
| 148 | + | |
| 149 | +- [ ] **Step 3.3 提交** | |
| 150 | + - `git commit -m "feat(usr): in-memory login attempt store REQ-USR-004"` | |
| 151 | + | |
| 152 | +--- | |
| 153 | + | |
| 154 | +### Task 4: LoginService + Mockito 单元测试 | |
| 155 | + | |
| 156 | +**Files:** | |
| 157 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/LoginService.java` | |
| 158 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/service/impl/LoginServiceImpl.java` | |
| 159 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java` | |
| 160 | + | |
| 161 | +**API shape:** | |
| 162 | +- `LoginService.login(LoginDTO dto): LoginResultVO` | |
| 163 | +- 实现步骤(plan 锁定): | |
| 164 | + 1. 检查锁定:`long cd = attemptStore.cooldownSeconds(dto.sUserName)`。`cd > 0` → 抛 `BizException(LOGIN_ACCOUNT_LOCKED, message + cooldownSeconds)`;message 体内含 cooldownSeconds,由 controller 层把 BizException 转换时把 cd 放到 data.cooldownSeconds。 | |
| 165 | + - **简化方案**:直接抛 `BizException(LOGIN_ACCOUNT_LOCKED)` 不带 cooldown;controller 层 `catch BizException` 包装成 `ApiResponse.fail` 时丢失 cooldown。**改进方案**:自定义 `AccountLockedException extends BizException`,含 `long cooldownSeconds`,GlobalExceptionHandler 加专门 handler 把 cooldownSeconds 放进 `ApiResponse.data`。spec § 验收 #7 要求 `data.cooldownSeconds`,故选改进方案。 | |
| 166 | + 2. 查用户:`userMapper.selectOne(eq(sUserName).eq(bDeleted, false))`。null → `attemptStore.recordFailure(sUserName)` + 抛 `LOGIN_INVALID_CREDENTIALS`。 | |
| 167 | + 3. BCrypt 校验:`passwordEncoder.matches(dto.sPassword, user.sPasswordHash)`。false → `attemptStore.recordFailure(sUserName)` + 抛 `LOGIN_INVALID_CREDENTIALS`。 | |
| 168 | + 4. 成功:`attemptStore.clear(sUserName)`;签发 token;`userMapper.update(null, LambdaUpdateWrapper.eq(iIncrement,user.iIncrement).set(tLastLoginDate, now))`;构造 LoginResultVO 返回。 | |
| 169 | +- 标 `@Transactional(rollbackFor = Exception.class)`(成功路径有 update;失败也安全)。 | |
| 170 | + | |
| 171 | +**新建** `AccountLockedException`(在 module_usr/security 或 common/exception,本 plan 选 common/exception): | |
| 172 | +``` | |
| 173 | +class AccountLockedException extends BizException { | |
| 174 | + private final long cooldownSeconds; | |
| 175 | + public AccountLockedException(long cd) { | |
| 176 | + super(ErrorCode.LOGIN_ACCOUNT_LOCKED); | |
| 177 | + this.cooldownSeconds = cd; | |
| 178 | + } | |
| 179 | +} | |
| 180 | +``` | |
| 181 | + | |
| 182 | +GlobalExceptionHandler 追加 `@ExceptionHandler(AccountLockedException.class)` 把 cooldownSeconds 包进 `ApiResponse.data`,为此需要 `Map<String, Object>` data 形式:`Map.of("cooldownSeconds", e.getCooldownSeconds())`。 | |
| 183 | + | |
| 184 | +- [ ] **Step 4.1 写失败测试(7 个)** | |
| 185 | + - `login_validCredentials_returnsTokenAndClearsFailCount`:mock attemptStore.cooldownSeconds=0 / userMapper.selectOne 返回 user / passwordEncoder.matches=true / jwt.sign 返回固定 token / userMapper.update 返回 1。断言 VO.accessToken / verify attemptStore.clear / verify userMapper.update 被调(含 LambdaUpdateWrapper)。 | |
| 186 | + - `login_userNotFound_returns40101_recordsFailure` | |
| 187 | + - `login_userSoftDeleted_returns40101`:selectOne 已带 bDeleted=0 过滤,覆盖该路径等价于 userNotFound(返回 null);mock selectOne null 即可。 | |
| 188 | + - `login_passwordMismatch_returns40101_recordsFailure` | |
| 189 | + - `login_accountLocked_throwsAccountLockedException_withCooldown` | |
| 190 | + - `login_5thFailureTriggersLock`(mock attemptStore 检验 recordFailure 调用次数) | |
| 191 | + - `login_successUpdatesTLastLoginDate`:ArgumentCaptor 捕 LambdaUpdateWrapper(或 Wrapper),断言 sql set 含 tLastLoginDate。 | |
| 192 | + - 子会话: FAIL | |
| 193 | + | |
| 194 | +- [ ] **Step 4.2 实现 LoginService + Impl + AccountLockedException + GlobalExceptionHandler 扩展** | |
| 195 | + - 子会话: PASS | |
| 196 | + | |
| 197 | +- [ ] **Step 4.3 提交** | |
| 198 | + - `git commit -m "feat(usr): login service + account locked handling REQ-USR-004"` | |
| 199 | + | |
| 200 | +--- | |
| 201 | + | |
| 202 | +### Task 5: LoginController + SecurityConfig 白名单 + 端到端 IT | |
| 203 | + | |
| 204 | +**Files:** | |
| 205 | +- Create: `backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java` | |
| 206 | +- Modify: `backend/src/main/java/com/xly/erp/config/SecurityConfig.java`(白名单 /api/auth/login) | |
| 207 | +- Modify: `backend/src/main/resources/application.yml`(确认 `erp.jwt.secret` / `erp.jwt.expires-in-seconds` 已存在;REQ-USR-001 引入时已设置) | |
| 208 | +- Test: `backend/src/test/java/com/xly/erp/module/usr/controller/LoginControllerIT.java` | |
| 209 | + | |
| 210 | +**API shape:** | |
| 211 | +- `@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor LoginController` | |
| 212 | +- `@PostMapping("/login") ApiResponse<LoginResultVO> login(@Valid @RequestBody LoginDTO dto)` | |
| 213 | +- Javadoc: `REQ-USR-004 用户登录 — 永久 permitAll;其他既有端点鉴权切换由后续 REQ 完成` | |
| 214 | +- SecurityConfig 修改:在 `authorizeHttpRequests` 配置上下文中显式 `requestMatchers("/api/auth/login").permitAll()`,其他保持 permitAll(REQ-USR-004 不强制收紧,留作技术债登记) | |
| 215 | + | |
| 216 | +- [ ] **Step 5.1 写失败测试(9 个)** | |
| 217 | + - 测试方式:`@SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") @Transactional @Rollback`,`@Autowired UserMapper / PasswordEncoder / InMemoryLoginAttemptStore`,`@BeforeEach` 调用 `attemptStore.clear(sUserName)`(避免跨测试串扰,因为 store 是单例 ConcurrentHashMap)。 | |
| 218 | + - `login_validCredentials_returns200WithToken`:先 mapper.insert 一个 user(密码 BCrypt 哈希 666666)→ POST 登录 → 断言 200 / token 非空 / expiresIn=7200 / user 嵌套。 | |
| 219 | + - `login_jwtClaimsAreCorrect`:上一条 token 用 JwtTokenProvider 解析(@Autowired),断言 sub / uid / type 与 DB 一致。 | |
| 220 | + - `login_invalidUsername_returns40101` | |
| 221 | + - `login_wrongPassword_returns40101` | |
| 222 | + - `login_softDeletedUser_returns40101` | |
| 223 | + - `login_missingPassword_returns40010` | |
| 224 | + - `login_invalidVersion_returns40010`:sVersion="experimental" | |
| 225 | + - `login_5thFailureLocks_returns40301`:连续 5 次 wrong password,第 5 次断言 code=40301 + `data.cooldownSeconds > 0` | |
| 226 | + - `login_responseExcludesSPasswordHash`:jsonPath `$.data.user.sPasswordHash` doesNotExist | |
| 227 | + - 子会话: FAIL | |
| 228 | + | |
| 229 | +- [ ] **Step 5.2 实现 LoginController + SecurityConfig 白名单** | |
| 230 | + - 子会话: PASS | |
| 231 | + | |
| 232 | +- [ ] **Step 5.3 跑全量 backend 测试** | |
| 233 | + - `cd backend && mvn -B test` | |
| 234 | + - 期望 144 + 4(DTO valid) + 2(JwtTokenProvider) + 4(InMemoryStore) + 7(service unit) + 9(controller IT) = 170 测试,全绿 | |
| 235 | + | |
| 236 | +- [ ] **Step 5.4 提交** | |
| 237 | + - `git commit -m "feat(usr): POST /api/auth/login controller REQ-USR-004"` | |
| 238 | + | |
| 239 | +--- | |
| 240 | + | |
| 241 | +## 提交计划 | |
| 242 | + | |
| 243 | +- `feat(usr): jjwt deps + login DTO/VO + error codes REQ-USR-004`(Task 1) | |
| 244 | +- `feat(usr): JwtTokenProvider sign/parse REQ-USR-004`(Task 2) | |
| 245 | +- `feat(usr): in-memory login attempt store REQ-USR-004`(Task 3) | |
| 246 | +- `feat(usr): login service + account locked handling REQ-USR-004`(Task 4) | |
| 247 | +- `feat(usr): POST /api/auth/login controller REQ-USR-004`(Task 5) | ... | ... |
docs/superpowers/reviews/2026-05-06-REQ-USR-004.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-004 | |
| 3 | +date: 2026-05-06 | |
| 4 | +round: 2 | |
| 5 | +reviewer: superpower-code-reviewer | |
| 6 | +--- | |
| 7 | + | |
| 8 | +# Review: REQ-USR-004 — round 2 | |
| 9 | + | |
| 10 | +## 结论 | |
| 11 | +approve | |
| 12 | + | |
| 13 | +## Must-fix | |
| 14 | +(无) | |
| 15 | + | |
| 16 | +Round 1 三条 must_fix 处理结果(commit d439c0d): | |
| 17 | +1. **CRITICAL JwtTokenProviderTest 硬编码生产 JWT_SECRET** — RESOLVED。test SECRET 改为 fake 32-byte hex(line 21);`.env.local` JWT_SECRET 已旋转为新随机值。**遗留运维项**:旧值已入 commit b7ed804 git history,所有已部署环境必须同步轮换 JWT_SECRET。 | |
| 18 | +2. **HIGH InMemoryLoginAttemptStore 锁定到期不重置** — RESOLVED。cooldownSeconds 过期路径 `store.remove`,recordFailure compute 入口检测 prev.lockUntil 过期重建 FailRecord;spec § 业务规则 4 第 4 条达成。 | |
| 19 | +3. **MEDIUM 验收 #9 无测试** — RESOLVED。补 `cooldown_afterExpiry_resetsCount` 单测(含 reset 后再 4 次未锁、第 5 次再锁的完整往返)+ `login_afterLockExpiry_returns200` IT;`expireLockForTest` 改 public 解决跨包可见性。 | |
| 20 | + | |
| 21 | +## Nice-to-have | |
| 22 | + | |
| 23 | +- backend/src/main/java/com/xly/erp/module/usr/controller/LoginController.java + service — spec § 业务规则 7 客户端 IP 审计未实施;service 未拿 `HttpServletRequest.getRemoteAddr()`。**需在模块完成报告 § ⑩ 登记技术债**。 | |
| 24 | +- backend/src/test/java/com/xly/erp/module/usr/service/LoginServiceImplTest.java — `login_successUpdatesTLastLoginDate_viaSetClause` 仍只 `verify(...).update(isNull(), any(Wrapper.class))`,没断言 LambdaUpdateWrapper 的 set 子句包含 tLastLoginDate(plan Step 4.1 要求)。 | |
| 25 | +- backend/src/main/java/com/xly/erp/config/SecurityConfig.java — plan Step 5.2 要求 `requestMatchers("/api/auth/login").permitAll()` 显式白名单;当前 anyRequest().permitAll() 等价覆盖但未做 plan 偏离登记。 | |
| 26 | +- backend/src/main/java/com/xly/erp/common/exception/GlobalExceptionHandler.java:17 — `java.util.Map` fully-qualified;下次 sweep 加 import。 | |
| 27 | +- backend/src/main/java/com/xly/erp/module/usr/security/InMemoryLoginAttemptStore.java:51 — `expireLockForTest` 已 public 但住在生产包;下一 sweep 可挪到 src/test 的 testutil 包。 | |
| 28 | + | |
| 29 | +## 反例 / 测试覆盖缺口 | |
| 30 | + | |
| 31 | +Round 1 三项 must_fix 全部解决: | |
| 32 | +1. JwtTokenProviderTest SECRET 改 fake + .env.local 已旋转;运维侧需轮换部署环境。 | |
| 33 | +2. cooldownSeconds + recordFailure 双路径处理过期重置;business rule #4 完整。 | |
| 34 | +3. 验收 #9 单测 + IT 双层覆盖;reset 后能重新触发新一轮锁定。 | |
| 35 | + | |
| 36 | +`mvn -B test` 经 .env.local 注入后 172/172 全绿;`scripts/test.sh` GREEN;无新高危。 | |
| 37 | + | |
| 38 | +**核心结论**:critical + high + medium 三项关键修复已落地。剩余 5 条 nice-to-have(IP 审计 / SET 子句捕获 / SecurityConfig 显式白名单 / Map import / testutil 移植)在模块完成报告 § ⑩ 登记后留给后续 sweep。verdict: approve。 | ... | ... |
docs/superpowers/specs/2026-05-06-REQ-USR-004.md
0 → 100644
| 1 | +--- | |
| 2 | +req_id: REQ-USR-004 | |
| 3 | +date: 2026-05-06 | |
| 4 | +module: module_usr | |
| 5 | +--- | |
| 6 | + | |
| 7 | +# Spec: REQ-USR-004 — 用户登录 | |
| 8 | + | |
| 9 | +## 目标 | |
| 10 | + | |
| 11 | +实现后端 `POST /api/auth/login` 接口:用户凭用户名 + 密码(+ 版本)完成身份认证,签发限时 JWT access token + 返回用户基本信息;连续失败时临时锁定账号;登录成功回写 `tLastLoginDate`。 | |
| 12 | + | |
| 13 | +## 范围说明 | |
| 14 | + | |
| 15 | +本 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 处理。 | |
| 16 | + | |
| 17 | +> 锁定失败计数:spec 写明用**内存** `ConcurrentHashMap` 保管;docs/03 § tUser 业务注记声明走 Redis,但本仓库 .env.local 未配 Redis 凭据、pom 也未引 spring-boot-starter-data-redis。本期保留内存实现并在 javadoc / 业务注记中注明"REQ-USR-XXX 引入 Redis 后替换"。 | |
| 18 | + | |
| 19 | +## 输入 / 触发 | |
| 20 | + | |
| 21 | +**接口**:`POST /api/auth/login`,Content-Type `application/json`。**不需鉴权**(white-list in SecurityConfig)。 | |
| 22 | + | |
| 23 | +**Request body**(`LoginDTO`): | |
| 24 | + | |
| 25 | +| 字段 | 类型 | 必填 | 校验 | | |
| 26 | +|---|---|---|---| | |
| 27 | +| `sUserName` | String | 是 | 长度 1-50 | | |
| 28 | +| `sPassword` | String | 是 | 长度 1-100(明文 HTTPS 传输;线下 prod 必须 HTTPS) | | |
| 29 | +| `sVersion` | String | 是 | 枚举:`standard`(仅一个值;REQ 卡片表 1 写"标准版",spec 用 ASCII `standard` 作 SQL/code 友好) | | |
| 30 | + | |
| 31 | +## 输出 / 结果 | |
| 32 | + | |
| 33 | +**HTTP 200,响应体**(成功): | |
| 34 | + | |
| 35 | +```json | |
| 36 | +{ | |
| 37 | + "code": 200, | |
| 38 | + "message": "操作成功", | |
| 39 | + "data": { | |
| 40 | + "accessToken": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImlhdCI6MTc0NjUyODYwMCwiZXhwIjoxNzQ2NTM1ODAwfQ...", | |
| 41 | + "expiresIn": 7200, | |
| 42 | + "user": { | |
| 43 | + "iIncrement": 12, | |
| 44 | + "sUserNo": "u001", | |
| 45 | + "sUserName": "alice", | |
| 46 | + "sUserType": "普通用户", | |
| 47 | + "sLanguage": "zh" | |
| 48 | + } | |
| 49 | + }, | |
| 50 | + "timestamp": 1746528600000 | |
| 51 | +} | |
| 52 | +``` | |
| 53 | + | |
| 54 | +新建 VO `LoginResultVO`:字段 `accessToken` / `expiresIn`(秒)/ `user`(嵌套 `LoginUserInfo`:iIncrement / sUserNo / sUserName / sUserType / sLanguage 5 字段)。 | |
| 55 | + | |
| 56 | +**不返回**:`sPasswordHash` / `iStaffId` / `bCanModifyDocs` / `tLastLoginDate` / 多租户字段 / 软删除三件套。`refreshToken` 暂不签发(spec § 范围说明,留作后续)。 | |
| 57 | + | |
| 58 | +## 业务规则 | |
| 59 | + | |
| 60 | +1. **JWT 签发**:HS256,secret 来自 `.env.local` `JWT_SECRET`(已配置);`expiresIn = 7200`(2 小时);claims:`sub = sUserName`、`iat`、`exp`、`uid = iIncrement`、`type = sUserType`。 | |
| 61 | +2. **密码校验**:`BCryptPasswordEncoder.matches(sPassword, user.sPasswordHash)`。复用 REQ-USR-001 引入的 PasswordConfig bean。 | |
| 62 | +3. **目标用户存在 + 未软删**:`SELECT iIncrement, sUserName, sPasswordHash, sUserType, sLanguage, sUserNo, bDeleted FROM tUser WHERE sUserName = ? AND bDeleted = 0`。null → 失败计数 + `40101`(用户名或密码错误,不区分原因避免账号枚举);`bDeleted=1` 同样 40101。 | |
| 63 | +4. **失败计数 + 锁定**:内存 `Map<String, FailRecord>`(key=sUserName)。`FailRecord{count, firstFailAt, lockUntil}`。 | |
| 64 | + - 每次密码错误 → `count++`,记 `firstFailAt`(首次失败时间,每个统计窗口)。 | |
| 65 | + - `count >= 5` 且未锁定 → 设 `lockUntil = now + 15min`。 | |
| 66 | + - 已锁定(`now < lockUntil`)→ 返回 `40301`(账号锁定)+ `data.cooldownSeconds = (lockUntil - now)`。 | |
| 67 | + - 锁定到期(`now >= lockUntil`)→ reset `count=0`,正常进入校验流程。 | |
| 68 | + - 登录成功 → `cache.remove(sUserName)`(清失败计数)。 | |
| 69 | +5. **`sVersion` 校验**:当前仅支持 `standard`(DTO @Pattern + service 层防御)。其他值返回 `40010`。 | |
| 70 | +6. **`tLastLoginDate` 回写**:成功签发 token 后用 `LambdaUpdateWrapper.set(tLastLoginDate, now)` 显式更新,避免 entity-driven update 触发 iStaffId.IGNORED 副作用。 | |
| 71 | +7. **审计 / 日志**:登录失败 / 锁定 / 成功均 `log.info` 一条(含 sUserName + 客户端 IP,IP 由 controller 通过 `HttpServletRequest.getRemoteAddr()` 获取并传给 service;REQ 卡片 § 验收提到"防暴力破解"以日志为最低抑制)。 | |
| 72 | +8. **响应格式**:成功 / 失败统一 `{code, message, data, timestamp}`;data 在失败时为 null(除 40301 外,含 `cooldownSeconds`)。 | |
| 73 | + | |
| 74 | +## 边界与约束 | |
| 75 | + | |
| 76 | +### 鉴权策略 | |
| 77 | + | |
| 78 | +- **本接口** `/api/auth/login`:white-list,permitAll(任何人可调)。 | |
| 79 | +- **其他既有接口**:本 REQ 不切换为 authenticated,仍保留 permitAll。技术债登记在模块完成报告 § ⑩。 | |
| 80 | + | |
| 81 | +### 错误码映射 | |
| 82 | + | |
| 83 | +| 场景 | 错误码 | ErrorCode 枚举 | | |
| 84 | +|---|---|---| | |
| 85 | +| 必填缺失 / 长度超限 / sVersion 非枚举 | 40010 | `PARAM_INVALID`(已存在) | | |
| 86 | +| 用户名不存在 / 密码错误 / 用户已软删 | 40101 | `LOGIN_INVALID_CREDENTIALS`(**新增**) | | |
| 87 | +| 账号被临时锁定 | 40301 | `LOGIN_ACCOUNT_LOCKED`(**新增**) | | |
| 88 | +| 服务端兜底 | 50000 | `INTERNAL_ERROR` | | |
| 89 | + | |
| 90 | +### Redis 缺位的妥协 | |
| 91 | + | |
| 92 | +`InMemoryLoginAttemptStore`(service 层 bean)实现 `LoginAttemptStore` 接口,提供 `recordFailure / clear / isLocked / cooldownSeconds`。后续 REQ 引入 Redis 时新增 `RedisLoginAttemptStore` impl,替换 bean,service 不变。 | |
| 93 | + | |
| 94 | +### JWT 库选择 | |
| 95 | + | |
| 96 | +引入 `io.jsonwebtoken:jjwt-api / jjwt-impl / jjwt-jackson` 0.12.x(最新稳定)。这属 docs/04 § 零 已列「权限认证 Spring Security / JWT」的具体实现,不触发软规则 S1。 | |
| 97 | + | |
| 98 | +### SecurityConfig 改动 | |
| 99 | + | |
| 100 | +`SecurityConfig` 在白名单中显式加 `requestMatchers("/api/auth/login").permitAll()`;其他 `anyRequest().permitAll()` 维持原状(与 module_mod / module_usr 已有端点兼容)。 | |
| 101 | + | |
| 102 | +### 防暴力破解 | |
| 103 | + | |
| 104 | +仅做基础锁定(5 次错误 → 15 分钟锁定)。docs/04 § 1.6 提到 token 生命周期 / 刷新机制 / 密钥管理;本期完成 expires-in、未实施 refreshToken 流转,记入已知 gap。 | |
| 105 | + | |
| 106 | +## 依赖的 schema 表 / 字段 | |
| 107 | + | |
| 108 | +**读表**:`tUser`(按 sUserName 查 + 校验密码) | |
| 109 | + | |
| 110 | +**写表**:`tUser`(仅写 `tLastLoginDate`;其他字段不动) | |
| 111 | + | |
| 112 | +| 字段 | 用途 | | |
| 113 | +|---|---| | |
| 114 | +| `sUserName` | 主索引(uk_user_name) | | |
| 115 | +| `sPasswordHash` | BCrypt 校验 | | |
| 116 | +| `bDeleted` | 过滤 | | |
| 117 | +| `iIncrement` / `sUserNo` / `sUserType` / `sLanguage` | LoginUserInfo / JWT claims | | |
| 118 | +| `tLastLoginDate` | 成功登录回写 | | |
| 119 | + | |
| 120 | +**索引利用**:`uk_user_name`(UNIQUE,单字段查询 O(1))。 | |
| 121 | + | |
| 122 | +**外键 / 关联表**:本接口不用。 | |
| 123 | + | |
| 124 | +## 依赖的接口 | |
| 125 | + | |
| 126 | +无(独立鉴权入口)。 | |
| 127 | + | |
| 128 | +## 验收标准 | |
| 129 | + | |
| 130 | +### 功能正确性 | |
| 131 | + | |
| 132 | +1. **正向 — 凭据正确**:先用 REQ-USR-001 创建用户(默认密码 666666),POST /api/auth/login,返回 200 + accessToken(非空)+ expiresIn=7200 + user 含 5 字段;DB 中 tLastLoginDate 已写入。 | |
| 133 | +2. **JWT 解析**:解析 accessToken(用 JWT_SECRET),断言 claims 含 `sub == sUserName`、`uid == iIncrement`、`type == sUserType`、`exp - iat == 7200`。 | |
| 134 | +3. **凭据错误 — 用户名不存在**:返回 40101。 | |
| 135 | +4. **凭据错误 — 密码错误**:返回 40101;DB tLastLoginDate 不更新;内存计数 +1。 | |
| 136 | +5. **用户已软删**:先创建用户后置 bDeleted=1,登录返回 40101(不区分原因防账号枚举)。 | |
| 137 | +6. **必填缺失 / sVersion 非枚举**:返回 40010。 | |
| 138 | +7. **5 次密码错误后锁定**:连续 5 次错误密码登录同一用户名,第 5 次(含)开始返回 40301 + `data.cooldownSeconds > 0`;正确密码也返回 40301(锁定期内一律锁定)。 | |
| 139 | +8. **锁定后正确密码仍 40301**:锁定状态下传正确密码也拒绝;登录失败和成功都不重置锁定到期时间。 | |
| 140 | +9. **锁定到期后可登录**:直接修改内存 store 的 lockUntil 至过去,再用正确密码登录,返回 200。 | |
| 141 | +10. **响应不含 sPasswordHash**:jsonPath 验证 `$.data.user.sPasswordHash` doesNotExist。 | |
| 142 | + | |
| 143 | +### 接口契约一致性 | |
| 144 | + | |
| 145 | +- 响应格式 `{code, message, data, timestamp}`;data 嵌套 `{accessToken, expiresIn, user}`。 | |
| 146 | +- 错误码 200 / 40010 / 40101 / 40301 / 50000。 | |
| 147 | +- 不暴露 sPasswordHash;不回显堆栈。 | |
| 148 | + | |
| 149 | +### 测试覆盖 | |
| 150 | + | |
| 151 | +- **单元测试** `LoginServiceImplTest`(mock UserMapper / PasswordEncoder / LoginAttemptStore / JwtTokenProvider): | |
| 152 | + - login_validCredentials_returnsTokenAndClearsFailCount | |
| 153 | + - login_userNotFound_returns40101_recordsFailure | |
| 154 | + - login_userSoftDeleted_returns40101 | |
| 155 | + - login_passwordMismatch_returns40101_recordsFailure | |
| 156 | + - login_accountLocked_returns40301_withCooldown | |
| 157 | + - login_5thFailureTriggersLock | |
| 158 | + - login_successUpdatesTLastLoginDate | |
| 159 | + - jwtTokenProvider_signAndParse_returnsClaims(独立 JwtTokenProvider 单元测试) | |
| 160 | + | |
| 161 | +- **集成测试** `LoginControllerIT`(@SpringBootTest @Transactional + 真实 PasswordEncoder + 真实 InMemoryLoginAttemptStore,用 @BeforeEach 重置 store): | |
| 162 | + - login_validCredentials_returns200WithToken | |
| 163 | + - login_jwtClaimsAreCorrect | |
| 164 | + - login_invalidUsername_returns40101 | |
| 165 | + - login_wrongPassword_returns40101 | |
| 166 | + - login_softDeletedUser_returns40101 | |
| 167 | + - login_missingPassword_returns40010 | |
| 168 | + - login_invalidVersion_returns40010 | |
| 169 | + - login_5thFailureLocks_returns40301 | |
| 170 | + - login_responseExcludesSPasswordHash | |
| 171 | + | |
| 172 | +### 代码与文档 | |
| 173 | + | |
| 174 | +- `// REQ-USR-004` 注释贴在 LoginController / LoginService / JwtTokenProvider / LoginAttemptStore / 新增 ErrorCode。 | |
| 175 | +- 提交按 `feat(usr): <subject> REQ-USR-004` 规范。 | |
| 176 | +- pom.xml 新增 jjwt 三件套(api/impl/jackson 0.12.x)。 | ... | ... |