Commit 293118bc68c866d68782026c083145ed3cae7d87

Authored by zichun
1 parent a8075798

feat(usr): 登录认证 Service 与公司列表 REQ-USR-004

backend/src/main/java/com/xly/erp/modules/usr/service/UsrAuthService.java 0 → 100644
  1 +package com.xly.erp.modules.usr.service;
  2 +
  3 +import com.xly.erp.modules.usr.dto.LoginDTO;
  4 +import com.xly.erp.modules.usr.vo.CompanyOptionVO;
  5 +import com.xly.erp.modules.usr.vo.LoginVO;
  6 +import java.util.List;
  7 +
  8 +/**
  9 + * 认证服务(spec § 3)。REQ-USR-004 T4。
  10 + */
  11 +public interface UsrAuthService {
  12 +
  13 + /**
  14 + * 登录认证:基础参数校验 → 查用户 → BCrypt 比对 → 判禁用 → companyId 存在性 →
  15 + * 签发 JWT + 更新登录时间;含连续失败限流。
  16 + *
  17 + * @param dto 登录入参
  18 + * @return 登录输出(token + 用户基础信息,绝不含密码)
  19 + */
  20 + LoginVO login(LoginDTO dto);
  21 +
  22 + /**
  23 + * 公司下拉列表(登录页「版本」选项),只读无副作用。
  24 + *
  25 + * @return 全部公司下拉项
  26 + */
  27 + List<CompanyOptionVO> listCompanies();
  28 +}
backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrAuthServiceImpl.java 0 → 100644
  1 +package com.xly.erp.modules.usr.service.impl;
  2 +
  3 +import com.baomidou.mybatisplus.core.toolkit.Wrappers;
  4 +import com.xly.erp.common.exception.BusinessException;
  5 +import com.xly.erp.common.response.ResultCode;
  6 +import com.xly.erp.common.security.JwtUtil;
  7 +import com.xly.erp.modules.usr.dto.LoginDTO;
  8 +import com.xly.erp.modules.usr.entity.UsrCompany;
  9 +import com.xly.erp.modules.usr.entity.UsrUser;
  10 +import com.xly.erp.modules.usr.mapper.UsrCompanyMapper;
  11 +import com.xly.erp.modules.usr.mapper.UsrUserMapper;
  12 +import com.xly.erp.modules.usr.service.UsrAuthService;
  13 +import com.xly.erp.modules.usr.vo.CompanyOptionVO;
  14 +import com.xly.erp.modules.usr.vo.LoginVO;
  15 +import java.time.LocalDateTime;
  16 +import java.util.ArrayList;
  17 +import java.util.List;
  18 +import java.util.concurrent.ConcurrentHashMap;
  19 +import org.springframework.beans.factory.annotation.Value;
  20 +import org.springframework.security.crypto.password.PasswordEncoder;
  21 +import org.springframework.stereotype.Service;
  22 +import org.springframework.transaction.annotation.Transactional;
  23 +import org.springframework.util.StringUtils;
  24 +
  25 +/**
  26 + * 认证业务实现(spec § 3)。REQ-USR-004 T4。
  27 + *
  28 + * <p>认证判定顺序(spec § 3 规则 1 / § 8 D3,固定):
  29 + * ①限流窗判定(命中锁定 → 42901,置于最前)→ ②按 sUserName(trim) 查 usr_user,null → 40101(记一次失败)
  30 + * → ③BCrypt 比对密码,不匹配 → 40101(记一次失败,与②同码同 message 防枚举)→ ④iIsVoid=1 → 40302
  31 + * (先验密码再判禁用,禁用不计入失败计数避免锁死)→ ⑤companyId 存在性校验,不存在 → 40001
  32 + * → ⑥签发 JWT + 仅更新 tLastLoginDate + 清零失败计数 + 返回 LoginVO。</p>
  33 + *
  34 + * <p>限流为进程内(内存)按用户名计数(spec § 8 D7),不依赖 Redis;阈值 / 窗口来自
  35 + * application.yml auth.login.max-fail / auth.login.lock-seconds。密码明文绝不进日志 / 异常 message。</p>
  36 + */
  37 +@Service
  38 +public class UsrAuthServiceImpl implements UsrAuthService {
  39 +
  40 + private final UsrUserMapper usrUserMapper;
  41 + private final UsrCompanyMapper usrCompanyMapper;
  42 + private final PasswordEncoder passwordEncoder;
  43 + private final JwtUtil jwtUtil;
  44 +
  45 + /** 达到该连续失败次数后锁定。 */
  46 + private final int maxFail;
  47 + /** 锁定窗秒数。 */
  48 + private final long lockSeconds;
  49 +
  50 + /** 进程内按用户名的失败计数 / 锁定状态(线程安全)。 */
  51 + private final ConcurrentHashMap<String, LoginAttempt> attempts = new ConcurrentHashMap<>();
  52 +
  53 + public UsrAuthServiceImpl(UsrUserMapper usrUserMapper,
  54 + UsrCompanyMapper usrCompanyMapper,
  55 + PasswordEncoder passwordEncoder,
  56 + JwtUtil jwtUtil,
  57 + @Value("${auth.login.max-fail:5}") int maxFail,
  58 + @Value("${auth.login.lock-seconds:300}") long lockSeconds) {
  59 + this.usrUserMapper = usrUserMapper;
  60 + this.usrCompanyMapper = usrCompanyMapper;
  61 + this.passwordEncoder = passwordEncoder;
  62 + this.jwtUtil = jwtUtil;
  63 + this.maxFail = maxFail;
  64 + this.lockSeconds = lockSeconds;
  65 + }
  66 +
  67 + @Override
  68 + @Transactional(rollbackFor = Exception.class)
  69 + public LoginVO login(LoginDTO dto) {
  70 + String userName = dto.getSUserName() == null ? null : dto.getSUserName().trim();
  71 +
  72 + // ① 限流窗判定(命中锁定 → 42901,置于最前)。
  73 + if (isLocked(userName)) {
  74 + throw new BusinessException(ResultCode.LOGIN_RATE_LIMITED);
  75 + }
  76 +
  77 + // ② 按用户名查用户,未查到 → 40101(防枚举,统一文案)。
  78 + UsrUser user = usrUserMapper.selectOne(
  79 + Wrappers.<UsrUser>lambdaQuery().eq(UsrUser::getSUserName, userName));
  80 + if (user == null) {
  81 + recordFailure(userName);
  82 + throw new BusinessException(ResultCode.UNAUTHORIZED);
  83 + }
  84 +
  85 + // ③ 先 BCrypt 比对密码,不匹配 → 40101(与②同码同 message)。
  86 + if (!passwordEncoder.matches(dto.getPassword(), user.getSPassword())) {
  87 + recordFailure(userName);
  88 + throw new BusinessException(ResultCode.UNAUTHORIZED);
  89 + }
  90 +
  91 + // ④ 再判禁用 → 40302(先验密码再返禁用码;禁用不计入失败计数避免锁死禁用账号)。
  92 + if (user.getIIsVoid() != null && user.getIIsVoid() == 1) {
  93 + throw new BusinessException(ResultCode.ACCOUNT_DISABLED);
  94 + }
  95 +
  96 + // ⑤ companyId 存在性校验(不参与认证绑定),不存在 → 40001(中性提示,不含枚举信息)。
  97 + if (usrCompanyMapper.selectById(dto.getCompanyId()) == null) {
  98 + throw new BusinessException(ResultCode.PARAM_INVALID);
  99 + }
  100 +
  101 + // ⑥ 签发 JWT + 仅更新 tLastLoginDate + 清零失败计数 + 装配 LoginVO。
  102 + String token = jwtUtil.generateToken(user.getSUserName(), user.getSUserType());
  103 +
  104 + UsrUser loginTimeUpdate = new UsrUser();
  105 + loginTimeUpdate.setIIncrement(user.getIIncrement());
  106 + loginTimeUpdate.setTLastLoginDate(LocalDateTime.now());
  107 + usrUserMapper.updateById(loginTimeUpdate);
  108 +
  109 + attempts.remove(userName);
  110 +
  111 + return toLoginVO(token, user);
  112 + }
  113 +
  114 + @Override
  115 + @Transactional(readOnly = true)
  116 + public List<CompanyOptionVO> listCompanies() {
  117 + List<UsrCompany> companies = usrCompanyMapper.selectList(null);
  118 + List<CompanyOptionVO> result = new ArrayList<>();
  119 + for (UsrCompany c : companies) {
  120 + CompanyOptionVO vo = new CompanyOptionVO();
  121 + vo.setId(c.getIIncrement());
  122 + vo.setSCompanyName(c.getSCompanyName());
  123 + vo.setSVersion(c.getSVersion());
  124 + result.add(vo);
  125 + }
  126 + return result;
  127 + }
  128 +
  129 + private LoginVO toLoginVO(String token, UsrUser user) {
  130 + LoginVO.UserInfo info = new LoginVO.UserInfo();
  131 + info.setId(user.getIIncrement());
  132 + info.setSUserName(user.getSUserName());
  133 + info.setSUserType(user.getSUserType());
  134 + info.setSLanguage(user.getSLanguage());
  135 +
  136 + LoginVO vo = new LoginVO();
  137 + vo.setToken(token);
  138 + vo.setUser(info);
  139 + return vo;
  140 + }
  141 +
  142 + /** 当前用户名是否处于锁定窗内(连续失败已达阈值且未过期)。 */
  143 + private boolean isLocked(String userName) {
  144 + if (!StringUtils.hasText(userName)) {
  145 + return false;
  146 + }
  147 + LoginAttempt attempt = attempts.get(userName);
  148 + if (attempt == null) {
  149 + return false;
  150 + }
  151 + if (attempt.failCount >= maxFail && attempt.lockUntilEpochSec > nowEpochSec()) {
  152 + return true;
  153 + }
  154 + // 锁定窗已过:重置计数,允许再次尝试。
  155 + if (attempt.lockUntilEpochSec > 0 && attempt.lockUntilEpochSec <= nowEpochSec()) {
  156 + attempts.remove(userName);
  157 + }
  158 + return false;
  159 + }
  160 +
  161 + /** 记一次失败:累计计数,达阈值则设置锁定到期时间。 */
  162 + private void recordFailure(String userName) {
  163 + if (!StringUtils.hasText(userName)) {
  164 + return;
  165 + }
  166 + attempts.compute(userName, (k, prev) -> {
  167 + LoginAttempt a = prev == null ? new LoginAttempt() : prev;
  168 + a.failCount++;
  169 + if (a.failCount >= maxFail) {
  170 + a.lockUntilEpochSec = nowEpochSec() + lockSeconds;
  171 + }
  172 + return a;
  173 + });
  174 + }
  175 +
  176 + private long nowEpochSec() {
  177 + return System.currentTimeMillis() / 1000L;
  178 + }
  179 +
  180 + /** 单个用户名的失败计数与锁定到期(epoch 秒)。 */
  181 + private static final class LoginAttempt {
  182 + private int failCount;
  183 + private long lockUntilEpochSec;
  184 + }
  185 +}
backend/src/test/java/com/xly/erp/modules/usr/service/UsrAuthServiceImplTest.java 0 → 100644
  1 +package com.xly.erp.modules.usr.service;
  2 +
  3 +import static org.assertj.core.api.Assertions.assertThat;
  4 +import static org.assertj.core.api.Assertions.assertThatThrownBy;
  5 +import static org.mockito.ArgumentMatchers.any;
  6 +import static org.mockito.ArgumentMatchers.anyString;
  7 +import static org.mockito.ArgumentMatchers.eq;
  8 +import static org.mockito.Mockito.atLeastOnce;
  9 +import static org.mockito.Mockito.never;
  10 +import static org.mockito.Mockito.times;
  11 +import static org.mockito.Mockito.verify;
  12 +import static org.mockito.Mockito.when;
  13 +
  14 +import com.xly.erp.common.exception.BusinessException;
  15 +import com.xly.erp.common.response.ResultCode;
  16 +import com.xly.erp.common.security.JwtUtil;
  17 +import com.xly.erp.modules.usr.dto.LoginDTO;
  18 +import com.xly.erp.modules.usr.entity.UsrCompany;
  19 +import com.xly.erp.modules.usr.entity.UsrUser;
  20 +import com.xly.erp.modules.usr.mapper.UsrCompanyMapper;
  21 +import com.xly.erp.modules.usr.mapper.UsrUserMapper;
  22 +import com.xly.erp.modules.usr.service.impl.UsrAuthServiceImpl;
  23 +import com.xly.erp.modules.usr.vo.CompanyOptionVO;
  24 +import com.xly.erp.modules.usr.vo.LoginVO;
  25 +import java.util.List;
  26 +import org.junit.jupiter.api.BeforeEach;
  27 +import org.junit.jupiter.api.Test;
  28 +import org.mockito.ArgumentCaptor;
  29 +import org.mockito.Mockito;
  30 +
  31 +/**
  32 + * REQ-USR-004 T4:UsrAuthServiceImpl 认证核心(纯单元,Mockito)。
  33 + *
  34 + * <p>桩 UsrUserMapper/UsrCompanyMapper/PasswordEncoder/JwtUtil;限流阈值经测试构造器注入
  35 + * maxFail=3、lockSeconds=300,便于断言锁定行为。覆盖认证判定顺序、防枚举同码同文案、
  36 + * 限流锁定与成功清零、公司列表映射。</p>
  37 + */
  38 +class UsrAuthServiceImplTest {
  39 +
  40 + private UsrUserMapper usrUserMapper;
  41 + private UsrCompanyMapper usrCompanyMapper;
  42 + private org.springframework.security.crypto.password.PasswordEncoder passwordEncoder;
  43 + private JwtUtil jwtUtil;
  44 + private UsrAuthServiceImpl service;
  45 +
  46 + private static final int MAX_FAIL = 3;
  47 +
  48 + @BeforeEach
  49 + void setUp() {
  50 + usrUserMapper = Mockito.mock(UsrUserMapper.class);
  51 + usrCompanyMapper = Mockito.mock(UsrCompanyMapper.class);
  52 + passwordEncoder = Mockito.mock(
  53 + org.springframework.security.crypto.password.PasswordEncoder.class);
  54 + jwtUtil = Mockito.mock(JwtUtil.class);
  55 + service = new UsrAuthServiceImpl(usrUserMapper, usrCompanyMapper, passwordEncoder, jwtUtil,
  56 + MAX_FAIL, 300);
  57 + }
  58 +
  59 + private LoginDTO dto(String userName) {
  60 + LoginDTO d = new LoginDTO();
  61 + d.setSUserName(userName);
  62 + d.setPassword("666666");
  63 + d.setCompanyId(1);
  64 + return d;
  65 + }
  66 +
  67 + private UsrUser activeUser(String userName) {
  68 + UsrUser u = new UsrUser();
  69 + u.setIIncrement(42);
  70 + u.setSUserName(userName);
  71 + u.setSPassword("$2a$hash");
  72 + u.setSUserType("超级管理员");
  73 + u.setSLanguage("中文");
  74 + u.setIIsVoid(0);
  75 + return u;
  76 + }
  77 +
  78 + private void stubUserFound(String userName) {
  79 + when(usrUserMapper.selectOne(any())).thenReturn(activeUser(userName));
  80 + }
  81 +
  82 + @Test
  83 + void successReturnsTokenAndUpdatesLoginTime() {
  84 + stubUserFound("admin");
  85 + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true);
  86 + when(usrCompanyMapper.selectById(1)).thenReturn(new UsrCompany());
  87 + when(jwtUtil.generateToken("admin", "超级管理员")).thenReturn("jwt.token");
  88 +
  89 + LoginVO vo = service.login(dto("admin"));
  90 +
  91 + assertThat(vo.getToken()).isEqualTo("jwt.token");
  92 + assertThat(vo.getUser().getId()).isEqualTo(42);
  93 + assertThat(vo.getUser().getSUserName()).isEqualTo("admin");
  94 + assertThat(vo.getUser().getSUserType()).isEqualTo("超级管理员");
  95 + assertThat(vo.getUser().getSLanguage()).isEqualTo("中文");
  96 +
  97 + ArgumentCaptor<UsrUser> captor = ArgumentCaptor.forClass(UsrUser.class);
  98 + verify(usrUserMapper, times(1)).updateById(captor.capture());
  99 + UsrUser updated = captor.getValue();
  100 + assertThat(updated.getTLastLoginDate()).isNotNull();
  101 + assertThat(updated.getIIncrement()).isEqualTo(42);
  102 + // 仅更新主键 + tLastLoginDate,未覆写其它列(用户名/类型/语言/密码留 null)。
  103 + assertThat(updated.getSUserName()).isNull();
  104 + assertThat(updated.getSPassword()).isNull();
  105 + }
  106 +
  107 + @Test
  108 + void userNotFoundThrows40101() {
  109 + when(usrUserMapper.selectOne(any())).thenReturn(null);
  110 +
  111 + assertThatThrownBy(() -> service.login(dto("ghost")))
  112 + .isInstanceOf(BusinessException.class)
  113 + .extracting(e -> ((BusinessException) e).getResultCode())
  114 + .isEqualTo(ResultCode.UNAUTHORIZED);
  115 +
  116 + verify(passwordEncoder, never()).matches(anyString(), anyString());
  117 + verify(usrUserMapper, never()).updateById(any(UsrUser.class));
  118 + verify(jwtUtil, never()).generateToken(anyString(), anyString());
  119 + }
  120 +
  121 + @Test
  122 + void wrongPasswordThrows40101() {
  123 + stubUserFound("admin");
  124 + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false);
  125 +
  126 + assertThatThrownBy(() -> service.login(dto("admin")))
  127 + .isInstanceOf(BusinessException.class)
  128 + .extracting(e -> ((BusinessException) e).getResultCode())
  129 + .isEqualTo(ResultCode.UNAUTHORIZED);
  130 +
  131 + verify(usrUserMapper, never()).updateById(any(UsrUser.class));
  132 + verify(jwtUtil, never()).generateToken(anyString(), anyString());
  133 + }
  134 +
  135 + @Test
  136 + void notFoundAndWrongPasswordSameCodeAndMessage() {
  137 + // 用户不存在路径
  138 + when(usrUserMapper.selectOne(any())).thenReturn(null);
  139 + BusinessException notFound =
  140 + (BusinessException) catchEx(() -> service.login(dto("ghost")));
  141 +
  142 + // 密码错误路径
  143 + when(usrUserMapper.selectOne(any())).thenReturn(activeUser("admin"));
  144 + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false);
  145 + BusinessException wrongPwd =
  146 + (BusinessException) catchEx(() -> service.login(dto("admin")));
  147 +
  148 + assertThat(notFound.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED);
  149 + assertThat(wrongPwd.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED);
  150 + assertThat(notFound.getMessage()).isEqualTo(wrongPwd.getMessage());
  151 + }
  152 +
  153 + @Test
  154 + void disabledUserThrows40302AfterPasswordOk() {
  155 + UsrUser disabled = activeUser("admin");
  156 + disabled.setIIsVoid(1);
  157 + when(usrUserMapper.selectOne(any())).thenReturn(disabled);
  158 + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true);
  159 +
  160 + assertThatThrownBy(() -> service.login(dto("admin")))
  161 + .isInstanceOf(BusinessException.class)
  162 + .extracting(e -> ((BusinessException) e).getResultCode())
  163 + .isEqualTo(ResultCode.ACCOUNT_DISABLED);
  164 +
  165 + // 先验密码再判禁用:matches 被调用过。
  166 + verify(passwordEncoder, atLeastOnce()).matches(eq("666666"), anyString());
  167 + verify(jwtUtil, never()).generateToken(anyString(), anyString());
  168 + verify(usrUserMapper, never()).updateById(any(UsrUser.class));
  169 + }
  170 +
  171 + @Test
  172 + void illegalCompanyIdThrows40001() {
  173 + stubUserFound("admin");
  174 + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true);
  175 + when(usrCompanyMapper.selectById(1)).thenReturn(null);
  176 +
  177 + assertThatThrownBy(() -> service.login(dto("admin")))
  178 + .isInstanceOf(BusinessException.class)
  179 + .extracting(e -> ((BusinessException) e).getResultCode())
  180 + .isEqualTo(ResultCode.PARAM_INVALID);
  181 +
  182 + verify(jwtUtil, never()).generateToken(anyString(), anyString());
  183 + verify(usrUserMapper, never()).updateById(any(UsrUser.class));
  184 + }
  185 +
  186 + @Test
  187 + void rateLimitAfterMaxFailThrows42901() {
  188 + String user = "ratelimited";
  189 + when(usrUserMapper.selectOne(any())).thenReturn(activeUser(user));
  190 + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false);
  191 +
  192 + for (int i = 0; i < MAX_FAIL; i++) {
  193 + catchEx(() -> service.login(dto(user)));
  194 + }
  195 + Mockito.clearInvocations(usrUserMapper, jwtUtil);
  196 + // 锁定后即使密码正确也 42901,且不进入查用户之后的认证逻辑、不签发 token。
  197 + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true);
  198 +
  199 + assertThatThrownBy(() -> service.login(dto(user)))
  200 + .isInstanceOf(BusinessException.class)
  201 + .extracting(e -> ((BusinessException) e).getResultCode())
  202 + .isEqualTo(ResultCode.LOGIN_RATE_LIMITED);
  203 +
  204 + verify(jwtUtil, never()).generateToken(anyString(), anyString());
  205 + }
  206 +
  207 + @Test
  208 + void successResetsFailCounter() {
  209 + String user = "resetme";
  210 + when(usrUserMapper.selectOne(any())).thenReturn(activeUser(user));
  211 + when(usrCompanyMapper.selectById(1)).thenReturn(new UsrCompany());
  212 + when(jwtUtil.generateToken(anyString(), anyString())).thenReturn("jwt.token");
  213 +
  214 + // 先失败 2 次(< maxFail=3)
  215 + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false);
  216 + catchEx(() -> service.login(dto(user)));
  217 + catchEx(() -> service.login(dto(user)));
  218 +
  219 + // 一次成功登录清零
  220 + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true);
  221 + service.login(dto(user));
  222 +
  223 + // 再连续失败到「成功前次数 + 1」=3 次,不应触发 42901(仍为 40101)
  224 + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false);
  225 + for (int i = 0; i < MAX_FAIL; i++) {
  226 + BusinessException ex = (BusinessException) catchEx(() -> service.login(dto(user)));
  227 + assertThat(ex.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED);
  228 + }
  229 + }
  230 +
  231 + @Test
  232 + void listCompaniesMapsAllRows() {
  233 + UsrCompany a = new UsrCompany();
  234 + a.setIIncrement(1);
  235 + a.setSCompanyName("公司A");
  236 + a.setSVersion("v1");
  237 + UsrCompany b = new UsrCompany();
  238 + b.setIIncrement(2);
  239 + b.setSCompanyName("公司B");
  240 + b.setSVersion(null);
  241 + when(usrCompanyMapper.selectList(any())).thenReturn(List.of(a, b));
  242 +
  243 + List<CompanyOptionVO> result = service.listCompanies();
  244 +
  245 + assertThat(result).hasSize(2);
  246 + assertThat(result.get(0).getId()).isEqualTo(1);
  247 + assertThat(result.get(0).getSCompanyName()).isEqualTo("公司A");
  248 + assertThat(result.get(0).getSVersion()).isEqualTo("v1");
  249 + assertThat(result.get(1).getId()).isEqualTo(2);
  250 + assertThat(result.get(1).getSVersion()).isNull();
  251 + }
  252 +
  253 + private static Throwable catchEx(Runnable r) {
  254 + try {
  255 + r.run();
  256 + } catch (Throwable t) {
  257 + return t;
  258 + }
  259 + throw new AssertionError("expected exception but none thrown");
  260 + }
  261 +}