修改网盘显示等细节,登陆验证更加严格,同时允许一台设备在线
This commit is contained in:
@@ -4,6 +4,7 @@ import com.yoyuzh.config.AdminProperties;
|
||||
import com.yoyuzh.config.CorsProperties;
|
||||
import com.yoyuzh.config.FileStorageProperties;
|
||||
import com.yoyuzh.config.JwtProperties;
|
||||
import com.yoyuzh.config.RegistrationProperties;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
@@ -13,7 +14,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
JwtProperties.class,
|
||||
FileStorageProperties.class,
|
||||
CorsProperties.class,
|
||||
AdminProperties.class
|
||||
AdminProperties.class,
|
||||
RegistrationProperties.class
|
||||
})
|
||||
public class PortalBackendApplication {
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.yoyuzh.admin;
|
||||
|
||||
import com.yoyuzh.auth.PasswordPolicy;
|
||||
import com.yoyuzh.auth.RegistrationInviteService;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.auth.UserRole;
|
||||
import com.yoyuzh.auth.UserRepository;
|
||||
@@ -21,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -31,12 +33,14 @@ public class AdminService {
|
||||
private final FileService fileService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final RegistrationInviteService registrationInviteService;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
public AdminSummaryResponse getSummary() {
|
||||
return new AdminSummaryResponse(
|
||||
userRepository.count(),
|
||||
storedFileRepository.count()
|
||||
storedFileRepository.count(),
|
||||
registrationInviteService.getCurrentInviteCode()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,6 +86,7 @@ public class AdminService {
|
||||
public AdminUserResponse updateUserBanned(Long userId, boolean banned) {
|
||||
User user = getRequiredUser(userId);
|
||||
user.setBanned(banned);
|
||||
user.setActiveSessionId(UUID.randomUUID().toString());
|
||||
refreshTokenService.revokeAllForUser(user.getId());
|
||||
return toUserResponse(userRepository.save(user));
|
||||
}
|
||||
@@ -93,6 +98,7 @@ public class AdminService {
|
||||
}
|
||||
User user = getRequiredUser(userId);
|
||||
user.setPasswordHash(passwordEncoder.encode(newPassword));
|
||||
user.setActiveSessionId(UUID.randomUUID().toString());
|
||||
refreshTokenService.revokeAllForUser(user.getId());
|
||||
return toUserResponse(userRepository.save(user));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.yoyuzh.admin;
|
||||
|
||||
public record AdminSummaryResponse(
|
||||
long totalUsers,
|
||||
long totalFiles
|
||||
long totalFiles,
|
||||
String inviteCode
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ public class AuthService {
|
||||
private final RefreshTokenService refreshTokenService;
|
||||
private final FileService fileService;
|
||||
private final FileContentStorage fileContentStorage;
|
||||
private final RegistrationInviteService registrationInviteService;
|
||||
|
||||
@Transactional
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
@@ -59,6 +60,8 @@ public class AuthService {
|
||||
throw new BusinessException(ErrorCode.UNKNOWN, "手机号已存在");
|
||||
}
|
||||
|
||||
registrationInviteService.consumeInviteCode(request.inviteCode());
|
||||
|
||||
User user = new User();
|
||||
user.setUsername(request.username());
|
||||
user.setDisplayName(request.username());
|
||||
@@ -69,9 +72,10 @@ public class AuthService {
|
||||
user.setPreferredLanguage("zh-CN");
|
||||
User saved = userRepository.save(user);
|
||||
fileService.ensureDefaultDirectories(saved);
|
||||
return issueTokens(saved);
|
||||
return issueFreshTokens(saved);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public AuthResponse login(LoginRequest request) {
|
||||
try {
|
||||
authenticationManager.authenticate(
|
||||
@@ -85,7 +89,7 @@ public class AuthService {
|
||||
User user = userRepository.findByUsername(request.username())
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
|
||||
fileService.ensureDefaultDirectories(user);
|
||||
return issueTokens(user);
|
||||
return issueFreshTokens(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -107,7 +111,7 @@ public class AuthService {
|
||||
return userRepository.save(created);
|
||||
});
|
||||
fileService.ensureDefaultDirectories(user);
|
||||
return issueTokens(user);
|
||||
return issueFreshTokens(user);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -154,9 +158,7 @@ public class AuthService {
|
||||
}
|
||||
|
||||
user.setPasswordHash(passwordEncoder.encode(request.newPassword()));
|
||||
userRepository.save(user);
|
||||
refreshTokenService.revokeAllForUser(user.getId());
|
||||
return issueTokens(user);
|
||||
return issueFreshTokens(user);
|
||||
}
|
||||
|
||||
public InitiateUploadResponse initiateAvatarUpload(String username, UpdateUserAvatarRequest request) {
|
||||
@@ -263,13 +265,24 @@ public class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
private AuthResponse issueTokens(User user) {
|
||||
private AuthResponse issueFreshTokens(User user) {
|
||||
refreshTokenService.revokeAllForUser(user.getId());
|
||||
return issueTokens(user, refreshTokenService.issueRefreshToken(user));
|
||||
}
|
||||
|
||||
private AuthResponse issueTokens(User user, String refreshToken) {
|
||||
String accessToken = jwtTokenProvider.generateAccessToken(user.getId(), user.getUsername());
|
||||
return AuthResponse.issued(accessToken, refreshToken, toProfile(user));
|
||||
User sessionUser = rotateActiveSession(user);
|
||||
String accessToken = jwtTokenProvider.generateAccessToken(
|
||||
sessionUser.getId(),
|
||||
sessionUser.getUsername(),
|
||||
sessionUser.getActiveSessionId()
|
||||
);
|
||||
return AuthResponse.issued(accessToken, refreshToken, toProfile(sessionUser));
|
||||
}
|
||||
|
||||
private User rotateActiveSession(User user) {
|
||||
user.setActiveSessionId(UUID.randomUUID().toString());
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
private String normalizeOptionalText(String value) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Component
|
||||
public class JwtTokenProvider {
|
||||
@@ -39,15 +40,20 @@ public class JwtTokenProvider {
|
||||
secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public String generateAccessToken(Long userId, String username) {
|
||||
public String generateAccessToken(Long userId, String username, String sessionId) {
|
||||
Instant now = Instant.now();
|
||||
return Jwts.builder()
|
||||
var builder = Jwts.builder()
|
||||
.subject(username)
|
||||
.claim("uid", userId)
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plusSeconds(jwtProperties.getAccessExpirationSeconds())))
|
||||
.signWith(secretKey)
|
||||
.compact();
|
||||
.signWith(secretKey);
|
||||
|
||||
if (StringUtils.hasText(sessionId)) {
|
||||
builder.claim("sid", sessionId);
|
||||
}
|
||||
|
||||
return builder.compact();
|
||||
}
|
||||
|
||||
public boolean validateToken(String token) {
|
||||
@@ -68,6 +74,21 @@ public class JwtTokenProvider {
|
||||
return uid == null ? null : Long.parseLong(uid.toString());
|
||||
}
|
||||
|
||||
public String getSessionId(String token) {
|
||||
Object sessionId = parseClaims(token).get("sid");
|
||||
return sessionId == null ? null : sessionId.toString();
|
||||
}
|
||||
|
||||
public boolean hasMatchingSession(String token, String activeSessionId) {
|
||||
String tokenSessionId = getSessionId(token);
|
||||
|
||||
if (!StringUtils.hasText(activeSessionId)) {
|
||||
return !StringUtils.hasText(tokenSessionId);
|
||||
}
|
||||
|
||||
return activeSessionId.equals(tokenSessionId);
|
||||
}
|
||||
|
||||
private Claims parseClaims(String token) {
|
||||
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload();
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ public class RefreshTokenService {
|
||||
|
||||
User user = existing.getUser();
|
||||
existing.revoke(LocalDateTime.now());
|
||||
revokeAllForUser(user.getId());
|
||||
|
||||
String nextRefreshToken = issueRefreshToken(user);
|
||||
return new RotatedRefreshToken(user, nextRefreshToken);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import com.yoyuzh.common.ErrorCode;
|
||||
import com.yoyuzh.config.RegistrationProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RegistrationInviteService {
|
||||
|
||||
private static final Long STATE_ID = 1L;
|
||||
private static final String INVITE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
|
||||
private static final int INVITE_LENGTH = 16;
|
||||
|
||||
private final RegistrationInviteStateRepository registrationInviteStateRepository;
|
||||
private final RegistrationProperties registrationProperties;
|
||||
private final SecureRandom secureRandom = new SecureRandom();
|
||||
|
||||
@Transactional
|
||||
public String getCurrentInviteCode() {
|
||||
return ensureCurrentState().getInviteCode();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void consumeInviteCode(String inviteCode) {
|
||||
RegistrationInviteState state = ensureCurrentStateForUpdate();
|
||||
String candidateCode = normalize(inviteCode);
|
||||
if (!state.getInviteCode().equals(candidateCode)) {
|
||||
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "邀请码错误");
|
||||
}
|
||||
|
||||
state.setInviteCode(generateNextInviteCode(state.getInviteCode()));
|
||||
registrationInviteStateRepository.save(state);
|
||||
}
|
||||
|
||||
private RegistrationInviteState ensureCurrentState() {
|
||||
return registrationInviteStateRepository.findById(STATE_ID)
|
||||
.orElseGet(this::createInitialState);
|
||||
}
|
||||
|
||||
private RegistrationInviteState ensureCurrentStateForUpdate() {
|
||||
return registrationInviteStateRepository.findByIdForUpdate(STATE_ID)
|
||||
.orElseGet(() -> {
|
||||
createInitialState();
|
||||
return registrationInviteStateRepository.findByIdForUpdate(STATE_ID)
|
||||
.orElseThrow(() -> new IllegalStateException("邀请码状态初始化失败"));
|
||||
});
|
||||
}
|
||||
|
||||
private RegistrationInviteState createInitialState() {
|
||||
RegistrationInviteState state = new RegistrationInviteState();
|
||||
state.setId(STATE_ID);
|
||||
state.setInviteCode(resolveInitialInviteCode());
|
||||
try {
|
||||
return registrationInviteStateRepository.saveAndFlush(state);
|
||||
} catch (DataIntegrityViolationException ignored) {
|
||||
return registrationInviteStateRepository.findById(STATE_ID)
|
||||
.orElseThrow(() -> ignored);
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveInitialInviteCode() {
|
||||
String configuredInviteCode = normalize(registrationProperties.getInviteCode());
|
||||
if (StringUtils.hasText(configuredInviteCode)) {
|
||||
return configuredInviteCode;
|
||||
}
|
||||
return generateInviteCode();
|
||||
}
|
||||
|
||||
private String generateNextInviteCode(String currentInviteCode) {
|
||||
String nextCode = generateInviteCode();
|
||||
while (nextCode.equals(currentInviteCode)) {
|
||||
nextCode = generateInviteCode();
|
||||
}
|
||||
return nextCode;
|
||||
}
|
||||
|
||||
private String generateInviteCode() {
|
||||
StringBuilder builder = new StringBuilder(INVITE_LENGTH);
|
||||
for (int i = 0; i < INVITE_LENGTH; i += 1) {
|
||||
builder.append(INVITE_CHARS.charAt(secureRandom.nextInt(INVITE_CHARS.length())));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "portal_registration_invite_state")
|
||||
public class RegistrationInviteState {
|
||||
|
||||
@Id
|
||||
private Long id;
|
||||
|
||||
@Column(name = "invite_code", nullable = false, length = 64)
|
||||
private String inviteCode;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
public void touch() {
|
||||
updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getInviteCode() {
|
||||
return inviteCode;
|
||||
}
|
||||
|
||||
public void setInviteCode(String inviteCode) {
|
||||
this.inviteCode = inviteCode;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.yoyuzh.auth;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface RegistrationInviteStateRepository extends JpaRepository<RegistrationInviteState, Long> {
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("select state from RegistrationInviteState state where state.id = :id")
|
||||
Optional<RegistrationInviteState> findByIdForUpdate(@Param("id") Long id);
|
||||
}
|
||||
@@ -58,6 +58,9 @@ public class User {
|
||||
@Column(name = "avatar_updated_at")
|
||||
private LocalDateTime avatarUpdatedAt;
|
||||
|
||||
@Column(name = "active_session_id", length = 64)
|
||||
private String activeSessionId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private UserRole role;
|
||||
@@ -177,6 +180,14 @@ public class User {
|
||||
this.avatarUpdatedAt = avatarUpdatedAt;
|
||||
}
|
||||
|
||||
public String getActiveSessionId() {
|
||||
return activeSessionId;
|
||||
}
|
||||
|
||||
public void setActiveSessionId(String activeSessionId) {
|
||||
this.activeSessionId = activeSessionId;
|
||||
}
|
||||
|
||||
public UserRole getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
@@ -13,11 +13,18 @@ public record RegisterRequest(
|
||||
@NotBlank
|
||||
@Pattern(regexp = "^1\\d{10}$", message = "请输入有效的11位手机号")
|
||||
String phoneNumber,
|
||||
@NotBlank @Size(min = 10, max = 64, message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符") String password
|
||||
@NotBlank @Size(min = 10, max = 64, message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符") String password,
|
||||
@NotBlank String confirmPassword,
|
||||
@NotBlank(message = "请输入邀请码") String inviteCode
|
||||
) {
|
||||
|
||||
@AssertTrue(message = "密码至少10位,且必须包含大写字母、小写字母、数字和特殊字符")
|
||||
public boolean isPasswordStrong() {
|
||||
return PasswordPolicy.isStrong(password);
|
||||
}
|
||||
|
||||
@AssertTrue(message = "两次输入的密码不一致")
|
||||
public boolean isPasswordConfirmed() {
|
||||
return password != null && password.equals(confirmPassword);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.yoyuzh.config;
|
||||
|
||||
import com.yoyuzh.auth.CustomUserDetailsService;
|
||||
import com.yoyuzh.auth.JwtTokenProvider;
|
||||
import com.yoyuzh.auth.User;
|
||||
import com.yoyuzh.common.BusinessException;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -33,6 +35,17 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
if (jwtTokenProvider.validateToken(token)
|
||||
&& SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||
String username = jwtTokenProvider.getUsername(token);
|
||||
User domainUser;
|
||||
try {
|
||||
domainUser = userDetailsService.loadDomainUser(username);
|
||||
} catch (BusinessException ex) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
if (!jwtTokenProvider.hasMatchingSession(token, domainUser.getActiveSessionId())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
if (!userDetails.isEnabled()) {
|
||||
filterChain.doFilter(request, response);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.yoyuzh.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "app.registration")
|
||||
public class RegistrationProperties {
|
||||
|
||||
private String inviteCode = "";
|
||||
|
||||
public String getInviteCode() {
|
||||
return inviteCode;
|
||||
}
|
||||
|
||||
public void setInviteCode(String inviteCode) {
|
||||
this.inviteCode = inviteCode;
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,5 @@ app:
|
||||
secret: ${APP_JWT_SECRET:}
|
||||
admin:
|
||||
usernames: ${APP_ADMIN_USERNAMES:}
|
||||
registration:
|
||||
invite-code: ${APP_AUTH_REGISTRATION_INVITE_CODE:dev-invite-code}
|
||||
|
||||
@@ -29,6 +29,8 @@ app:
|
||||
refresh-expiration-seconds: 1209600
|
||||
admin:
|
||||
usernames: ${APP_ADMIN_USERNAMES:}
|
||||
registration:
|
||||
invite-code: ${APP_AUTH_REGISTRATION_INVITE_CODE:}
|
||||
storage:
|
||||
root-dir: ./storage
|
||||
max-file-size: 524288000
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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("邀请码错误"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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("请输入邀请码");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user