diff --git a/backend/src/main/java/com/xly/erp/modules/usr/service/UsrAuthService.java b/backend/src/main/java/com/xly/erp/modules/usr/service/UsrAuthService.java new file mode 100644 index 0000000..62908af --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/service/UsrAuthService.java @@ -0,0 +1,28 @@ +package com.xly.erp.modules.usr.service; + +import com.xly.erp.modules.usr.dto.LoginDTO; +import com.xly.erp.modules.usr.vo.CompanyOptionVO; +import com.xly.erp.modules.usr.vo.LoginVO; +import java.util.List; + +/** + * 认证服务(spec § 3)。REQ-USR-004 T4。 + */ +public interface UsrAuthService { + + /** + * 登录认证:基础参数校验 → 查用户 → BCrypt 比对 → 判禁用 → companyId 存在性 → + * 签发 JWT + 更新登录时间;含连续失败限流。 + * + * @param dto 登录入参 + * @return 登录输出(token + 用户基础信息,绝不含密码) + */ + LoginVO login(LoginDTO dto); + + /** + * 公司下拉列表(登录页「版本」选项),只读无副作用。 + * + * @return 全部公司下拉项 + */ + List listCompanies(); +} diff --git a/backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrAuthServiceImpl.java b/backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrAuthServiceImpl.java new file mode 100644 index 0000000..59dade8 --- /dev/null +++ b/backend/src/main/java/com/xly/erp/modules/usr/service/impl/UsrAuthServiceImpl.java @@ -0,0 +1,185 @@ +package com.xly.erp.modules.usr.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.xly.erp.common.exception.BusinessException; +import com.xly.erp.common.response.ResultCode; +import com.xly.erp.common.security.JwtUtil; +import com.xly.erp.modules.usr.dto.LoginDTO; +import com.xly.erp.modules.usr.entity.UsrCompany; +import com.xly.erp.modules.usr.entity.UsrUser; +import com.xly.erp.modules.usr.mapper.UsrCompanyMapper; +import com.xly.erp.modules.usr.mapper.UsrUserMapper; +import com.xly.erp.modules.usr.service.UsrAuthService; +import com.xly.erp.modules.usr.vo.CompanyOptionVO; +import com.xly.erp.modules.usr.vo.LoginVO; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +/** + * 认证业务实现(spec § 3)。REQ-USR-004 T4。 + * + *

认证判定顺序(spec § 3 规则 1 / § 8 D3,固定): + * ①限流窗判定(命中锁定 → 42901,置于最前)→ ②按 sUserName(trim) 查 usr_user,null → 40101(记一次失败) + * → ③BCrypt 比对密码,不匹配 → 40101(记一次失败,与②同码同 message 防枚举)→ ④iIsVoid=1 → 40302 + * (先验密码再判禁用,禁用不计入失败计数避免锁死)→ ⑤companyId 存在性校验,不存在 → 40001 + * → ⑥签发 JWT + 仅更新 tLastLoginDate + 清零失败计数 + 返回 LoginVO。

+ * + *

限流为进程内(内存)按用户名计数(spec § 8 D7),不依赖 Redis;阈值 / 窗口来自 + * application.yml auth.login.max-fail / auth.login.lock-seconds。密码明文绝不进日志 / 异常 message。

+ */ +@Service +public class UsrAuthServiceImpl implements UsrAuthService { + + private final UsrUserMapper usrUserMapper; + private final UsrCompanyMapper usrCompanyMapper; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + /** 达到该连续失败次数后锁定。 */ + private final int maxFail; + /** 锁定窗秒数。 */ + private final long lockSeconds; + + /** 进程内按用户名的失败计数 / 锁定状态(线程安全)。 */ + private final ConcurrentHashMap attempts = new ConcurrentHashMap<>(); + + public UsrAuthServiceImpl(UsrUserMapper usrUserMapper, + UsrCompanyMapper usrCompanyMapper, + PasswordEncoder passwordEncoder, + JwtUtil jwtUtil, + @Value("${auth.login.max-fail:5}") int maxFail, + @Value("${auth.login.lock-seconds:300}") long lockSeconds) { + this.usrUserMapper = usrUserMapper; + this.usrCompanyMapper = usrCompanyMapper; + this.passwordEncoder = passwordEncoder; + this.jwtUtil = jwtUtil; + this.maxFail = maxFail; + this.lockSeconds = lockSeconds; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public LoginVO login(LoginDTO dto) { + String userName = dto.getSUserName() == null ? null : dto.getSUserName().trim(); + + // ① 限流窗判定(命中锁定 → 42901,置于最前)。 + if (isLocked(userName)) { + throw new BusinessException(ResultCode.LOGIN_RATE_LIMITED); + } + + // ② 按用户名查用户,未查到 → 40101(防枚举,统一文案)。 + UsrUser user = usrUserMapper.selectOne( + Wrappers.lambdaQuery().eq(UsrUser::getSUserName, userName)); + if (user == null) { + recordFailure(userName); + throw new BusinessException(ResultCode.UNAUTHORIZED); + } + + // ③ 先 BCrypt 比对密码,不匹配 → 40101(与②同码同 message)。 + if (!passwordEncoder.matches(dto.getPassword(), user.getSPassword())) { + recordFailure(userName); + throw new BusinessException(ResultCode.UNAUTHORIZED); + } + + // ④ 再判禁用 → 40302(先验密码再返禁用码;禁用不计入失败计数避免锁死禁用账号)。 + if (user.getIIsVoid() != null && user.getIIsVoid() == 1) { + throw new BusinessException(ResultCode.ACCOUNT_DISABLED); + } + + // ⑤ companyId 存在性校验(不参与认证绑定),不存在 → 40001(中性提示,不含枚举信息)。 + if (usrCompanyMapper.selectById(dto.getCompanyId()) == null) { + throw new BusinessException(ResultCode.PARAM_INVALID); + } + + // ⑥ 签发 JWT + 仅更新 tLastLoginDate + 清零失败计数 + 装配 LoginVO。 + String token = jwtUtil.generateToken(user.getSUserName(), user.getSUserType()); + + UsrUser loginTimeUpdate = new UsrUser(); + loginTimeUpdate.setIIncrement(user.getIIncrement()); + loginTimeUpdate.setTLastLoginDate(LocalDateTime.now()); + usrUserMapper.updateById(loginTimeUpdate); + + attempts.remove(userName); + + return toLoginVO(token, user); + } + + @Override + @Transactional(readOnly = true) + public List listCompanies() { + List companies = usrCompanyMapper.selectList(null); + List result = new ArrayList<>(); + for (UsrCompany c : companies) { + CompanyOptionVO vo = new CompanyOptionVO(); + vo.setId(c.getIIncrement()); + vo.setSCompanyName(c.getSCompanyName()); + vo.setSVersion(c.getSVersion()); + result.add(vo); + } + return result; + } + + private LoginVO toLoginVO(String token, UsrUser user) { + LoginVO.UserInfo info = new LoginVO.UserInfo(); + info.setId(user.getIIncrement()); + info.setSUserName(user.getSUserName()); + info.setSUserType(user.getSUserType()); + info.setSLanguage(user.getSLanguage()); + + LoginVO vo = new LoginVO(); + vo.setToken(token); + vo.setUser(info); + return vo; + } + + /** 当前用户名是否处于锁定窗内(连续失败已达阈值且未过期)。 */ + private boolean isLocked(String userName) { + if (!StringUtils.hasText(userName)) { + return false; + } + LoginAttempt attempt = attempts.get(userName); + if (attempt == null) { + return false; + } + if (attempt.failCount >= maxFail && attempt.lockUntilEpochSec > nowEpochSec()) { + return true; + } + // 锁定窗已过:重置计数,允许再次尝试。 + if (attempt.lockUntilEpochSec > 0 && attempt.lockUntilEpochSec <= nowEpochSec()) { + attempts.remove(userName); + } + return false; + } + + /** 记一次失败:累计计数,达阈值则设置锁定到期时间。 */ + private void recordFailure(String userName) { + if (!StringUtils.hasText(userName)) { + return; + } + attempts.compute(userName, (k, prev) -> { + LoginAttempt a = prev == null ? new LoginAttempt() : prev; + a.failCount++; + if (a.failCount >= maxFail) { + a.lockUntilEpochSec = nowEpochSec() + lockSeconds; + } + return a; + }); + } + + private long nowEpochSec() { + return System.currentTimeMillis() / 1000L; + } + + /** 单个用户名的失败计数与锁定到期(epoch 秒)。 */ + private static final class LoginAttempt { + private int failCount; + private long lockUntilEpochSec; + } +} diff --git a/backend/src/test/java/com/xly/erp/modules/usr/service/UsrAuthServiceImplTest.java b/backend/src/test/java/com/xly/erp/modules/usr/service/UsrAuthServiceImplTest.java new file mode 100644 index 0000000..c872a18 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/modules/usr/service/UsrAuthServiceImplTest.java @@ -0,0 +1,261 @@ +package com.xly.erp.modules.usr.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.xly.erp.common.exception.BusinessException; +import com.xly.erp.common.response.ResultCode; +import com.xly.erp.common.security.JwtUtil; +import com.xly.erp.modules.usr.dto.LoginDTO; +import com.xly.erp.modules.usr.entity.UsrCompany; +import com.xly.erp.modules.usr.entity.UsrUser; +import com.xly.erp.modules.usr.mapper.UsrCompanyMapper; +import com.xly.erp.modules.usr.mapper.UsrUserMapper; +import com.xly.erp.modules.usr.service.impl.UsrAuthServiceImpl; +import com.xly.erp.modules.usr.vo.CompanyOptionVO; +import com.xly.erp.modules.usr.vo.LoginVO; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; + +/** + * REQ-USR-004 T4:UsrAuthServiceImpl 认证核心(纯单元,Mockito)。 + * + *

桩 UsrUserMapper/UsrCompanyMapper/PasswordEncoder/JwtUtil;限流阈值经测试构造器注入 + * maxFail=3、lockSeconds=300,便于断言锁定行为。覆盖认证判定顺序、防枚举同码同文案、 + * 限流锁定与成功清零、公司列表映射。

+ */ +class UsrAuthServiceImplTest { + + private UsrUserMapper usrUserMapper; + private UsrCompanyMapper usrCompanyMapper; + private org.springframework.security.crypto.password.PasswordEncoder passwordEncoder; + private JwtUtil jwtUtil; + private UsrAuthServiceImpl service; + + private static final int MAX_FAIL = 3; + + @BeforeEach + void setUp() { + usrUserMapper = Mockito.mock(UsrUserMapper.class); + usrCompanyMapper = Mockito.mock(UsrCompanyMapper.class); + passwordEncoder = Mockito.mock( + org.springframework.security.crypto.password.PasswordEncoder.class); + jwtUtil = Mockito.mock(JwtUtil.class); + service = new UsrAuthServiceImpl(usrUserMapper, usrCompanyMapper, passwordEncoder, jwtUtil, + MAX_FAIL, 300); + } + + private LoginDTO dto(String userName) { + LoginDTO d = new LoginDTO(); + d.setSUserName(userName); + d.setPassword("666666"); + d.setCompanyId(1); + return d; + } + + private UsrUser activeUser(String userName) { + UsrUser u = new UsrUser(); + u.setIIncrement(42); + u.setSUserName(userName); + u.setSPassword("$2a$hash"); + u.setSUserType("超级管理员"); + u.setSLanguage("中文"); + u.setIIsVoid(0); + return u; + } + + private void stubUserFound(String userName) { + when(usrUserMapper.selectOne(any())).thenReturn(activeUser(userName)); + } + + @Test + void successReturnsTokenAndUpdatesLoginTime() { + stubUserFound("admin"); + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); + when(usrCompanyMapper.selectById(1)).thenReturn(new UsrCompany()); + when(jwtUtil.generateToken("admin", "超级管理员")).thenReturn("jwt.token"); + + LoginVO vo = service.login(dto("admin")); + + assertThat(vo.getToken()).isEqualTo("jwt.token"); + assertThat(vo.getUser().getId()).isEqualTo(42); + assertThat(vo.getUser().getSUserName()).isEqualTo("admin"); + assertThat(vo.getUser().getSUserType()).isEqualTo("超级管理员"); + assertThat(vo.getUser().getSLanguage()).isEqualTo("中文"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(UsrUser.class); + verify(usrUserMapper, times(1)).updateById(captor.capture()); + UsrUser updated = captor.getValue(); + assertThat(updated.getTLastLoginDate()).isNotNull(); + assertThat(updated.getIIncrement()).isEqualTo(42); + // 仅更新主键 + tLastLoginDate,未覆写其它列(用户名/类型/语言/密码留 null)。 + assertThat(updated.getSUserName()).isNull(); + assertThat(updated.getSPassword()).isNull(); + } + + @Test + void userNotFoundThrows40101() { + when(usrUserMapper.selectOne(any())).thenReturn(null); + + assertThatThrownBy(() -> service.login(dto("ghost"))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.UNAUTHORIZED); + + verify(passwordEncoder, never()).matches(anyString(), anyString()); + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); + verify(jwtUtil, never()).generateToken(anyString(), anyString()); + } + + @Test + void wrongPasswordThrows40101() { + stubUserFound("admin"); + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); + + assertThatThrownBy(() -> service.login(dto("admin"))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.UNAUTHORIZED); + + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); + verify(jwtUtil, never()).generateToken(anyString(), anyString()); + } + + @Test + void notFoundAndWrongPasswordSameCodeAndMessage() { + // 用户不存在路径 + when(usrUserMapper.selectOne(any())).thenReturn(null); + BusinessException notFound = + (BusinessException) catchEx(() -> service.login(dto("ghost"))); + + // 密码错误路径 + when(usrUserMapper.selectOne(any())).thenReturn(activeUser("admin")); + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); + BusinessException wrongPwd = + (BusinessException) catchEx(() -> service.login(dto("admin"))); + + assertThat(notFound.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED); + assertThat(wrongPwd.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED); + assertThat(notFound.getMessage()).isEqualTo(wrongPwd.getMessage()); + } + + @Test + void disabledUserThrows40302AfterPasswordOk() { + UsrUser disabled = activeUser("admin"); + disabled.setIIsVoid(1); + when(usrUserMapper.selectOne(any())).thenReturn(disabled); + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); + + assertThatThrownBy(() -> service.login(dto("admin"))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.ACCOUNT_DISABLED); + + // 先验密码再判禁用:matches 被调用过。 + verify(passwordEncoder, atLeastOnce()).matches(eq("666666"), anyString()); + verify(jwtUtil, never()).generateToken(anyString(), anyString()); + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); + } + + @Test + void illegalCompanyIdThrows40001() { + stubUserFound("admin"); + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); + when(usrCompanyMapper.selectById(1)).thenReturn(null); + + assertThatThrownBy(() -> service.login(dto("admin"))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.PARAM_INVALID); + + verify(jwtUtil, never()).generateToken(anyString(), anyString()); + verify(usrUserMapper, never()).updateById(any(UsrUser.class)); + } + + @Test + void rateLimitAfterMaxFailThrows42901() { + String user = "ratelimited"; + when(usrUserMapper.selectOne(any())).thenReturn(activeUser(user)); + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); + + for (int i = 0; i < MAX_FAIL; i++) { + catchEx(() -> service.login(dto(user))); + } + Mockito.clearInvocations(usrUserMapper, jwtUtil); + // 锁定后即使密码正确也 42901,且不进入查用户之后的认证逻辑、不签发 token。 + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); + + assertThatThrownBy(() -> service.login(dto(user))) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getResultCode()) + .isEqualTo(ResultCode.LOGIN_RATE_LIMITED); + + verify(jwtUtil, never()).generateToken(anyString(), anyString()); + } + + @Test + void successResetsFailCounter() { + String user = "resetme"; + when(usrUserMapper.selectOne(any())).thenReturn(activeUser(user)); + when(usrCompanyMapper.selectById(1)).thenReturn(new UsrCompany()); + when(jwtUtil.generateToken(anyString(), anyString())).thenReturn("jwt.token"); + + // 先失败 2 次(< maxFail=3) + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); + catchEx(() -> service.login(dto(user))); + catchEx(() -> service.login(dto(user))); + + // 一次成功登录清零 + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(true); + service.login(dto(user)); + + // 再连续失败到「成功前次数 + 1」=3 次,不应触发 42901(仍为 40101) + when(passwordEncoder.matches(eq("666666"), anyString())).thenReturn(false); + for (int i = 0; i < MAX_FAIL; i++) { + BusinessException ex = (BusinessException) catchEx(() -> service.login(dto(user))); + assertThat(ex.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED); + } + } + + @Test + void listCompaniesMapsAllRows() { + UsrCompany a = new UsrCompany(); + a.setIIncrement(1); + a.setSCompanyName("公司A"); + a.setSVersion("v1"); + UsrCompany b = new UsrCompany(); + b.setIIncrement(2); + b.setSCompanyName("公司B"); + b.setSVersion(null); + when(usrCompanyMapper.selectList(any())).thenReturn(List.of(a, b)); + + List result = service.listCompanies(); + + assertThat(result).hasSize(2); + assertThat(result.get(0).getId()).isEqualTo(1); + assertThat(result.get(0).getSCompanyName()).isEqualTo("公司A"); + assertThat(result.get(0).getSVersion()).isEqualTo("v1"); + assertThat(result.get(1).getId()).isEqualTo(2); + assertThat(result.get(1).getSVersion()).isNull(); + } + + private static Throwable catchEx(Runnable r) { + try { + r.run(); + } catch (Throwable t) { + return t; + } + throw new AssertionError("expected exception but none thrown"); + } +}