测试用例补充并修复问题

This commit is contained in:
nojava
2026-05-18 21:54:13 +08:00
parent 2134ef4691
commit c6d1b54822
16 changed files with 1694 additions and 470 deletions
@@ -22,6 +22,8 @@ import java.util.UUID;
@RequiredArgsConstructor
public class AuthController {
private static final String PLACEHOLDER_CAPTCHA_CODE = "1234";
private final AuthService authService;
@Operation(summary = "用户登录")
@@ -53,7 +55,7 @@ public class AuthController {
public ApiResult<Map<String, String>> captcha() {
return ApiResult.success(Map.of(
"captchaKey", UUID.randomUUID().toString(),
"captchaImage", ""
"captchaImage", PLACEHOLDER_CAPTCHA_CODE
));
}
@@ -1,6 +1,7 @@
package fun.nojava.module.system.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
@@ -11,8 +12,18 @@ public class LoginDTO {
private String email;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 20, message = "密码长度6-20")
@Size(min = 8, max = 32, message = "密码长度8-32")
@Pattern(
regexp = "^(?:(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)|(?=.*[a-z])(?=.*[A-Z])(?=.*[^A-Za-z0-9])|(?=.*[a-z])(?=.*\\d)(?=.*[^A-Za-z0-9])|(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z0-9])).{8,32}$",
message = "密码需包含大小写字母、数字、特殊字符中的至少三类"
)
private String password;
private String captchaKey;
private String captchaCode;
private String deviceFingerprint;
private Boolean rememberMe = false;
}
@@ -2,6 +2,7 @@ package fun.nojava.module.system.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
@@ -13,7 +14,11 @@ public class RegisterDTO {
private String email;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 20, message = "密码长度6-20")
@Size(min = 8, max = 32, message = "密码长度8-32")
@Pattern(
regexp = "^(?:(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)|(?=.*[a-z])(?=.*[A-Z])(?=.*[^A-Za-z0-9])|(?=.*[a-z])(?=.*\\d)(?=.*[^A-Za-z0-9])|(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z0-9])).{8,32}$",
message = "密码需包含大小写字母、数字、特殊字符中的至少三类"
)
private String password;
@NotBlank(message = "昵称不能为空")
@@ -19,10 +19,11 @@ public class LoginLog {
private Integer loginType;
@TableField("ip_address")
private String ipAddress;
@TableField(exist = false)
private String userAgent;
@TableField("device_fingerprint")
private String deviceFingerprint;
@TableField("result")
private Integer status;
@@ -31,6 +31,10 @@ public class User {
private Integer status;
private Integer loginFailCount;
private LocalDateTime lockedUntil;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
@@ -1,6 +1,7 @@
package fun.nojava.module.system.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import fun.nojava.common.constant.SecurityConstants;
import fun.nojava.common.enums.UserStatus;
import fun.nojava.common.enums.UserType;
import fun.nojava.common.exception.BusinessException;
@@ -21,6 +22,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
@@ -28,6 +30,8 @@ import java.time.LocalDateTime;
@RequiredArgsConstructor
public class AuthServiceImpl implements AuthService {
private static final String PLACEHOLDER_CAPTCHA_CODE = "1234";
private final UserMapper userMapper;
private final LoginLogMapper loginLogMapper;
private final UserSessionMapper userSessionMapper;
@@ -38,6 +42,54 @@ public class AuthServiceImpl implements AuthService {
@Override
@Transactional
public LoginVO login(LoginDTO dto, String ipAddress, String userAgent) {
String loginAccount = dto.getEmail();
User user = userMapper.selectOne(
new LambdaQueryWrapper<User>()
.eq(User::getEmail, loginAccount)
.or()
.eq(User::getUsername, loginAccount)
.or()
.eq(User::getPhone, loginAccount)
);
if (user == null) {
recordLoginLog(null, loginAccount, ipAddress, resolveDeviceFingerprint(dto, userAgent), 0, 0, "用户不存在");
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
unlockExpiredLockIfNeeded(user);
if (Boolean.TRUE.equals(isLocked(user))) {
recordLoginLog(user.getId(), loginAccount, ipAddress, resolveDeviceFingerprint(dto, userAgent), 0, user.getType(), "账号已锁定");
throw new BusinessException(ErrorCode.ACCOUNT_LOCKED);
}
if (requiresCaptcha(user)) {
if (!hasCaptchaPayload(dto)) {
recordLoginLog(user.getId(), loginAccount, ipAddress, resolveDeviceFingerprint(dto, userAgent), 0, user.getType(), "需要验证码");
throw new BusinessException(ErrorCode.CAPTCHA_REQUIRED);
}
if (!isCaptchaValid(dto)) {
recordLoginLog(user.getId(), loginAccount, ipAddress, resolveDeviceFingerprint(dto, userAgent), 0, user.getType(), "验证码无效");
throw new BusinessException(ErrorCode.CAPTCHA_INVALID);
}
}
if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
onLoginPasswordFailed(user);
recordLoginLog(user.getId(), loginAccount, ipAddress, resolveDeviceFingerprint(dto, userAgent), 0, user.getType(), "密码错误");
throw new BusinessException(ErrorCode.PASSWORD_ERROR);
}
resetLoginFailState(user);
recordLoginLog(user.getId(), loginAccount, ipAddress, resolveDeviceFingerprint(dto, userAgent), 1, user.getType(), null);
return generateLoginVO(user, dto.getRememberMe());
}
@Override
@Transactional
public LoginVO adminLogin(LoginDTO dto, String ipAddress, String userAgent) {
User user = userMapper.selectOne(
new LambdaQueryWrapper<User>()
.eq(User::getEmail, dto.getEmail())
@@ -46,38 +98,16 @@ public class AuthServiceImpl implements AuthService {
.or()
.eq(User::getPhone, dto.getEmail())
);
if (user == null) {
recordLoginLog(null, dto.getEmail(), ipAddress, userAgent, 0, "用户不存在");
recordLoginLog(null, dto.getEmail(), ipAddress, resolveDeviceFingerprint(dto, userAgent), 0, UserType.ADMIN.getCode(), "用户不存在");
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
if (user.getStatus() == UserStatus.LOCKED.getCode()) {
recordLoginLog(user.getId(), dto.getEmail(), ipAddress, userAgent, 0, "账号已锁定");
throw new BusinessException(ErrorCode.ACCOUNT_LOCKED);
}
if (!passwordEncoder.matches(dto.getPassword(), user.getPassword())) {
recordLoginLog(user.getId(), dto.getEmail(), ipAddress, userAgent, 0, "密码错误");
throw new BusinessException(ErrorCode.PASSWORD_ERROR);
}
recordLoginLog(user.getId(), dto.getEmail(), ipAddress, userAgent, 1, null);
return generateLoginVO(user, dto.getRememberMe());
}
@Override
@Transactional
public LoginVO adminLogin(LoginDTO dto, String ipAddress, String userAgent) {
LoginVO loginVO = login(dto, ipAddress, userAgent);
User user = userMapper.selectById(loginVO.getUserId());
if (user.getType() != UserType.ADMIN.getCode()) {
recordLoginLog(user.getId(), dto.getEmail(), ipAddress, resolveDeviceFingerprint(dto, userAgent), 0, UserType.ADMIN.getCode(), "权限不足");
throw new BusinessException(ErrorCode.PERMISSION_DENIED);
}
return loginVO;
return login(dto, ipAddress, userAgent);
}
@Override
@@ -162,15 +192,93 @@ public class AuthServiceImpl implements AuthService {
.build();
}
private void recordLoginLog(Long userId, String email, String ip, String ua, int status, String reason) {
private void recordLoginLog(Long userId, String email, String ip, String deviceFingerprint, int status, int loginType, String reason) {
LoginLog log = new LoginLog();
log.setUserId(userId);
log.setEmail(email);
log.setLoginType(1);
log.setLoginType(loginType);
log.setIpAddress(ip);
log.setUserAgent(ua);
log.setDeviceFingerprint(deviceFingerprint);
log.setStatus(status);
log.setFailReason(reason);
loginLogMapper.insert(log);
}
private String resolveDeviceFingerprint(LoginDTO dto, String userAgent) {
if (StringUtils.hasText(dto.getDeviceFingerprint())) {
return dto.getDeviceFingerprint();
}
return userAgent;
}
private boolean requiresCaptcha(User user) {
Integer failCount = user.getLoginFailCount();
return failCount != null && failCount >= SecurityConstants.CAPTCHA_TRIGGER_COUNT;
}
private boolean hasCaptchaPayload(LoginDTO dto) {
return StringUtils.hasText(dto.getCaptchaKey()) && StringUtils.hasText(dto.getCaptchaCode());
}
private boolean isCaptchaValid(LoginDTO dto) {
return PLACEHOLDER_CAPTCHA_CODE.equalsIgnoreCase(dto.getCaptchaCode());
}
private void onLoginPasswordFailed(User user) {
int failCount = user.getLoginFailCount() == null ? 0 : user.getLoginFailCount();
failCount += 1;
user.setLoginFailCount(failCount);
int lockThreshold = SecurityConstants.MAX_LOGIN_FAIL_USER;
int lockMinutes = SecurityConstants.USER_LOCK_MINUTES;
if (Integer.valueOf(UserType.ADMIN.getCode()).equals(user.getType())) {
lockThreshold = SecurityConstants.MAX_LOGIN_FAIL_ADMIN;
lockMinutes = SecurityConstants.ADMIN_LOCK_MINUTES;
}
if (failCount >= lockThreshold) {
user.setStatus(UserStatus.LOCKED.getCode());
user.setLockedUntil(LocalDateTime.now().plusMinutes(lockMinutes));
}
userMapper.updateById(user);
}
private void resetLoginFailState(User user) {
boolean changed = false;
if (user.getLoginFailCount() != null && user.getLoginFailCount() > 0) {
user.setLoginFailCount(0);
changed = true;
}
if (user.getStatus() != null && user.getStatus() == UserStatus.LOCKED.getCode()) {
user.setStatus(UserStatus.NORMAL.getCode());
changed = true;
}
if (user.getLockedUntil() != null) {
user.setLockedUntil(null);
changed = true;
}
if (changed) {
userMapper.updateById(user);
}
}
private void unlockExpiredLockIfNeeded(User user) {
if (user.getStatus() == null || user.getStatus() != UserStatus.LOCKED.getCode()) {
return;
}
if (user.getLockedUntil() == null || user.getLockedUntil().isAfter(LocalDateTime.now())) {
return;
}
user.setStatus(UserStatus.NORMAL.getCode());
user.setLockedUntil(null);
user.setLoginFailCount(0);
userMapper.updateById(user);
}
private Boolean isLocked(User user) {
if (user.getStatus() == null || user.getStatus() != UserStatus.LOCKED.getCode()) {
return false;
}
return user.getLockedUntil() == null || user.getLockedUntil().isAfter(LocalDateTime.now());
}
}
@@ -0,0 +1,269 @@
package fun.nojava.module.system.service.impl;
import fun.nojava.common.enums.UserStatus;
import fun.nojava.common.enums.UserType;
import fun.nojava.common.exception.BusinessException;
import fun.nojava.common.exception.ErrorCode;
import fun.nojava.framework.security.JwtTokenProvider;
import fun.nojava.framework.security.TokenBlacklistService;
import fun.nojava.module.system.dto.LoginDTO;
import fun.nojava.module.system.dto.LoginVO;
import fun.nojava.module.system.dto.RegisterDTO;
import fun.nojava.module.system.entity.LoginLog;
import fun.nojava.module.system.entity.User;
import fun.nojava.module.system.entity.UserSession;
import fun.nojava.module.system.mapper.LoginLogMapper;
import fun.nojava.module.system.mapper.UserMapper;
import fun.nojava.module.system.mapper.UserSessionMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class AuthServiceImplP0P1Test {
@Mock
private UserMapper userMapper;
@Mock
private LoginLogMapper loginLogMapper;
@Mock
private UserSessionMapper userSessionMapper;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private JwtTokenProvider jwtTokenProvider;
@Mock
private TokenBlacklistService tokenBlacklistService;
@InjectMocks
private AuthServiceImpl authService;
@Test
@Tag("p0")
@DisplayName("TC-AC-USER-LOGIN-01: 用户密码正确时应登录成功并写入登录日志")
void shouldLoginSuccessfullyAndRecordSuccessLog() {
User user = buildUser(101L, UserType.USER.getCode(), UserStatus.NORMAL.getCode(), "u@test.dev");
LoginDTO dto = new LoginDTO();
dto.setEmail("u@test.dev");
dto.setPassword("User@123");
dto.setDeviceFingerprint("fp-101");
dto.setRememberMe(false);
when(userMapper.selectOne(any())).thenReturn(user);
when(passwordEncoder.matches("User@123", user.getPassword())).thenReturn(true);
when(jwtTokenProvider.generateAccessToken(101L, UserType.USER.getCode())).thenReturn("access-token");
when(jwtTokenProvider.generateRefreshToken(101L)).thenReturn("refresh-token");
LoginVO result = authService.login(dto, "127.0.0.1", "JUnit");
assertNotNull(result);
assertEquals(101L, result.getUserId());
assertEquals("access-token", result.getAccessToken());
assertEquals("refresh-token", result.getRefreshToken());
ArgumentCaptor<LoginLog> logCaptor = ArgumentCaptor.forClass(LoginLog.class);
verify(loginLogMapper, times(1)).insert(logCaptor.capture());
assertEquals(1, logCaptor.getValue().getStatus());
assertEquals("u@test.dev", logCaptor.getValue().getEmail());
assertEquals(UserType.USER.getCode(), logCaptor.getValue().getLoginType());
assertEquals("fp-101", logCaptor.getValue().getDeviceFingerprint());
verify(userSessionMapper, times(1)).insert(any(UserSession.class));
}
@Test
@Tag("p0")
@DisplayName("TC-AC-USER-LOGIN-03: 密码错误时应拒绝并写失败日志")
void shouldRejectWhenPasswordInvalidAndWriteFailLog() {
User user = buildUser(102L, UserType.USER.getCode(), UserStatus.NORMAL.getCode(), "invalid@test.dev");
LoginDTO dto = new LoginDTO();
dto.setEmail("invalid@test.dev");
dto.setPassword("Wrong@123");
when(userMapper.selectOne(any())).thenReturn(user);
when(passwordEncoder.matches("Wrong@123", user.getPassword())).thenReturn(false);
BusinessException ex = assertThrows(BusinessException.class, () ->
authService.login(dto, "127.0.0.1", "JUnit"));
assertEquals(ErrorCode.PASSWORD_ERROR.getCode(), ex.getCode());
ArgumentCaptor<LoginLog> logCaptor = ArgumentCaptor.forClass(LoginLog.class);
verify(loginLogMapper).insert(logCaptor.capture());
assertEquals(0, logCaptor.getValue().getStatus());
assertEquals("密码错误", logCaptor.getValue().getFailReason());
assertEquals(1, user.getLoginFailCount());
verify(userMapper).updateById(user);
verify(userSessionMapper, never()).insert(org.mockito.ArgumentMatchers.<UserSession>any());
}
@Test
@Tag("p0")
@DisplayName("TC-AC-USER-REG-02: 重复邮箱注册应被拒绝")
void shouldRejectDuplicateEmailRegister() {
RegisterDTO dto = new RegisterDTO();
dto.setEmail("dup@test.dev");
dto.setPassword("Dup@1234");
dto.setNickname("dup");
when(userMapper.selectCount(any())).thenReturn(1L);
BusinessException ex = assertThrows(BusinessException.class, () -> authService.register(dto));
assertEquals(ErrorCode.EMAIL_EXISTS.getCode(), ex.getCode());
verify(userMapper, never()).insert(org.mockito.ArgumentMatchers.<User>any());
}
@Test
@Tag("p0")
@DisplayName("TC-NF-SEC-01: 注册密码应经编码后再入库")
void shouldEncodePasswordOnRegister() {
RegisterDTO dto = new RegisterDTO();
dto.setEmail("new@test.dev");
dto.setPassword("Strong@123");
dto.setNickname("newbie");
when(userMapper.selectCount(any())).thenReturn(0L);
when(passwordEncoder.encode("Strong@123")).thenReturn("$2a$mocked");
authService.register(dto);
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
verify(userMapper).insert(userCaptor.capture());
assertEquals("$2a$mocked", userCaptor.getValue().getPassword());
assertEquals(UserType.USER.getCode(), userCaptor.getValue().getType());
}
@Test
@Tag("p1")
@DisplayName("TC-AC-ADM-LOGIN-01: 非管理员账号调用管理员登录应被拒绝")
void shouldRejectNonAdminOnAdminLogin() {
User user = buildUser(103L, UserType.USER.getCode(), UserStatus.NORMAL.getCode(), "normal@test.dev");
LoginDTO dto = new LoginDTO();
dto.setEmail("normal@test.dev");
dto.setPassword("User@123");
dto.setRememberMe(false);
when(userMapper.selectOne(any())).thenReturn(user);
BusinessException ex = assertThrows(BusinessException.class, () ->
authService.adminLogin(dto, "127.0.0.1", "JUnit"));
assertEquals(ErrorCode.PERMISSION_DENIED.getCode(), ex.getCode());
}
@Test
@Tag("p0")
@DisplayName("TC-AC-USER-LOGIN-06: 勾选 rememberMe 会话应接近14天")
void shouldCreateRememberMeSessionFor14Days() {
User user = buildUser(104L, UserType.USER.getCode(), UserStatus.NORMAL.getCode(), "remember@test.dev");
LoginDTO dto = new LoginDTO();
dto.setEmail("remember@test.dev");
dto.setPassword("User@123");
dto.setRememberMe(true);
when(userMapper.selectOne(any())).thenReturn(user);
when(passwordEncoder.matches("User@123", user.getPassword())).thenReturn(true);
when(jwtTokenProvider.generateAccessToken(104L, UserType.USER.getCode())).thenReturn("access-token");
when(jwtTokenProvider.generateRefreshToken(104L)).thenReturn("refresh-token");
LocalDateTime before = LocalDateTime.now();
authService.login(dto, "127.0.0.1", "JUnit");
LocalDateTime after = LocalDateTime.now();
ArgumentCaptor<UserSession> sessionCaptor = ArgumentCaptor.forClass(UserSession.class);
verify(userSessionMapper).insert(sessionCaptor.capture());
LocalDateTime expiresAt = sessionCaptor.getValue().getExpiresAt();
long secondsFromBefore = java.time.Duration.between(before, expiresAt).getSeconds();
long secondsFromAfter = java.time.Duration.between(after, expiresAt).getSeconds();
assertTrue(secondsFromBefore <= 1_209_600L + 2 && secondsFromBefore >= 1_209_600L - 2);
assertTrue(secondsFromAfter <= 1_209_600L + 2 && secondsFromAfter >= 1_209_600L - 3);
}
@Test
@Tag("p0")
@DisplayName("TC-AC-USER-LOGIN-03: 已锁定账号登录应被拒绝")
void shouldRejectLockedAccount() {
User user = buildUser(105L, UserType.USER.getCode(), UserStatus.LOCKED.getCode(), "locked@test.dev");
user.setLockedUntil(LocalDateTime.now().plusMinutes(10));
LoginDTO dto = new LoginDTO();
dto.setEmail("locked@test.dev");
dto.setPassword("User@123");
when(userMapper.selectOne(any())).thenReturn(user);
BusinessException ex = assertThrows(BusinessException.class, () ->
authService.login(dto, "127.0.0.1", "JUnit"));
assertEquals(ErrorCode.ACCOUNT_LOCKED.getCode(), ex.getCode());
}
@Test
@Tag("p0")
@DisplayName("TC-AC-ADM-LOGIN-02: 连续3次错密后第4次应强制验证码")
void shouldRequireCaptchaAfterThreeFailures() {
User user = buildUser(106L, UserType.ADMIN.getCode(), UserStatus.NORMAL.getCode(), "admin@test.dev");
user.setLoginFailCount(3);
LoginDTO dto = new LoginDTO();
dto.setEmail("admin@test.dev");
dto.setPassword("Admin@123");
when(userMapper.selectOne(any())).thenReturn(user);
BusinessException ex = assertThrows(BusinessException.class, () ->
authService.login(dto, "127.0.0.1", "JUnit"));
assertEquals(ErrorCode.CAPTCHA_REQUIRED.getCode(), ex.getCode());
}
@Test
@Tag("p0")
@DisplayName("TC-AC-ADM-LOGIN-03: 连续5次错误后应锁定账号")
void shouldLockAccountAfterFiveFailures() {
User user = buildUser(107L, UserType.ADMIN.getCode(), UserStatus.NORMAL.getCode(), "admin-lock@test.dev");
user.setLoginFailCount(4);
LoginDTO dto = new LoginDTO();
dto.setEmail("admin-lock@test.dev");
dto.setPassword("Wrong@123");
dto.setCaptchaKey("k1");
dto.setCaptchaCode("1234");
when(userMapper.selectOne(any())).thenReturn(user);
when(passwordEncoder.matches("Wrong@123", user.getPassword())).thenReturn(false);
BusinessException ex = assertThrows(BusinessException.class, () ->
authService.login(dto, "127.0.0.1", "JUnit"));
assertEquals(ErrorCode.PASSWORD_ERROR.getCode(), ex.getCode());
assertEquals(UserStatus.LOCKED.getCode(), user.getStatus());
assertNotNull(user.getLockedUntil());
long minutes = ChronoUnit.MINUTES.between(LocalDateTime.now(), user.getLockedUntil());
assertTrue(minutes >= 29 && minutes <= 30);
verify(userMapper).updateById(user);
}
private User buildUser(Long id, Integer type, Integer status, String email) {
User user = new User();
user.setId(id);
user.setType(type);
user.setStatus(status);
user.setEmail(email);
user.setPassword("$2a$encoded");
user.setNickname("tester");
user.setLoginFailCount(0);
return user;
}
}