feat(auth): harden token lifecycle and password policy
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
115
backend/src/main/java/com/yoyuzh/auth/RefreshToken.java
Normal file
115
backend/src/main/java/com/yoyuzh/auth/RefreshToken.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record RefreshTokenRequest(@NotBlank String refreshToken) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user