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

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

@@ -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 {

View File

@@ -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));
}

View File

@@ -2,6 +2,7 @@ package com.yoyuzh.admin;
public record AdminSummaryResponse(
long totalUsers,
long totalFiles
long totalFiles,
String inviteCode
) {
}

View File

@@ -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) {

View File

@@ -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();
}

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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}

View File

@@ -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