You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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过滤器的执行顺序和实现错误:

  1. 当前自定义CorsFilter是修改请求头而非响应头,CORS规范要求这些头必须添加到响应中,请求头修改完全无效。
  2. 即使修复头的位置,当前配置中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);
}

验证修复

  1. 重启Spring Boot应用。
  2. 使用过期JWT请求/users/info,检查浏览器控制台是否还有CORS错误,同时确认能获取到401响应体。
  3. 验证有效JWT请求仍能正常返回用户信息。

内容的提问来源于stack exchange,提问作者gs_it

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 04:35:01