feat(auth): harden token lifecycle and password policy
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yoyuzh.auth.dto.AuthResponse;
|
||||
import com.yoyuzh.auth.dto.UserProfileResponse;
|
||||
import com.yoyuzh.common.GlobalExceptionHandler;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AuthControllerValidationTest {
|
||||
|
||||
@Mock
|
||||
private AuthService authService;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(new AuthController(authService))
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnReadablePasswordValidationMessage() throws Exception {
|
||||
mockMvc.perform(post("/api/auth/register")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"username": "alice",
|
||||
"email": "alice@example.com",
|
||||
"password": "weakpass"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(1000))
|
||||
.andExpect(jsonPath("$.msg").value("密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldExposeRefreshEndpointContract() throws Exception {
|
||||
AuthResponse response = AuthResponse.issued(
|
||||
"new-access-token",
|
||||
"new-refresh-token",
|
||||
new UserProfileResponse(7L, "alice", "alice@example.com", LocalDateTime.now())
|
||||
);
|
||||
when(authService.refresh("refresh-1")).thenReturn(response);
|
||||
|
||||
mockMvc.perform(post("/api/auth/refresh")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(new Object() {
|
||||
public final String refreshToken = "refresh-1";
|
||||
})))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.token").value("new-access-token"))
|
||||
.andExpect(jsonPath("$.data.accessToken").value("new-access-token"))
|
||||
.andExpect(jsonPath("$.data.refreshToken").value("new-refresh-token"))
|
||||
.andExpect(jsonPath("$.data.user.username").value("alice"));
|
||||
|
||||
verify(authService).refresh("refresh-1");
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,9 @@ class AuthServiceTest {
|
||||
@Mock
|
||||
private JwtTokenProvider jwtTokenProvider;
|
||||
|
||||
@Mock
|
||||
private RefreshTokenService refreshTokenService;
|
||||
|
||||
@Mock
|
||||
private FileService fileService;
|
||||
|
||||
@@ -47,29 +50,32 @@ class AuthServiceTest {
|
||||
|
||||
@Test
|
||||
void shouldRegisterUserWithEncryptedPassword() {
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "plain-password");
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "StrongPass1!");
|
||||
when(userRepository.existsByUsername("alice")).thenReturn(false);
|
||||
when(userRepository.existsByEmail("alice@example.com")).thenReturn(false);
|
||||
when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password");
|
||||
when(passwordEncoder.encode("StrongPass1!")).thenReturn("encoded-password");
|
||||
when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
|
||||
User user = invocation.getArgument(0);
|
||||
user.setId(1L);
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
return user;
|
||||
});
|
||||
when(jwtTokenProvider.generateToken(1L, "alice")).thenReturn("jwt-token");
|
||||
when(jwtTokenProvider.generateAccessToken(1L, "alice")).thenReturn("access-token");
|
||||
when(refreshTokenService.issueRefreshToken(any(User.class))).thenReturn("refresh-token");
|
||||
|
||||
AuthResponse response = authService.register(request);
|
||||
|
||||
assertThat(response.token()).isEqualTo("jwt-token");
|
||||
assertThat(response.token()).isEqualTo("access-token");
|
||||
assertThat(response.accessToken()).isEqualTo("access-token");
|
||||
assertThat(response.refreshToken()).isEqualTo("refresh-token");
|
||||
assertThat(response.user().username()).isEqualTo("alice");
|
||||
verify(passwordEncoder).encode("plain-password");
|
||||
verify(passwordEncoder).encode("StrongPass1!");
|
||||
verify(fileService).ensureDefaultDirectories(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectDuplicateUsernameOnRegister() {
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "plain-password");
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "StrongPass1!");
|
||||
when(userRepository.existsByUsername("alice")).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> authService.register(request))
|
||||
@@ -87,17 +93,39 @@ class AuthServiceTest {
|
||||
user.setPasswordHash("encoded-password");
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
|
||||
when(jwtTokenProvider.generateToken(1L, "alice")).thenReturn("jwt-token");
|
||||
when(jwtTokenProvider.generateAccessToken(1L, "alice")).thenReturn("access-token");
|
||||
when(refreshTokenService.issueRefreshToken(user)).thenReturn("refresh-token");
|
||||
|
||||
AuthResponse response = authService.login(request);
|
||||
|
||||
verify(authenticationManager).authenticate(
|
||||
new UsernamePasswordAuthenticationToken("alice", "plain-password"));
|
||||
assertThat(response.token()).isEqualTo("jwt-token");
|
||||
assertThat(response.token()).isEqualTo("access-token");
|
||||
assertThat(response.accessToken()).isEqualTo("access-token");
|
||||
assertThat(response.refreshToken()).isEqualTo("refresh-token");
|
||||
assertThat(response.user().email()).isEqualTo("alice@example.com");
|
||||
verify(fileService).ensureDefaultDirectories(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRotateRefreshTokenAndReturnNewCredentials() {
|
||||
User user = new User();
|
||||
user.setId(1L);
|
||||
user.setUsername("alice");
|
||||
user.setEmail("alice@example.com");
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
when(refreshTokenService.rotateRefreshToken("old-refresh"))
|
||||
.thenReturn(new RefreshTokenService.RotatedRefreshToken(user, "new-refresh"));
|
||||
when(jwtTokenProvider.generateAccessToken(1L, "alice")).thenReturn("new-access");
|
||||
|
||||
AuthResponse response = authService.refresh("old-refresh");
|
||||
|
||||
assertThat(response.token()).isEqualTo("new-access");
|
||||
assertThat(response.accessToken()).isEqualTo("new-access");
|
||||
assertThat(response.refreshToken()).isEqualTo("new-refresh");
|
||||
assertThat(response.user().username()).isEqualTo("alice");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowBusinessExceptionWhenAuthenticationFails() {
|
||||
LoginRequest request = new LoginRequest("alice", "wrong-password");
|
||||
@@ -119,11 +147,14 @@ class AuthServiceTest {
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
return user;
|
||||
});
|
||||
when(jwtTokenProvider.generateToken(9L, "demo")).thenReturn("jwt-token");
|
||||
when(jwtTokenProvider.generateAccessToken(9L, "demo")).thenReturn("access-token");
|
||||
when(refreshTokenService.issueRefreshToken(any(User.class))).thenReturn("refresh-token");
|
||||
|
||||
AuthResponse response = authService.devLogin("demo");
|
||||
|
||||
assertThat(response.user().username()).isEqualTo("demo");
|
||||
assertThat(response.accessToken()).isEqualTo("access-token");
|
||||
assertThat(response.refreshToken()).isEqualTo("refresh-token");
|
||||
verify(fileService).ensureDefaultDirectories(any(User.class));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import com.yoyuzh.config.JwtProperties;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class JwtTokenProviderTest {
|
||||
|
||||
@Test
|
||||
void shouldRejectEmptyJwtSecret() {
|
||||
JwtProperties properties = new JwtProperties();
|
||||
properties.setSecret(" ");
|
||||
|
||||
JwtTokenProvider provider = new JwtTokenProvider(properties);
|
||||
|
||||
assertThatThrownBy(provider::init)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("未配置");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectDefaultJwtSecret() {
|
||||
JwtProperties properties = new JwtProperties();
|
||||
properties.setSecret("change-me-change-me-change-me-change-me");
|
||||
|
||||
JwtTokenProvider provider = new JwtTokenProvider(properties);
|
||||
|
||||
assertThatThrownBy(provider::init)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("默认 JWT 密钥");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectTooShortJwtSecret() {
|
||||
JwtProperties properties = new JwtProperties();
|
||||
properties.setSecret("too-short-secret");
|
||||
|
||||
JwtTokenProvider provider = new JwtTokenProvider(properties);
|
||||
|
||||
assertThatThrownBy(provider::init)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("至少需要 32 字节");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldGenerateShortLivedAccessToken() {
|
||||
JwtProperties properties = new JwtProperties();
|
||||
properties.setSecret("0123456789abcdef0123456789abcdef");
|
||||
properties.setAccessExpirationSeconds(900);
|
||||
|
||||
JwtTokenProvider provider = new JwtTokenProvider(properties);
|
||||
provider.init();
|
||||
|
||||
String token = provider.generateAccessToken(7L, "alice");
|
||||
SecretKey secretKey = Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8));
|
||||
Instant expiration = Jwts.parser().verifyWith(secretKey).build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload()
|
||||
.getExpiration()
|
||||
.toInstant();
|
||||
|
||||
assertThat(provider.validateToken(token)).isTrue();
|
||||
assertThat(provider.getUsername(token)).isEqualTo("alice");
|
||||
assertThat(provider.getUserId(token)).isEqualTo(7L);
|
||||
assertThat(expiration).isAfter(Instant.now().plusSeconds(850));
|
||||
assertThat(expiration).isBefore(Instant.now().plusSeconds(950));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import com.yoyuzh.PortalBackendApplication;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = PortalBackendApplication.class,
|
||||
properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:refresh_token_test;MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000",
|
||||
"spring.datasource.driver-class-name=org.h2.Driver",
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||
"app.jwt.secret=0123456789abcdef0123456789abcdef",
|
||||
"app.storage.root-dir=./target/test-storage-refresh",
|
||||
"app.cqu.require-login=true",
|
||||
"app.cqu.mock-enabled=false"
|
||||
}
|
||||
)
|
||||
class RefreshTokenServiceIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private RefreshTokenService refreshTokenService;
|
||||
|
||||
@Autowired
|
||||
private RefreshTokenRepository refreshTokenRepository;
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
refreshTokenRepository.deleteAll();
|
||||
userRepository.deleteAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectRefreshTokenReuseAfterRotation() {
|
||||
User user = createUser("alice");
|
||||
|
||||
String rawToken = refreshTokenService.issueRefreshToken(user);
|
||||
RefreshTokenService.RotatedRefreshToken rotated = refreshTokenService.rotateRefreshToken(rawToken);
|
||||
|
||||
assertThat(rotated.refreshToken()).isNotBlank().isNotEqualTo(rawToken);
|
||||
assertThatThrownBy(() -> refreshTokenService.rotateRefreshToken(rawToken))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("无效或已使用");
|
||||
assertThat(refreshTokenRepository.findAll())
|
||||
.hasSize(2)
|
||||
.filteredOn(RefreshToken::isRevoked)
|
||||
.hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldStoreRefreshTokenAsHashInsteadOfPlaintext() {
|
||||
User user = createUser("hash-check");
|
||||
|
||||
String rawToken = refreshTokenService.issueRefreshToken(user);
|
||||
|
||||
assertThat(refreshTokenRepository.findAll())
|
||||
.singleElement()
|
||||
.satisfies(token -> {
|
||||
assertThat(token.getTokenHash()).hasSize(64);
|
||||
assertThat(token.getTokenHash()).isNotEqualTo(rawToken);
|
||||
assertThat(token.getTokenHash()).doesNotContain(rawToken.substring(0, 8));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectExpiredRefreshTokenAndRevokeIt() {
|
||||
User user = createUser("expired");
|
||||
String rawToken = refreshTokenService.issueRefreshToken(user);
|
||||
RefreshToken storedToken = refreshTokenRepository.findAll().get(0);
|
||||
storedToken.setExpiresAt(LocalDateTime.now().minusSeconds(1));
|
||||
refreshTokenRepository.save(storedToken);
|
||||
|
||||
assertThatThrownBy(() -> refreshTokenService.rotateRefreshToken(rawToken))
|
||||
.isInstanceOf(BusinessException.class)
|
||||
.hasMessageContaining("刷新令牌已过期");
|
||||
assertThat(refreshTokenRepository.findById(storedToken.getId()))
|
||||
.get()
|
||||
.extracting(RefreshToken::isRevoked)
|
||||
.isEqualTo(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAllowConcurrentRefreshTokenConsumptionOnlyOnce() throws Exception {
|
||||
User user = createUser("bob");
|
||||
String rawToken = refreshTokenService.issueRefreshToken(user);
|
||||
ExecutorService executorService = Executors.newFixedThreadPool(2);
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
|
||||
try {
|
||||
List<Future<Object>> futures = new ArrayList<>();
|
||||
for (int i = 0; i < 2; i += 1) {
|
||||
futures.add(executorService.submit(() -> {
|
||||
ready.countDown();
|
||||
start.await(5, TimeUnit.SECONDS);
|
||||
try {
|
||||
return refreshTokenService.rotateRefreshToken(rawToken);
|
||||
} catch (BusinessException ex) {
|
||||
return ex;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
start.countDown();
|
||||
|
||||
List<Object> results = new ArrayList<>();
|
||||
for (Future<Object> future : futures) {
|
||||
results.add(future.get(5, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
assertThat(results)
|
||||
.filteredOn(result -> result instanceof RefreshTokenService.RotatedRefreshToken)
|
||||
.hasSize(1);
|
||||
assertThat(results)
|
||||
.filteredOn(result -> result instanceof BusinessException)
|
||||
.singleElement()
|
||||
.extracting(result -> ((BusinessException) result).getMessage())
|
||||
.isEqualTo("刷新令牌无效或已使用");
|
||||
assertThat(refreshTokenRepository.findAll())
|
||||
.hasSize(2)
|
||||
.filteredOn(token -> !token.isRevoked())
|
||||
.hasSize(1);
|
||||
} finally {
|
||||
executorService.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private User createUser(String username) {
|
||||
User user = new User();
|
||||
user.setUsername(username);
|
||||
user.setEmail(username + "@example.com");
|
||||
user.setPasswordHash("encoded-password");
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
return userRepository.save(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import com.yoyuzh.auth.dto.RegisterRequest;
|
||||
import jakarta.validation.Validation;
|
||||
import jakarta.validation.Validator;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class RegisterRequestValidationTest {
|
||||
|
||||
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||
|
||||
@Test
|
||||
void shouldRejectWeakPassword() {
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "weakpass");
|
||||
|
||||
var violations = validator.validate(request);
|
||||
|
||||
assertThat(violations)
|
||||
.extracting(violation -> violation.getMessage())
|
||||
.contains("密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptStrongPassword() {
|
||||
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "StrongPass1!");
|
||||
|
||||
var violations = validator.validate(request);
|
||||
|
||||
assertThat(violations).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import static org.mockito.Mockito.when;
|
||||
"spring.datasource.username=sa",
|
||||
"spring.datasource.password=",
|
||||
"spring.jpa.hibernate.ddl-auto=create-drop",
|
||||
"app.jwt.secret=0123456789abcdef0123456789abcdef",
|
||||
"app.cqu.require-login=true",
|
||||
"app.cqu.mock-enabled=false"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user