Spring Security 6.3.3 WebFlux 无效JWT触发CORS错误排查
环境配置
- Spring Boot 3.3
- Spring Security 6.3.3
- Angular 18
服务端与客户端部署在不同主机:
- Spring Boot应用运行于:http://api.example.com
- Angular应用运行于:http://client.example.com
问题描述
携带有效JWT令牌请求http://api.example.com/users/info时,请求可正常通过CORS过滤器与认证过滤器,在Postman和Chrome浏览器中均能获取用户信息JSON。
但使用无效JWT(如过期令牌)请求该接口时,虽返回预期的401未授权状态码,浏览器控制台却出现CORS错误,且无法获取错误响应体;而Postman中可正常获取错误响应体。
Spring Boot日志未提及任何CORS头缺失,仅记录两行日志:
- HTTP GET "/users/info"
- Completed 401 UNAUTHORIZED
日志配置如下:
logging: level: org.springframework.web: DEBUG org.springframework.security: DEBUG io.r2dbc.spi: DEBUG
本人Spring Boot开发经验不足,欢迎指正代码实现,已提供所有相关信息,恳请给出解决方案,感谢!
相关代码
SecurityConfiguration.java
@Configuration @EnableWebFluxSecurity @EnableConfigurationProperties(ResourceServiceSecurityProperties.class) @RequiredArgsConstructor public class SecurityConfiguration { private static final String[] AUTH_WHITELIST = { "/users/login" }; private final ResourceServiceSecurityProperties resourceServiceSecurityProperties; private final JwtAuthFilter jwtAuthFilter; private final CorsFilter corsFilter; @Bean public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { http // 禁用基础认证 .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable) // 禁用CSRF .csrf(ServerHttpSecurity.CsrfSpec::disable) // 禁用登录表单 .formLogin(ServerHttpSecurity.FormLoginSpec::disable) .logout(ServerHttpSecurity.LogoutSpec::disable) // 禁用Spring Security自带CORS,使用自定义CORS过滤器 .cors(ServerHttpSecurity.CorsSpec::disable) .authorizeExchange(authorizeRequests -> authorizeRequests .pathMatchers(HttpMethod.OPTIONS, "/**") .permitAll() .pathMatchers(HttpMethod.POST, AUTH_WHITELIST) .permitAll() .anyExchange() .authenticated() ) .addFilterAt(jwtAuthFilter, SecurityWebFiltersOrder.AUTHENTICATION) .addFilterAt(corsFilter, SecurityWebFiltersOrder.CORS) // 不允许会话 .securityContextRepository(NoOpServerSecurityContextRepository.getInstance()) .oauth2ResourceServer(oauth2ResourceServerCustomizer -> oauth2ResourceServerCustomizer .jwt(Customizer.withDefaults()) ); return http.build(); } @Bean public ReactiveJwtDecoder reactiveJwtDecoder() { return ReactiveJwtDecoders.fromIssuerLocation(resourceServiceSecurityProperties.getIssuerUri()); } }
CorsFilter.java
@Component public class CorsFilter implements WebFilter { @Override @NonNull public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); ServerHttpResponse response = exchange.getResponse(); if (request.getMethod().equals(HttpMethod.OPTIONS)) { response.setStatusCode(HttpStatus.ACCEPTED); return chain.filter(exchange); } request.mutate() .header("Access-Control-Allow-Origin", "http://client.example.com") .header("Access-Control-Allow-Credentials", "true") .header("Access-Control-Allow-Methods", "POST, GET, PUT, OPTIONS, DELETE") .header("Access-Control-Max-Age", "3600") .header("Access-Control-Allow-Headers", "Content-Type, authorization") .build(); return chain.filter(exchange.mutate().request(request).build()); } }
也尝试过不修改请求和exchange的实现方式
JwtAuthFilter.java
@Component @AllArgsConstructor public class JwtAuthFilter implements WebFilter { private final JwtUtil jwtUtil; private static final String[] AUTH_WHITELIST = { "/users/login" }; @Override @NonNull public Mono<Void> filter(@NonNull ServerWebExchange exchange, @NonNull WebFilterChain chain) { if (Arrays.asList(AUTH_WHITELIST).contains(exchange.getRequest().getPath().value())) { return chain.filter(exchange); } if (exchange.getRequest().getMethod().equals(HttpMethod.OPTIONS)) { exchange.getResponse().setStatusCode(HttpStatus.ACCEPTED); return chain.filter(exchange); } return Mono.justOrEmpty(exchange.getRequest().getHeaders().getFirst("Authorization")) .filter(authHeader -> authHeader.startsWith("Bearer ")) .switchIfEmpty( Mono.error( new InvalidBearerTokenException("The token provided is expired, malformed, revoked, or invalid for other reasons.") ) ) .map(authHeader -> authHeader.substring(7)) .map(jwtUtil::isExpired) .flatMap(isExpired -> { if (isExpired) { return Mono.error(new InvalidBearerTokenException("The token provided is expired, malformed, revoked, or invalid for other reasons.")); } return chain.filter(exchange); }); } }
ExceptionHandlerAdvice.java
@RestControllerAdvice @AllArgsConstructor //@Priority(0) @Order(-2) public class ExceptionHandlerAdvice implements ErrorWebExceptionHandler { private final ExceptionErrorFactory exceptionErrorFactory; private final ObjectMapper objectMapper; @ExceptionHandler(WebExchangeBindException.class) public ResponseEntity<ErrorDto> handleValidationExceptions(final WebExchangeBindException ex) { return new ResponseEntity<>( this.exceptionErrorFactory.createError(ex.getClass().getName(), ex), ex.getStatusCode()); } @ExceptionHandler(InvalidBearerTokenException.class) public ResponseEntity<ErrorDto> handleInvalidBearerTokenException(final InvalidBearerTokenException ex) { return new ResponseEntity<>( this.exceptionErrorFactory.createError(ex.getClass().getName()), HttpStatus.UNAUTHORIZED ); } @ExceptionHandler(ExpiredJwtException.class) public ResponseEntity<ErrorDto> handleExpiredBearerTokenException(final ExpiredJwtException ex) { return new ResponseEntity<>( this.exceptionErrorFactory.createError(ex.getClass().getName()), HttpStatus.UNAUTHORIZED ); } @ExceptionHandler(UnauthorisedException.class) public ResponseEntity<ErrorDto> handleUnauthorizedException(UnauthorisedException ex) { return new ResponseEntity<>( this.exceptionErrorFactory.createError(ex.getClass().getName(), ex), HttpStatus.UNAUTHORIZED ); } @Override @NonNull public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) { DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory(); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); ErrorDto errorResponse = null; if (ex.getClass().equals(ExpiredJwtException.class)) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); errorResponse = this.exceptionErrorFactory.createError(ex.getClass().getName()); } else if (ex.getClass().equals(InvalidBearerTokenException.class)) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); errorResponse = this.exceptionErrorFactory.createError(ex.getClass().getName()); } DataBuffer dataBuffer = null; try { dataBuffer = bufferFactory.wrap(objectMapper.writeValueAsBytes(errorResponse)); } catch (Exception e) { dataBuffer = bufferFactory.wrap("".getBytes()); } return exchange.getResponse().writeWith(Flux.just(dataBuffer)); // 也试过这段代码 // return exchange.getResponse().writeWith(Mono.just(dataBuffer)); } }
此外还尝试过以下CORS过滤器实现,但未解决问题,仅能在浏览器中看到预检请求(OPTIONS):
@Bean CorsConfigurationSource corsConfiguration() { CorsConfiguration configuration = new CorsConfiguration(); configuration.addAllowedOrigin("http://client.example.com"); configuration.setAllowCredentials(true); configuration.setMaxAge(3600L); configuration.addAllowedMethod("GET"); configuration.addAllowedMethod("PUT"); configuration.addAllowedMethod("POST"); configuration.addAllowedMethod("DELETE"); configuration.addAllowedMethod("OPTIONS"); configuration.addAllowedHeader("Content-Type"); configuration.addAllowedHeader("authorization"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } @Bean public CorsWebFilter corsWebFilter() { return new CorsWebFilter(corsConfiguration()); }
解决方案
核心问题分析
问题出在CORS过滤器的执行顺序和实现错误:
- 当前自定义
CorsFilter是修改请求头而非响应头,CORS规范要求这些头必须添加到响应中,请求头修改完全无效。 - 即使修复头的位置,当前配置中
CorsFilter的执行顺序在JwtAuthFilter之后(SecurityWebFiltersOrder.CORS在AUTHENTICATION之后),当JwtAuthFilter抛出异常时,CORS过滤器还没执行,导致401响应中没有CORS头,浏览器拦截响应并报错。
具体修复步骤
1. 移除自定义CorsFilter,改用Spring Security自带CORS配置
删除自定义的CorsFilter.java,然后修改SecurityConfiguration.java,启用Spring Security的CORS支持:
@Configuration @EnableWebFluxSecurity @EnableConfigurationProperties(ResourceServiceSecurityProperties.class) @RequiredArgsConstructor public class SecurityConfiguration { private static final String[] AUTH_WHITELIST = { "/users/login" }; private final ResourceServiceSecurityProperties resourceServiceSecurityProperties; private final JwtAuthFilter jwtAuthFilter; @Bean public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) { http .httpBasic(ServerHttpSecurity.HttpBasicSpec::disable) .csrf(ServerHttpSecurity.CsrfSpec::disable) .formLogin(ServerHttpSecurity.FormLoginSpec::disable) .logout(ServerHttpSecurity.LogoutSpec::disable) // 启用Spring Security自带CORS,使用下面配置的规则 .cors(cors -> cors.configurationSource(corsConfiguration())) .authorizeExchange(authorizeRequests -> authorizeRequests .pathMatchers(HttpMethod.OPTIONS, "/**").permitAll() .pathMatchers(HttpMethod.POST, AUTH_WHITELIST).permitAll() .anyExchange().authenticated() ) .addFilterAt(jwtAuthFilter, SecurityWebFiltersOrder.AUTHENTICATION) .securityContextRepository(NoOpServerSecurityContextRepository.getInstance()) .oauth2ResourceServer(oauth2ResourceServerCustomizer -> oauth2ResourceServerCustomizer .jwt(Customizer.withDefaults()) ); return http.build(); } @Bean public CorsConfigurationSource corsConfiguration() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(Collections.singletonList("http://client.example.com")); configuration.setAllowCredentials(true); configuration.setMaxAge(3600L); configuration.setAllowedMethods(Arrays.asList("GET", "PUT", "POST", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(Arrays.asList("Content-Type", "Authorization")); // 暴露响应头,确保错误响应的头能被浏览器访问 configuration.setExposedHeaders(Arrays.asList("Authorization")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } @Bean public ReactiveJwtDecoder reactiveJwtDecoder() { return ReactiveJwtDecoders.fromIssuerLocation(resourceServiceSecurityProperties.getIssuerUri()); } }
2. 修复异常处理器,确保CORS头被添加到错误响应中
修改ExceptionHandlerAdvice.java的handle方法,添加CORS头到响应:
@Override @NonNull public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) { ServerHttpResponse response = exchange.getResponse(); DataBufferFactory bufferFactory = response.bufferFactory(); // 添加CORS头 response.getHeaders().add("Access-Control-Allow-Origin", "http://client.example.com"); response.getHeaders().add("Access-Control-Allow-Credentials", "true"); response.getHeaders().setContentType(MediaType.APPLICATION_JSON); ErrorDto errorResponse = null; if (ex.getClass().equals(ExpiredJwtException.class)) { response.setStatusCode(HttpStatus.UNAUTHORIZED); errorResponse = this.exceptionErrorFactory.createError(ex.getClass().getName()); } else if (ex.getClass().equals(InvalidBearerTokenException.class)) { response.setStatusCode(HttpStatus.UNAUTHORIZED); errorResponse = this.exceptionErrorFactory.createError(ex.getClass().getName()); } else { // 处理其他异常 response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR); errorResponse = this.exceptionErrorFactory.createError("Unknown error"); } DataBuffer dataBuffer; try { dataBuffer = bufferFactory.wrap(objectMapper.writeValueAsBytes(errorResponse)); } catch (Exception e) { dataBuffer = bufferFactory.wrap("{}".getBytes()); } return response.writeWith(Mono.just(dataBuffer)); }
3. 移除JwtAuthFilter中的OPTIONS请求处理
SecurityConfiguration中已经配置了OPTIONS请求允许通过,无需在JwtAuthFilter中重复处理,删除以下代码:
if (exchange.getRequest().getMethod().equals(HttpMethod.OPTIONS)) { exchange.getResponse().setStatusCode(HttpStatus.ACCEPTED); return chain.filter(exchange); }
验证修复
- 重启Spring Boot应用。
- 使用过期JWT请求
/users/info,检查浏览器控制台是否还有CORS错误,同时确认能获取到401响应体。 - 验证有效JWT请求仍能正常返回用户信息。
内容的提问来源于stack exchange,提问作者gs_it
相关产品推荐
相关产品推荐

