feat(security): 配置安全过滤器链支持异步请求

- 添加 DispatcherType.ASYNC 类型匹配以允许异步请求通过
- 添加 Order 注解确保安全过滤器链正确排序
- 导入必要的 jakarta.servlet.DispatcherType 和 org.springframework.core.annotation.Order 依赖
This commit is contained in:
zkh
2026-02-04 01:15:08 +08:00
parent 01d29e6ec3
commit f152b1e655
@@ -1,6 +1,7 @@
package vip.jcfd.web.config; package vip.jcfd.web.config;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
@@ -10,6 +11,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.data.domain.AuditorAware; import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@@ -60,192 +62,194 @@ import java.util.UUID;
@EnableScheduling @EnableScheduling
public class WebSecurityConfig { public class WebSecurityConfig {
private static final Logger log = LoggerFactory.getLogger(WebSecurityConfig.class); private static final Logger log = LoggerFactory.getLogger(WebSecurityConfig.class);
private final SecurityProps securityProps; private final SecurityProps securityProps;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final TokenRedisStorage tokenRedisStorage; private final TokenRedisStorage tokenRedisStorage;
public WebSecurityConfig(SecurityProps securityProps, public WebSecurityConfig(SecurityProps securityProps,
ObjectMapper objectMapper, ObjectMapper objectMapper,
TokenRedisStorage tokenRedisStorage, TokenRedisStorage tokenRedisStorage,
AuthenticationManagerBuilder builder, AuthenticationManagerBuilder builder,
UserDetailsService userDetailsService) { UserDetailsService userDetailsService) {
this.securityProps = securityProps; this.securityProps = securityProps;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.tokenRedisStorage = tokenRedisStorage; this.tokenRedisStorage = tokenRedisStorage;
builder.authenticationProvider(new RefreshTokenAuthProvider(userDetailsService)); builder.authenticationProvider(new RefreshTokenAuthProvider(userDetailsService));
DaoAuthenticationProvider authenticationProvider = new CustomDaoAuthenticationProvider(userDetailsService); DaoAuthenticationProvider authenticationProvider = new CustomDaoAuthenticationProvider(userDetailsService);
authenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder()); authenticationProvider.setPasswordEncoder(new BCryptPasswordEncoder());
builder.authenticationProvider(authenticationProvider); builder.authenticationProvider(authenticationProvider);
} }
@Scheduled(cron = "0 */30 * * * *") @Scheduled(cron = "0 */30 * * * *")
@Async @Async
public void scheduleClearExpiredTokens() { public void scheduleClearExpiredTokens() {
tokenRedisStorage.clearExpiredTokens(); tokenRedisStorage.clearExpiredTokens();
} }
@Bean @Bean
public AuditorAware<String> auditorAware() { public AuditorAware<String> auditorAware() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext()) return () -> Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication) .map(SecurityContext::getAuthentication)
.map(Authentication::getName) .map(Authentication::getName)
.or(() -> Optional.of("system")); .or(() -> Optional.of("system"));
} }
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
} }
@Bean @Bean
@ConditionalOnMissingBean @ConditionalOnMissingBean
public TokenFilter tokenFilter() { public TokenFilter tokenFilter() {
return new TokenFilter(tokenRedisStorage); return new TokenFilter(tokenRedisStorage);
} }
@Bean @Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager(); return configuration.getAuthenticationManager();
} }
@Bean @Bean
public SecurityFilterChain security(HttpSecurity http, TokenFilter tokenFilter, AuthenticationManager authenticationManager) throws Exception { @Order
http.authorizeHttpRequests(config -> { public SecurityFilterChain security(HttpSecurity http, TokenFilter tokenFilter, AuthenticationManager authenticationManager) throws Exception {
config.requestMatchers(securityProps.getIgnoreUrls()).permitAll(); http.authorizeHttpRequests(config -> {
config.anyRequest().authenticated(); config.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll();
}); config.requestMatchers(securityProps.getIgnoreUrls()).permitAll();
CustomAuthenticationEntryPoint authenticationEntryPoint = new CustomAuthenticationEntryPoint(objectMapper, tokenRedisStorage, securityProps); config.anyRequest().authenticated();
http.formLogin(config -> { });
config.loginProcessingUrl("/login"); CustomAuthenticationEntryPoint authenticationEntryPoint = new CustomAuthenticationEntryPoint(objectMapper, tokenRedisStorage, securityProps);
}); http.formLogin(config -> {
http.csrf(AbstractHttpConfigurer::disable); config.loginProcessingUrl("/login");
http.logout(config -> { });
config.addLogoutHandler(new CustomLogoutSuccessHandler(objectMapper, tokenRedisStorage)); http.csrf(AbstractHttpConfigurer::disable);
}); http.logout(config -> {
http.rememberMe(AbstractHttpConfigurer::disable); config.addLogoutHandler(new CustomLogoutSuccessHandler(objectMapper, tokenRedisStorage));
http.sessionManagement(AbstractHttpConfigurer::disable); });
http.exceptionHandling(config -> { http.rememberMe(AbstractHttpConfigurer::disable);
config.authenticationEntryPoint(authenticationEntryPoint); http.sessionManagement(AbstractHttpConfigurer::disable);
config.accessDeniedHandler(new CustomAccessDeniedHandler(objectMapper)); http.exceptionHandling(config -> {
}); config.authenticationEntryPoint(authenticationEntryPoint);
config.accessDeniedHandler(new CustomAccessDeniedHandler(objectMapper));
});
http.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class); http.addFilterBefore(tokenFilter, UsernamePasswordAuthenticationFilter.class);
JsonUsernamePasswordAuthenticationFilter filter = new JsonUsernamePasswordAuthenticationFilter(objectMapper, authenticationManager); JsonUsernamePasswordAuthenticationFilter filter = new JsonUsernamePasswordAuthenticationFilter(objectMapper, authenticationManager);
filter.setAuthenticationSuccessHandler(authenticationEntryPoint); filter.setAuthenticationSuccessHandler(authenticationEntryPoint);
filter.setAuthenticationFailureHandler(authenticationEntryPoint); filter.setAuthenticationFailureHandler(authenticationEntryPoint);
http.addFilterAt(filter, UsernamePasswordAuthenticationFilter.class); http.addFilterAt(filter, UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }
private record CustomAuthenticationEntryPoint( private record CustomAuthenticationEntryPoint(
ObjectMapper objectMapper, ObjectMapper objectMapper,
TokenRedisStorage tokenRedisStorage, TokenRedisStorage tokenRedisStorage,
SecurityProps securityProps) implements AuthenticationEntryPoint, AuthenticationFailureHandler, AuthenticationSuccessHandler { SecurityProps securityProps) implements AuthenticationEntryPoint, AuthenticationFailureHandler, AuthenticationSuccessHandler {
@Override @Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
log.warn("访问 {} ,但是认证失败", request.getRequestURI(), authException); log.warn("访问 {} ,但是认证失败", request.getRequestURI(), authException);
R<Object> data = new R<>(HttpServletResponse.SC_UNAUTHORIZED, "未登录", false, null); R<Object> data = new R<>(HttpServletResponse.SC_UNAUTHORIZED, "未登录", false, null);
response.setContentType("application/json;charset=UTF-8"); response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(), data); objectMapper.writeValue(response.getWriter(), data);
} }
@Override @Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
log.warn("登录失败", exception); log.warn("登录失败", exception);
R<Object> data = new R<>(HttpServletResponse.SC_BAD_REQUEST, "用户名或密码错误", false, null); R<Object> data = new R<>(HttpServletResponse.SC_BAD_REQUEST, "用户名或密码错误", false, null);
response.setContentType("application/json;charset=UTF-8"); response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(), data); objectMapper.writeValue(response.getWriter(), data);
} }
@Override @Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
log.info("用户「{}」登录成功", authentication.getName()); log.info("用户「{}」登录成功", authentication.getName());
// 生成双重Token // 生成双重Token
String accessToken = UUID.randomUUID().toString(); String accessToken = UUID.randomUUID().toString();
String refreshToken = UUID.randomUUID().toString(); String refreshToken = UUID.randomUUID().toString();
// 存储Access Token // 存储Access Token
tokenRedisStorage.putAccessToken(accessToken, authentication); tokenRedisStorage.putAccessToken(accessToken, authentication);
// 存储Refresh Token // 存储Refresh Token
String deviceId = extractDeviceId(request); String deviceId = extractDeviceId(request);
tokenRedisStorage.putRefreshToken(refreshToken, authentication.getName(), deviceId); tokenRedisStorage.putRefreshToken(refreshToken, authentication.getName(), deviceId);
// 构造登录响应 // 构造登录响应
LoginResponse loginResponse = new LoginResponse( LoginResponse loginResponse = new LoginResponse(
accessToken, accessToken,
refreshToken, refreshToken,
"Bearer", "Bearer",
securityProps.getDuration().getSeconds(), // 30分钟,秒数 securityProps.getDuration().getSeconds(), // 30分钟,秒数
authentication.getName() authentication.getName()
); );
response.setContentType("application/json;charset=UTF-8"); response.setContentType("application/json;charset=UTF-8");
R<LoginResponse> data = new R<>(HttpServletResponse.SC_OK, "登录成功", true, loginResponse); R<LoginResponse> data = new R<>(HttpServletResponse.SC_OK, "登录成功", true, loginResponse);
objectMapper.writeValue(response.getWriter(), data); objectMapper.writeValue(response.getWriter(), data);
} }
private String extractDeviceId(HttpServletRequest request) { private String extractDeviceId(HttpServletRequest request) {
// 尝试从User-Agent提取设备信息 // 尝试从User-Agent提取设备信息
String userAgent = request.getHeader("User-Agent"); String userAgent = request.getHeader("User-Agent");
if (userAgent != null) { if (userAgent != null) {
// 简单的设备识别逻辑,生产环境可以使用更复杂的识别算法 // 简单的设备识别逻辑,生产环境可以使用更复杂的识别算法
if (userAgent.contains("Mobile") || userAgent.contains("Android") || userAgent.contains("iPhone")) { if (userAgent.contains("Mobile") || userAgent.contains("Android") || userAgent.contains("iPhone")) {
return "mobile-" + request.getRemoteAddr(); return "mobile-" + request.getRemoteAddr();
} else if (userAgent.contains("Tablet") || userAgent.contains("iPad")) { } else if (userAgent.contains("Tablet") || userAgent.contains("iPad")) {
return "tablet-" + request.getRemoteAddr(); return "tablet-" + request.getRemoteAddr();
} else { } else {
return "desktop-" + request.getRemoteAddr(); return "desktop-" + request.getRemoteAddr();
} }
} }
return "unknown-" + request.getRemoteAddr(); return "unknown-" + request.getRemoteAddr();
} }
} }
private record CustomAccessDeniedHandler(ObjectMapper objectMapper) implements AccessDeniedHandler { private record CustomAccessDeniedHandler(ObjectMapper objectMapper) implements AccessDeniedHandler {
@Override @Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
log.warn("访问被拒绝", accessDeniedException); log.warn("访问被拒绝", accessDeniedException);
if (authentication.isAuthenticated()) { if (authentication.isAuthenticated()) {
log.warn("用户「{}」访问「{}」被拒绝,因为:{}", authentication.getPrincipal(), request.getRequestURI(), accessDeniedException.getMessage()); log.warn("用户「{}」访问「{}」被拒绝,因为:{}", authentication.getPrincipal(), request.getRequestURI(), accessDeniedException.getMessage());
} else { } else {
log.warn("匿名用户访问「{}」被拒绝,因为:{}", request.getRequestURI(), accessDeniedException.getMessage()); log.warn("匿名用户访问「{}」被拒绝,因为:{}", request.getRequestURI(), accessDeniedException.getMessage());
} }
R<Object> data = new R<>(HttpServletResponse.SC_FORBIDDEN, "无权限", false, null); R<Object> data = new R<>(HttpServletResponse.SC_FORBIDDEN, "无权限", false, null);
response.setContentType("application/json;charset=UTF-8"); response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(), data); objectMapper.writeValue(response.getWriter(), data);
} }
} }
private record CustomLogoutSuccessHandler(ObjectMapper objectMapper, private record CustomLogoutSuccessHandler(ObjectMapper objectMapper,
TokenRedisStorage tokenRedisStorage) implements LogoutHandler { TokenRedisStorage tokenRedisStorage) implements LogoutHandler {
@Override @Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
String header = request.getHeader(HttpHeaders.AUTHORIZATION); String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header != null && header.startsWith("Bearer ")) { if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7); String token = header.substring(7);
authentication = tokenRedisStorage.get(token); authentication = tokenRedisStorage.get(token);
tokenRedisStorage.remove(token); tokenRedisStorage.remove(token);
} }
if (authentication != null) { if (authentication != null) {
log.info("用户「{}」退出成功", authentication.getName()); log.info("用户「{}」退出成功", authentication.getName());
String all = request.getParameter("all"); String all = request.getParameter("all");
if ("true".equals(all)) { if ("true".equals(all)) {
tokenRedisStorage.removeByUserName(authentication.getName()); tokenRedisStorage.removeByUserName(authentication.getName());
} }
} }
R<Object> data = new R<>(HttpServletResponse.SC_OK, "退出成功", true, null); R<Object> data = new R<>(HttpServletResponse.SC_OK, "退出成功", true, null);
response.setContentType("application/json;charset=UTF-8"); response.setContentType("application/json;charset=UTF-8");
try { try {
objectMapper.writeValue(response.getWriter(), data); objectMapper.writeValue(response.getWriter(), data);
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
} }
} }
} }