feat(auth): harden token lifecycle and password policy

This commit is contained in:
yoyuzh
2026-03-19 14:51:18 +08:00
parent 41a83d2805
commit a78d0dc2db
26 changed files with 1047 additions and 53 deletions

View File

@@ -2,6 +2,7 @@ package com.yoyuzh.auth;
import com.yoyuzh.auth.dto.AuthResponse;
import com.yoyuzh.auth.dto.LoginRequest;
import com.yoyuzh.auth.dto.RefreshTokenRequest;
import com.yoyuzh.auth.dto.RegisterRequest;
import com.yoyuzh.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
@@ -30,4 +31,10 @@ public class AuthController {
public ApiResponse<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
return ApiResponse.success(authService.login(request));
}
@Operation(summary = "刷新访问令牌")
@PostMapping("/refresh")
public ApiResponse<AuthResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
return ApiResponse.success(authService.refresh(request.refreshToken()));
}
}

View File

@@ -23,6 +23,7 @@ public class AuthService {
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenService refreshTokenService;
private final FileService fileService;
@Transactional
@@ -40,7 +41,7 @@ public class AuthService {
user.setPasswordHash(passwordEncoder.encode(request.password()));
User saved = userRepository.save(user);
fileService.ensureDefaultDirectories(saved);
return new AuthResponse(jwtTokenProvider.generateToken(saved.getId(), saved.getUsername()), toProfile(saved));
return issueTokens(saved);
}
public AuthResponse login(LoginRequest request) {
@@ -54,7 +55,7 @@ public class AuthService {
User user = userRepository.findByUsername(request.username())
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
fileService.ensureDefaultDirectories(user);
return new AuthResponse(jwtTokenProvider.generateToken(user.getId(), user.getUsername()), toProfile(user));
return issueTokens(user);
}
@Transactional
@@ -73,7 +74,13 @@ public class AuthService {
return userRepository.save(created);
});
fileService.ensureDefaultDirectories(user);
return new AuthResponse(jwtTokenProvider.generateToken(user.getId(), user.getUsername()), toProfile(user));
return issueTokens(user);
}
@Transactional
public AuthResponse refresh(String refreshToken) {
RefreshTokenService.RotatedRefreshToken rotated = refreshTokenService.rotateRefreshToken(refreshToken);
return issueTokens(rotated.user(), rotated.refreshToken());
}
public UserProfileResponse getProfile(String username) {
@@ -85,4 +92,13 @@ public class AuthService {
private UserProfileResponse toProfile(User user) {
return new UserProfileResponse(user.getId(), user.getUsername(), user.getEmail(), user.getCreatedAt());
}
private AuthResponse issueTokens(User user) {
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));
}
}

View File

@@ -15,6 +15,8 @@ import java.util.Date;
@Component
public class JwtTokenProvider {
private static final String DEFAULT_SECRET = "change-me-change-me-change-me-change-me";
private final JwtProperties jwtProperties;
private SecretKey secretKey;
@@ -24,16 +26,26 @@ public class JwtTokenProvider {
@PostConstruct
public void init() {
secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8));
String secret = jwtProperties.getSecret() == null ? "" : jwtProperties.getSecret().trim();
if (secret.isEmpty()) {
throw new IllegalStateException("app.jwt.secret 未配置,请设置强密钥后再启动");
}
if (DEFAULT_SECRET.equals(secret)) {
throw new IllegalStateException("检测到默认 JWT 密钥,请替换 app.jwt.secret 后再启动");
}
if (secret.getBytes(StandardCharsets.UTF_8).length < 32) {
throw new IllegalStateException("JWT 密钥长度过短,至少需要 32 字节");
}
secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(Long userId, String username) {
public String generateAccessToken(Long userId, String username) {
Instant now = Instant.now();
return Jwts.builder()
.subject(username)
.claim("uid", userId)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(jwtProperties.getExpirationSeconds())))
.expiration(Date.from(now.plusSeconds(jwtProperties.getAccessExpirationSeconds())))
.signWith(secretKey)
.compact();
}

View File

@@ -0,0 +1,115 @@
package com.yoyuzh.auth;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_refresh_token", indexes = {
@Index(name = "uk_refresh_token_hash", columnList = "token_hash", unique = true),
@Index(name = "idx_refresh_user_expired", columnList = "user_id, expires_at"),
@Index(name = "idx_refresh_revoked", columnList = "revoked")
})
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "token_hash", nullable = false, length = 64, unique = true)
private String tokenHash;
@Column(name = "expires_at", nullable = false)
private LocalDateTime expiresAt;
@Column(name = "revoked", nullable = false)
private boolean revoked;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "revoked_at")
private LocalDateTime revokedAt;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
public void revoke(LocalDateTime revokedAt) {
this.revoked = true;
this.revokedAt = revokedAt;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getTokenHash() {
return tokenHash;
}
public void setTokenHash(String tokenHash) {
this.tokenHash = tokenHash;
}
public LocalDateTime getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(LocalDateTime expiresAt) {
this.expiresAt = expiresAt;
}
public boolean isRevoked() {
return revoked;
}
public void setRevoked(boolean revoked) {
this.revoked = revoked;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getRevokedAt() {
return revokedAt;
}
public void setRevokedAt(LocalDateTime revokedAt) {
this.revokedAt = revokedAt;
}
}

View File

@@ -0,0 +1,15 @@
package com.yoyuzh.auth;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import java.util.Optional;
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select token from RefreshToken token join fetch token.user where token.tokenHash = :tokenHash")
Optional<RefreshToken> findForUpdateByTokenHash(String tokenHash);
}

View File

@@ -0,0 +1,84 @@
package com.yoyuzh.auth;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.config.JwtProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.HexFormat;
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private static final int REFRESH_TOKEN_BYTES = 48;
private final RefreshTokenRepository refreshTokenRepository;
private final JwtProperties jwtProperties;
private final SecureRandom secureRandom = new SecureRandom();
@Transactional
public String issueRefreshToken(User user) {
String rawToken = generateRawToken();
RefreshToken refreshToken = new RefreshToken();
refreshToken.setUser(user);
refreshToken.setTokenHash(hashToken(rawToken));
refreshToken.setExpiresAt(LocalDateTime.now().plusSeconds(jwtProperties.getRefreshExpirationSeconds()));
refreshToken.setRevoked(false);
refreshTokenRepository.save(refreshToken);
return rawToken;
}
@Transactional(noRollbackFor = BusinessException.class)
public RotatedRefreshToken rotateRefreshToken(String rawToken) {
RefreshToken existing = refreshTokenRepository.findForUpdateByTokenHash(hashToken(rawToken))
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "刷新令牌无效"));
if (existing.isRevoked()) {
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "刷新令牌无效或已使用");
}
if (existing.getExpiresAt().isBefore(LocalDateTime.now())) {
existing.revoke(LocalDateTime.now());
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "刷新令牌已过期");
}
User user = existing.getUser();
existing.revoke(LocalDateTime.now());
String nextRefreshToken = issueRefreshToken(user);
return new RotatedRefreshToken(user, nextRefreshToken);
}
private String generateRawToken() {
byte[] bytes = new byte[REFRESH_TOKEN_BYTES];
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
private String hashToken(String rawToken) {
if (rawToken == null || rawToken.isBlank()) {
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "刷新令牌不能为空");
}
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(rawToken.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("无法初始化刷新令牌哈希算法", ex);
}
}
public record RotatedRefreshToken(User user, String refreshToken) {
}
}

View File

@@ -1,4 +1,8 @@
package com.yoyuzh.auth.dto;
public record AuthResponse(String token, UserProfileResponse user) {
public record AuthResponse(String token, String accessToken, String refreshToken, UserProfileResponse user) {
public static AuthResponse issued(String accessToken, String refreshToken, UserProfileResponse user) {
return new AuthResponse(accessToken, accessToken, refreshToken, user);
}
}

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.auth.dto;
import jakarta.validation.constraints.NotBlank;
public record RefreshTokenRequest(@NotBlank String refreshToken) {
}

View File

@@ -1,12 +1,40 @@
package com.yoyuzh.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@NotBlank @Size(min = 3, max = 64) String username,
@NotBlank @Email @Size(max = 128) String email,
@NotBlank @Size(min = 6, max = 64) String password
@NotBlank @Size(min = 10, max = 64, message = "密码至少10位且必须包含大写字母、小写字母、数字和特殊字符") String password
) {
@AssertTrue(message = "密码至少10位且必须包含大写字母、小写字母、数字和特殊字符")
public boolean isPasswordStrong() {
if (password == null || password.length() < 10) {
return false;
}
boolean hasLower = false;
boolean hasUpper = false;
boolean hasDigit = false;
boolean hasSpecial = false;
for (int i = 0; i < password.length(); i += 1) {
char c = password.charAt(i);
if (Character.isLowerCase(c)) {
hasLower = true;
} else if (Character.isUpperCase(c)) {
hasUpper = true;
} else if (Character.isDigit(c)) {
hasDigit = true;
} else {
hasSpecial = true;
}
}
return hasLower && hasUpper && hasDigit && hasSpecial;
}
}

View File

@@ -1,15 +1,19 @@
package com.yoyuzh.common;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Objects;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@@ -27,7 +31,27 @@ public class GlobalExceptionHandler {
@ExceptionHandler({MethodArgumentNotValidException.class, ConstraintViolationException.class})
public ResponseEntity<ApiResponse<Void>> handleValidationException(Exception ex) {
return ResponseEntity.badRequest().body(ApiResponse.error(ErrorCode.UNKNOWN, ex.getMessage()));
if (ex instanceof MethodArgumentNotValidException validationException) {
String message = validationException.getBindingResult().getAllErrors().stream()
.map(ObjectError::getDefaultMessage)
.filter(Objects::nonNull)
.map(String::trim)
.filter(msg -> !msg.isEmpty())
.findFirst()
.orElse("请求参数不合法");
return ResponseEntity.badRequest().body(ApiResponse.error(ErrorCode.UNKNOWN, message));
}
if (ex instanceof ConstraintViolationException validationException) {
String message = validationException.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.filter(Objects::nonNull)
.map(String::trim)
.filter(msg -> !msg.isEmpty())
.findFirst()
.orElse("请求参数不合法");
return ResponseEntity.badRequest().body(ApiResponse.error(ErrorCode.UNKNOWN, message));
}
return ResponseEntity.badRequest().body(ApiResponse.error(ErrorCode.UNKNOWN, "请求参数不合法"));
}
@ExceptionHandler(AccessDeniedException.class)

View File

@@ -5,8 +5,9 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.jwt")
public class JwtProperties {
private String secret = "change-me-change-me-change-me-change-me";
private long expirationSeconds = 86400;
private String secret = "";
private long accessExpirationSeconds = 900;
private long refreshExpirationSeconds = 1209600;
public String getSecret() {
return secret;
@@ -16,11 +17,19 @@ public class JwtProperties {
this.secret = secret;
}
public long getExpirationSeconds() {
return expirationSeconds;
public long getAccessExpirationSeconds() {
return accessExpirationSeconds;
}
public void setExpirationSeconds(long expirationSeconds) {
this.expirationSeconds = expirationSeconds;
public void setAccessExpirationSeconds(long accessExpirationSeconds) {
this.accessExpirationSeconds = accessExpirationSeconds;
}
public long getRefreshExpirationSeconds() {
return refreshExpirationSeconds;
}
public void setRefreshExpirationSeconds(long refreshExpirationSeconds) {
this.refreshExpirationSeconds = refreshExpirationSeconds;
}
}

View File

@@ -13,5 +13,7 @@ spring:
path: /h2-console
app:
jwt:
secret: ${APP_JWT_SECRET:}
cqu:
mock-enabled: true

View File

@@ -23,8 +23,9 @@ spring:
app:
jwt:
secret: change-me-change-me-change-me-change-me
expiration-seconds: 86400
secret: ${APP_JWT_SECRET:}
access-expiration-seconds: 900
refresh-expiration-seconds: 1209600
storage:
root-dir: ./storage
max-file-size: 524288000