feat: implement JWT authentication with TokenClient, TokenAuthenticationFilter, and SecurityConfig
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
package com.onixbyte.deltaforceguide.client;
|
||||
|
||||
import com.auth0.jwt.JWT;
|
||||
import com.auth0.jwt.JWTVerifier;
|
||||
import com.auth0.jwt.algorithms.Algorithm;
|
||||
import com.auth0.jwt.interfaces.DecodedJWT;
|
||||
import com.onixbyte.deltaforceguide.domain.entity.User;
|
||||
import com.onixbyte.deltaforceguide.properties.TokenProperties;
|
||||
import com.onixbyte.deltaforceguide.utils.DateTimeUtil;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Component
|
||||
public class TokenClient {
|
||||
|
||||
private final Algorithm algorithm;
|
||||
private final TokenProperties tokenProperties;
|
||||
private final JWTVerifier verifier;
|
||||
|
||||
/**
|
||||
* Constructs a new TokenClient with the necessary algorithm and token properties.
|
||||
*
|
||||
* @param algorithm the signing algorithm used to secure the JWT
|
||||
* @param tokenProperties the configuration properties for the token, such as issuer and
|
||||
* validity period
|
||||
*/
|
||||
@Autowired
|
||||
public TokenClient(
|
||||
Algorithm algorithm,
|
||||
TokenProperties tokenProperties,
|
||||
JWTVerifier verifier
|
||||
) {
|
||||
this.algorithm = algorithm;
|
||||
this.tokenProperties = tokenProperties;
|
||||
this.verifier = verifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JSON Web Token to the current user.
|
||||
*
|
||||
* @param user the current user for whom the token is being generated
|
||||
* @return a JWT string
|
||||
*/
|
||||
public String generateToken(User user) {
|
||||
var issuedAt = LocalDateTime.now();
|
||||
var expiresAt = issuedAt.plus(tokenProperties.validTime());
|
||||
|
||||
return JWT.create()
|
||||
.withSubject(user.getUsername())
|
||||
.withIssuer(tokenProperties.issuer())
|
||||
.withIssuedAt(DateTimeUtil.asInstant(issuedAt))
|
||||
.withExpiresAt(DateTimeUtil.asInstant(expiresAt))
|
||||
.sign(algorithm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode token.
|
||||
*
|
||||
* @param token a JWT token
|
||||
* @return information included in the given token
|
||||
* @throws com.auth0.jwt.exceptions.JWTVerificationException if the token is invalid, such as
|
||||
* expired, or not signed by
|
||||
* specific server
|
||||
*/
|
||||
public DecodedJWT verifyToken(String token) {
|
||||
return verifier.verify(token);
|
||||
}
|
||||
}
|
||||
@@ -2,46 +2,37 @@ package com.onixbyte.deltaforceguide.config;
|
||||
|
||||
import com.onixbyte.deltaforceguide.properties.CorsProperties;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({CorsProperties.class})
|
||||
public class CorsConfig implements WebMvcConfigurer {
|
||||
public class CorsConfig {
|
||||
|
||||
private final CorsProperties properties;
|
||||
|
||||
public CorsConfig(CorsProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(toSafeArray(properties.allowedOrigins()))
|
||||
.allowedHeaders(toSafeArray(properties.allowedHeaders()))
|
||||
.allowedMethods(toHttpMethodNames(properties.allowedMethods()))
|
||||
.allowCredentials(properties.allowCredentials())
|
||||
.maxAge(properties.maxAge().toSeconds())
|
||||
.exposedHeaders(toSafeArray(properties.exposedHeaders()));
|
||||
}
|
||||
|
||||
private static String[] toSafeArray(String[] values) {
|
||||
return values == null ? new String[0] : values;
|
||||
}
|
||||
|
||||
private static String[] toHttpMethodNames(HttpMethod[] methods) {
|
||||
return Optional.ofNullable(methods)
|
||||
.stream()
|
||||
.flatMap(Stream::of)
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource(
|
||||
CorsProperties properties
|
||||
) {
|
||||
var corsConfiguration = new CorsConfiguration();
|
||||
corsConfiguration.setAllowCredentials(properties.allowCredentials());
|
||||
corsConfiguration.setAllowedOrigins(List.of(properties.allowedOrigins()));
|
||||
corsConfiguration.setAllowedHeaders(List.of(properties.allowedHeaders()));
|
||||
corsConfiguration.setAllowedMethods(Stream.of(properties.allowedMethods())
|
||||
.map(HttpMethod::name)
|
||||
.toList()
|
||||
.toArray(String[]::new);
|
||||
}
|
||||
.toList());
|
||||
corsConfiguration.setMaxAge(properties.maxAge());
|
||||
corsConfiguration.setAllowPrivateNetwork(properties.allowPrivateNetwork());
|
||||
corsConfiguration.setExposedHeaders(List.of(properties.exposedHeaders()));
|
||||
|
||||
var corsConfigurationSource = new UrlBasedCorsConfigurationSource();
|
||||
corsConfigurationSource.registerCorsConfiguration("/**", corsConfiguration);
|
||||
return corsConfigurationSource;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.onixbyte.deltaforceguide.config;
|
||||
|
||||
import com.auth0.jwt.JWT;
|
||||
import com.auth0.jwt.JWTVerifier;
|
||||
import com.auth0.jwt.algorithms.Algorithm;
|
||||
import com.onixbyte.deltaforceguide.filter.TokenAuthenticationFilter;
|
||||
import com.onixbyte.deltaforceguide.properties.TokenProperties;
|
||||
import com.onixbyte.deltaforceguide.security.provider.UsernamePasswordAuthenticationProvider;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.authentication.ProviderManager;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.ExceptionTranslationFilter;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
@EnableConfigurationProperties({TokenProperties.class})
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(
|
||||
HttpSecurity httpSecurity,
|
||||
CorsConfigurationSource corsConfigurationSource,
|
||||
TokenAuthenticationFilter tokenAuthenticationFilter
|
||||
) throws Exception {
|
||||
return httpSecurity
|
||||
.cors((cors) -> cors
|
||||
.configurationSource(corsConfigurationSource))
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement((customiser) -> customiser
|
||||
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests((customiser) -> customiser
|
||||
.requestMatchers("/error", "/error/**").permitAll()
|
||||
.requestMatchers("/captcha", "/captcha/**").permitAll()
|
||||
.requestMatchers("/auth/**").permitAll()
|
||||
.requestMatchers("/auth/logout").authenticated()
|
||||
.requestMatchers(
|
||||
"/swagger-ui.html",
|
||||
"/swagger-ui",
|
||||
"/swagger-ui/**",
|
||||
"/v3/api-docs",
|
||||
"/v3/api-docs.yaml",
|
||||
"/v3/api-docs/swagger-config"
|
||||
).permitAll()
|
||||
.requestMatchers(HttpMethod.GET,
|
||||
"/firearms", "/firearms/*",
|
||||
"/modifications", "/modifications/*"
|
||||
).permitAll()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterAfter(tokenAuthenticationFilter, ExceptionTranslationFilter.class)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public AuthenticationManager authenticationManager(
|
||||
UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider
|
||||
) {
|
||||
return new ProviderManager(
|
||||
usernamePasswordAuthenticationProvider
|
||||
);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public Algorithm algorithm(TokenProperties properties) {
|
||||
return Algorithm.HMAC256(properties.secret());
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JWTVerifier verifier(Algorithm algorithm, TokenProperties tokenProperties) {
|
||||
return JWT.require(algorithm)
|
||||
.withIssuer(tokenProperties.issuer())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.onixbyte.deltaforceguide.exeption;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
public class BizException extends RuntimeException {
|
||||
|
||||
/**
|
||||
* The HTTP status code associated with this business exception.
|
||||
* <p>
|
||||
* This status code indicates the appropriate HTTP response status that should be returned to
|
||||
* clients when this exception occurs. It enables consistent error handling across
|
||||
* REST API endpoints.
|
||||
*/
|
||||
private final HttpStatus status;
|
||||
|
||||
public BizException(String message) {
|
||||
super(message);
|
||||
this.status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
}
|
||||
|
||||
public BizException(HttpStatus status, String message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the HTTP status code associated with this business exception.
|
||||
*
|
||||
* @return the HTTP status code that should be used in the error response
|
||||
*/
|
||||
public HttpStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.onixbyte.deltaforceguide.filter;
|
||||
|
||||
import com.auth0.jwt.exceptions.JWTVerificationException;
|
||||
import com.onixbyte.deltaforceguide.client.TokenClient;
|
||||
import com.onixbyte.deltaforceguide.exeption.BizException;
|
||||
import com.onixbyte.deltaforceguide.manager.UserManager;
|
||||
import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.jspecify.annotations.NonNull;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.web.servlet.HandlerExceptionResolver;
|
||||
import org.springframework.web.util.WebUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
@Component
|
||||
public class TokenAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final static Logger log = LoggerFactory.getLogger(TokenAuthenticationFilter.class);
|
||||
|
||||
private final UserManager userManager;
|
||||
private final TokenClient tokenClient;
|
||||
private final HandlerExceptionResolver handlerExceptionResolver;
|
||||
|
||||
public TokenAuthenticationFilter(
|
||||
UserManager userManager,
|
||||
TokenClient tokenClient,
|
||||
HandlerExceptionResolver handlerExceptionResolver
|
||||
) {
|
||||
this.userManager = userManager;
|
||||
this.tokenClient = tokenClient;
|
||||
this.handlerExceptionResolver = handlerExceptionResolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
var token = Optional.ofNullable(WebUtils.getCookie(request, "AccessToken"))
|
||||
.map(Cookie::getValue)
|
||||
.orElse(null);
|
||||
if (Objects.isNull(token) || token.isBlank()) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var decodedToken = tokenClient.verifyToken(token);
|
||||
var username = decodedToken.getSubject();
|
||||
|
||||
var userWrapper = userManager.findByUsername(username);
|
||||
if (userWrapper.isEmpty()) {
|
||||
throw new BizException(HttpStatus.UNAUTHORIZED, "登录已过期,请重新登录");
|
||||
}
|
||||
|
||||
var user = userWrapper.get();
|
||||
var authentication = UsernamePasswordAuthentication.authenticated(user);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
filterChain.doFilter(request, response);
|
||||
} catch (JWTVerificationException e) {
|
||||
log.error("JWT verification failed.", e);
|
||||
handlerExceptionResolver.resolveException(request, response, null,
|
||||
new BizException(HttpStatus.UNAUTHORIZED, "登录已过期,请重新登录"));
|
||||
} catch (BizException e) {
|
||||
handlerExceptionResolver.resolveException(request, response, null, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.onixbyte.deltaforceguide.properties;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
@ConfigurationProperties(prefix = "app.jwt")
|
||||
public record TokenProperties(
|
||||
String issuer,
|
||||
String secret,
|
||||
Duration validTime
|
||||
) {
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.onixbyte.deltaforceguide.security.authentication;
|
||||
|
||||
import com.onixbyte.deltaforceguide.domain.entity.User;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.CredentialsContainer;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public class UsernamePasswordAuthentication implements Authentication, CredentialsContainer {
|
||||
private final String username;
|
||||
private String password;
|
||||
private boolean authenticated;
|
||||
private User user;
|
||||
|
||||
private UsernamePasswordAuthentication(String username, String password, boolean authenticated, User user) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.authenticated = authenticated;
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public static UsernamePasswordAuthentication unauthenticated(String username, String password) {
|
||||
return new UsernamePasswordAuthentication(username, password, false, null);
|
||||
}
|
||||
|
||||
public static UsernamePasswordAuthentication authenticated(User user) {
|
||||
return new UsernamePasswordAuthentication(user.getUsername(), null, true, user);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCredentials() {
|
||||
return password;
|
||||
}
|
||||
|
||||
@Override
|
||||
public User getDetails() {
|
||||
return user;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPrincipal() {
|
||||
return username;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAuthenticated() {
|
||||
return authenticated;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAuthenticated(boolean authenticated) throws IllegalArgumentException {
|
||||
this.authenticated = authenticated;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return username;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void eraseCredentials() {
|
||||
this.password = null;
|
||||
}
|
||||
|
||||
public void setDetails(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
}
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.onixbyte.deltaforceguide.security.provider;
|
||||
|
||||
import com.onixbyte.deltaforceguide.domain.entity.UserCredential;
|
||||
import com.onixbyte.deltaforceguide.exeption.BizException;
|
||||
import com.onixbyte.deltaforceguide.manager.UserManager;
|
||||
import com.onixbyte.deltaforceguide.repository.UserCredentialRepository;
|
||||
import com.onixbyte.deltaforceguide.security.authentication.UsernamePasswordAuthentication;
|
||||
import com.onixbyte.deltaforceguide.shared.CredentialProvider;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Example;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.authentication.AuthenticationProvider;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class UsernamePasswordAuthenticationProvider implements AuthenticationProvider {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UsernamePasswordAuthenticationProvider.class);
|
||||
private final UserManager userManager;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final UserCredentialRepository userCredentialRepository;
|
||||
|
||||
@Autowired
|
||||
public UsernamePasswordAuthenticationProvider(
|
||||
UserManager userManager,
|
||||
PasswordEncoder passwordEncoder,
|
||||
UserCredentialRepository userCredentialRepository
|
||||
) {
|
||||
this.userManager = userManager;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.userCredentialRepository = userCredentialRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
|
||||
if (!(authentication instanceof UsernamePasswordAuthentication usernamePasswordAuthentication)) {
|
||||
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR, "用户认证失败,请稍后再试。");
|
||||
}
|
||||
|
||||
// get userContainer from database
|
||||
var userContainer = userManager.findByUsername(usernamePasswordAuthentication.getPrincipal());
|
||||
if (userContainer.isEmpty()) {
|
||||
log.error("User {} is trying to authenticate but no userContainer found.", usernamePasswordAuthentication.getPrincipal());
|
||||
throw new BizException(HttpStatus.UNAUTHORIZED, "用户名或密码错误。");
|
||||
}
|
||||
|
||||
var user = userContainer.get();
|
||||
|
||||
var userCredentialExample = new UserCredential();
|
||||
userCredentialExample.setUserId(user.getId());
|
||||
userCredentialExample.setProvider(CredentialProvider.LOCAL);
|
||||
|
||||
// get userContainer credentials from database
|
||||
var userCredentials = userCredentialRepository.findOne(Example.of(userCredentialExample))
|
||||
.orElseThrow(() -> new BizException(HttpStatus.UNAUTHORIZED, "您还没有配置密码,请联系管理员处理。"));
|
||||
|
||||
// validate password
|
||||
if (!passwordEncoder.matches(usernamePasswordAuthentication.getCredentials(), userCredentials.getCredential())) {
|
||||
log.error("User {} is trying to authenticate but password is incorrect.", usernamePasswordAuthentication.getPrincipal());
|
||||
throw new BizException(HttpStatus.UNAUTHORIZED, "用户名或密码错误。");
|
||||
}
|
||||
|
||||
// erase credentials
|
||||
usernamePasswordAuthentication.eraseCredentials();
|
||||
|
||||
// set values
|
||||
usernamePasswordAuthentication.setAuthenticated(true);
|
||||
usernamePasswordAuthentication.setDetails(user);
|
||||
|
||||
return usernamePasswordAuthentication;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(Class<?> authentication) {
|
||||
return UsernamePasswordAuthentication.class.isAssignableFrom(authentication);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.onixbyte.deltaforceguide.shared;
|
||||
|
||||
public class CredentialProvider {
|
||||
|
||||
public static final String LOCAL = "LOCAL";
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.onixbyte.deltaforceguide.utils;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
|
||||
public class DateTimeUtil {
|
||||
|
||||
public static Instant asInstant(LocalDateTime ldt) {
|
||||
return ldt.atZone(ZoneId.systemDefault())
|
||||
.toInstant();
|
||||
}
|
||||
}
|
||||
@@ -43,4 +43,3 @@ logging:
|
||||
level:
|
||||
org.hibernate:
|
||||
orm.connections.pooling: off
|
||||
|
||||
|
||||
Reference in New Issue
Block a user