修改网盘显示等细节,登陆验证更加严格,同时允许一台设备在线

This commit is contained in:
yoyuzh
2026-03-20 18:08:59 +08:00
parent 43358e29d7
commit f8ea5a6f85
37 changed files with 1541 additions and 100 deletions

View File

@@ -111,7 +111,8 @@ class AdminControllerIntegrationTest {
mockMvc.perform(get("/api/admin/summary"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.totalUsers").value(2))
.andExpect(jsonPath("$.data.totalFiles").value(2));
.andExpect(jsonPath("$.data.totalFiles").value(2))
.andExpect(jsonPath("$.data.inviteCode").isNotEmpty());
}
@Test

View File

@@ -46,7 +46,9 @@ class AuthControllerValidationTest {
"username": "alice",
"email": "alice@example.com",
"phoneNumber": "13800138000",
"password": "weakpass"
"password": "weakpass",
"confirmPassword": "weakpass",
"inviteCode": "invite-code"
}
"""))
.andExpect(status().isBadRequest())
@@ -63,7 +65,9 @@ class AuthControllerValidationTest {
"username": "alice",
"email": "alice@example.com",
"phoneNumber": "12345",
"password": "StrongPass1!"
"password": "StrongPass1!",
"confirmPassword": "StrongPass1!",
"inviteCode": "invite-code"
}
"""))
.andExpect(status().isBadRequest())
@@ -71,6 +75,44 @@ class AuthControllerValidationTest {
.andExpect(jsonPath("$.msg").value("请输入有效的11位手机号"));
}
@Test
void shouldReturnReadablePasswordConfirmationMessage() throws Exception {
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"username": "alice",
"email": "alice@example.com",
"phoneNumber": "13800138000",
"password": "StrongPass1!",
"confirmPassword": "StrongPass2!",
"inviteCode": "invite-code"
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(1000))
.andExpect(jsonPath("$.msg").value("两次输入的密码不一致"));
}
@Test
void shouldReturnReadableInviteCodeMessage() throws Exception {
mockMvc.perform(post("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"username": "alice",
"email": "alice@example.com",
"phoneNumber": "13800138000",
"password": "StrongPass1!",
"confirmPassword": "StrongPass1!",
"inviteCode": ""
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(1000))
.andExpect(jsonPath("$.msg").value("请输入邀请码"));
}
@Test
void shouldExposeRefreshEndpointContract() throws Exception {
AuthResponse response = AuthResponse.issued(

View File

@@ -0,0 +1,74 @@
package com.yoyuzh.auth;
import com.yoyuzh.PortalBackendApplication;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
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;
@SpringBootTest(
classes = PortalBackendApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:auth_invite_test;MODE=MySQL;DB_CLOSE_DELAY=-1",
"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.registration.invite-code=invite-code",
"app.storage.root-dir=./target/test-storage-auth-invite"
}
)
@AutoConfigureMockMvc
class AuthRegistrationInviteIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
void shouldRejectReusingInviteCodeAfterSuccessfulRegistration() throws Exception {
mockMvc.perform(post("/api/auth/register")
.contentType("application/json")
.content("""
{
"username": "alice",
"email": "alice@example.com",
"phoneNumber": "13800138000",
"password": "StrongPass1!",
"confirmPassword": "StrongPass1!",
"inviteCode": "invite-code"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
mockMvc.perform(post("/api/auth/register")
.contentType("application/json")
.content("""
{
"username": "bob",
"email": "bob@example.com",
"phoneNumber": "13900139000",
"password": "StrongPass1!",
"confirmPassword": "StrongPass1!",
"inviteCode": "invite-code"
}
"""))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.msg").value("邀请码错误"));
}
}

View File

@@ -29,6 +29,7 @@ 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.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -57,12 +58,22 @@ class AuthServiceTest {
@Mock
private FileContentStorage fileContentStorage;
@Mock
private RegistrationInviteService registrationInviteService;
@InjectMocks
private AuthService authService;
@Test
void shouldRegisterUserWithEncryptedPassword() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!");
RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"StrongPass1!",
"StrongPass1!",
"invite-code"
);
when(userRepository.existsByUsername("alice")).thenReturn(false);
when(userRepository.existsByEmail("alice@example.com")).thenReturn(false);
when(userRepository.existsByPhoneNumber("13800138000")).thenReturn(false);
@@ -73,7 +84,7 @@ class AuthServiceTest {
user.setCreatedAt(LocalDateTime.now());
return user;
});
when(jwtTokenProvider.generateAccessToken(1L, "alice")).thenReturn("access-token");
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("access-token");
when(refreshTokenService.issueRefreshToken(any(User.class))).thenReturn("refresh-token");
AuthResponse response = authService.register(request);
@@ -83,13 +94,21 @@ class AuthServiceTest {
assertThat(response.refreshToken()).isEqualTo("refresh-token");
assertThat(response.user().username()).isEqualTo("alice");
assertThat(response.user().phoneNumber()).isEqualTo("13800138000");
verify(registrationInviteService).consumeInviteCode("invite-code");
verify(passwordEncoder).encode("StrongPass1!");
verify(fileService).ensureDefaultDirectories(any(User.class));
}
@Test
void shouldRejectDuplicateUsernameOnRegister() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!");
RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"StrongPass1!",
"StrongPass1!",
"invite-code"
);
when(userRepository.existsByUsername("alice")).thenReturn(true);
assertThatThrownBy(() -> authService.register(request))
@@ -99,7 +118,14 @@ class AuthServiceTest {
@Test
void shouldRejectDuplicatePhoneNumberOnRegister() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!");
RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"StrongPass1!",
"StrongPass1!",
"invite-code"
);
when(userRepository.existsByUsername("alice")).thenReturn(false);
when(userRepository.existsByEmail("alice@example.com")).thenReturn(false);
when(userRepository.existsByPhoneNumber("13800138000")).thenReturn(true);
@@ -109,6 +135,26 @@ class AuthServiceTest {
.hasMessageContaining("手机号已存在");
}
@Test
void shouldRejectInvalidInviteCodeOnRegister() {
RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"StrongPass1!",
"StrongPass1!",
"wrong-code"
);
var invalidInviteCode = new BusinessException(com.yoyuzh.common.ErrorCode.PERMISSION_DENIED, "邀请码错误");
org.mockito.Mockito.doThrow(invalidInviteCode)
.when(registrationInviteService)
.consumeInviteCode("wrong-code");
assertThatThrownBy(() -> authService.register(request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("邀请码错误");
}
@Test
void shouldLoginAndReturnToken() {
LoginRequest request = new LoginRequest("alice", "plain-password");
@@ -119,7 +165,8 @@ class AuthServiceTest {
user.setPasswordHash("encoded-password");
user.setCreatedAt(LocalDateTime.now());
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
when(jwtTokenProvider.generateAccessToken(1L, "alice")).thenReturn("access-token");
when(userRepository.save(user)).thenReturn(user);
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("access-token");
when(refreshTokenService.issueRefreshToken(user)).thenReturn("refresh-token");
AuthResponse response = authService.login(request);
@@ -142,7 +189,8 @@ class AuthServiceTest {
user.setCreatedAt(LocalDateTime.now());
when(refreshTokenService.rotateRefreshToken("old-refresh"))
.thenReturn(new RefreshTokenService.RotatedRefreshToken(user, "new-refresh"));
when(jwtTokenProvider.generateAccessToken(1L, "alice")).thenReturn("new-access");
when(userRepository.save(user)).thenReturn(user);
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("new-access");
AuthResponse response = authService.refresh("old-refresh");
@@ -184,7 +232,7 @@ class AuthServiceTest {
user.setCreatedAt(LocalDateTime.now());
return user;
});
when(jwtTokenProvider.generateAccessToken(9L, "demo")).thenReturn("access-token");
when(jwtTokenProvider.generateAccessToken(eq(9L), eq("demo"), anyString())).thenReturn("access-token");
when(refreshTokenService.issueRefreshToken(any(User.class))).thenReturn("refresh-token");
AuthResponse response = authService.devLogin("demo");
@@ -248,7 +296,7 @@ class AuthServiceTest {
when(passwordEncoder.matches("OldPass1!", "encoded-old")).thenReturn(true);
when(passwordEncoder.encode("NewPass1!A")).thenReturn("encoded-new");
when(userRepository.save(user)).thenReturn(user);
when(jwtTokenProvider.generateAccessToken(1L, "alice")).thenReturn("new-access");
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("new-access");
when(refreshTokenService.issueRefreshToken(user)).thenReturn("new-refresh");
AuthResponse response = authService.changePassword("alice", request);

View File

@@ -0,0 +1,100 @@
package com.yoyuzh.auth;
import com.jayway.jsonpath.JsonPath;
import com.yoyuzh.PortalBackendApplication;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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;
@SpringBootTest(
classes = PortalBackendApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:auth_single_device_test;MODE=MySQL;DB_CLOSE_DELAY=-1",
"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-auth-single-device"
}
)
@AutoConfigureMockMvc
class AuthSingleDeviceIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
void shouldInvalidatePreviousAccessTokenAfterLoggingInAgain() throws Exception {
User user = new User();
user.setUsername("alice");
user.setDisplayName("Alice");
user.setEmail("alice@example.com");
user.setPhoneNumber("13800138000");
user.setPasswordHash(passwordEncoder.encode("StrongPass1!"));
user.setPreferredLanguage("zh-CN");
user.setRole(UserRole.USER);
user.setCreatedAt(LocalDateTime.now());
userRepository.save(user);
String loginRequest = """
{
"username": "alice",
"password": "StrongPass1!"
}
""";
String firstLoginResponse = mockMvc.perform(post("/api/auth/login")
.contentType("application/json")
.content(loginRequest))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
.andReturn()
.getResponse()
.getContentAsString();
String secondLoginResponse = mockMvc.perform(post("/api/auth/login")
.contentType("application/json")
.content(loginRequest))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
.andReturn()
.getResponse()
.getContentAsString();
String firstAccessToken = JsonPath.read(firstLoginResponse, "$.data.accessToken");
String secondAccessToken = JsonPath.read(secondLoginResponse, "$.data.accessToken");
mockMvc.perform(get("/api/user/profile")
.header("Authorization", "Bearer " + firstAccessToken))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(1001));
mockMvc.perform(get("/api/user/profile")
.header("Authorization", "Bearer " + secondAccessToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.username").value("alice"));
}
}

View File

@@ -59,7 +59,7 @@ class JwtTokenProviderTest {
JwtTokenProvider provider = new JwtTokenProvider(properties);
provider.init();
String token = provider.generateAccessToken(7L, "alice");
String token = provider.generateAccessToken(7L, "alice", "session-1");
SecretKey secretKey = Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8));
Instant expiration = Jwts.parser().verifyWith(secretKey).build()
.parseSignedClaims(token)
@@ -70,6 +70,9 @@ class JwtTokenProviderTest {
assertThat(provider.validateToken(token)).isTrue();
assertThat(provider.getUsername(token)).isEqualTo("alice");
assertThat(provider.getUserId(token)).isEqualTo(7L);
assertThat(provider.getSessionId(token)).isEqualTo("session-1");
assertThat(provider.hasMatchingSession(token, "session-1")).isTrue();
assertThat(provider.hasMatchingSession(token, "session-2")).isFalse();
assertThat(expiration).isAfter(Instant.now().plusSeconds(850));
assertThat(expiration).isBefore(Instant.now().plusSeconds(950));
}

View File

@@ -13,7 +13,14 @@ class RegisterRequestValidationTest {
@Test
void shouldRejectWeakPassword() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "weakpass");
RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"weakpass",
"weakpass",
"invite-code"
);
var violations = validator.validate(request);
@@ -24,7 +31,14 @@ class RegisterRequestValidationTest {
@Test
void shouldAcceptStrongPassword() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "13800138000", "StrongPass1!");
RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"StrongPass1!",
"StrongPass1!",
"invite-code"
);
var violations = validator.validate(request);
@@ -33,7 +47,14 @@ class RegisterRequestValidationTest {
@Test
void shouldRejectInvalidPhoneNumber() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "12345", "StrongPass1!");
RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"12345",
"StrongPass1!",
"StrongPass1!",
"invite-code"
);
var violations = validator.validate(request);
@@ -41,4 +62,40 @@ class RegisterRequestValidationTest {
.extracting(violation -> violation.getMessage())
.contains("请输入有效的11位手机号");
}
@Test
void shouldRejectMismatchedPasswordConfirmation() {
RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"StrongPass1!",
"StrongPass2!",
"invite-code"
);
var violations = validator.validate(request);
assertThat(violations)
.extracting(violation -> violation.getMessage())
.contains("两次输入的密码不一致");
}
@Test
void shouldRejectBlankInviteCode() {
RegisterRequest request = new RegisterRequest(
"alice",
"alice@example.com",
"13800138000",
"StrongPass1!",
"StrongPass1!",
""
);
var violations = validator.validate(request);
assertThat(violations)
.extracting(violation -> violation.getMessage())
.contains("请输入邀请码");
}
}