diff --git a/src/main/java/com/onixbyte/deltaforceguide/client/TokenClient.java b/src/main/java/com/onixbyte/deltaforceguide/client/TokenClient.java new file mode 100644 index 0000000..d4b745c --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/client/TokenClient.java @@ -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); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java index 916ff57..291695b 100644 --- a/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java +++ b/src/main/java/com/onixbyte/deltaforceguide/config/CorsConfig.java @@ -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; + } } diff --git a/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java new file mode 100644 index 0000000..effe916 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/config/SecurityConfig.java @@ -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(); + } +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java b/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java new file mode 100644 index 0000000..aad96d2 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/exeption/BizException.java @@ -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. + *

+ * 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; + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java new file mode 100644 index 0000000..8cfd219 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/filter/TokenAuthenticationFilter.java @@ -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); + } + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java b/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java new file mode 100644 index 0000000..0d7a618 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/properties/TokenProperties.java @@ -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 +) { +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java b/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java new file mode 100644 index 0000000..692efaf --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/security/authentication/UsernamePasswordAuthentication.java @@ -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 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; + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java new file mode 100644 index 0000000..b32c814 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/security/provider/UsernamePasswordAuthenticationProvider.java @@ -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); + } +} + diff --git a/src/main/java/com/onixbyte/deltaforceguide/shared/CredentialProvider.java b/src/main/java/com/onixbyte/deltaforceguide/shared/CredentialProvider.java new file mode 100644 index 0000000..115f935 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/shared/CredentialProvider.java @@ -0,0 +1,6 @@ +package com.onixbyte.deltaforceguide.shared; + +public class CredentialProvider { + + public static final String LOCAL = "LOCAL"; +} diff --git a/src/main/java/com/onixbyte/deltaforceguide/utils/DateTimeUtil.java b/src/main/java/com/onixbyte/deltaforceguide/utils/DateTimeUtil.java new file mode 100644 index 0000000..f1360c8 --- /dev/null +++ b/src/main/java/com/onixbyte/deltaforceguide/utils/DateTimeUtil.java @@ -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(); + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 65b087c..66611ee 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -43,4 +43,3 @@ logging: level: org.hibernate: orm.connections.pooling: off -