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

Spring Cloud Gateway 403处理异常:authenticationEntryPoint与accessDeniedHandler同时调用

Spring Security OAuth2资源服务器403错误时双处理器同时触发问题

问题描述

基于Spring Cloud Gateway、Spring Security及OAuth2 Resource Server的WebFlux项目中,发生403权限拒绝错误时,自定义的authenticationEntryPoint和accessDeniedHandler会同时被调用并输出日志;但401未认证错误时,仅authenticationEntryPoint正常触发。需要实现401仅触发认证失败处理器,403仅触发权限拒绝处理器。

技术栈

  • Spring Cloud Gateway
  • Spring Security (WebFlux)
  • OAuth2 Resource Server (JWT)

复现项目配置

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'

    implementation 'org.springframework.cloud:spring-cloud-starter-gateway'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'io.projectreactor:reactor-test'
    //testImplementation 'org.springframework.security:spring-security-test'
}

application.yml

spring:
  application:
    name: api-gateway-demo
  profiles:
    active: dev
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: ${KEYCLOAK_SERVER_URL:http://localhost:9090}/realms/ecommerce
  cloud:
    gateway:
      routes:
        - id: ORDER-SERVICE
          uri: http://localhost:3001
          predicates:
            - Path=/order/**
        - id: USER-SERVICE
          uri: http://localhost:8082
          predicates:
            - Path=/users/**

server:
  port: 8045

SecurityConfig.class

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.web.server.SecurityWebFilterChain;

@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http,
                                                            CustomAuthenticationEntryPoint customAuthenticationEntryPoint,
                                                            CustomAccessDeniedHandler customAccessDeniedHandler) {
        return http
                .csrf(ServerHttpSecurity.CsrfSpec::disable)
                .authorizeExchange(
                        (item) -> item
                                .pathMatchers("/order/**").hasRole("user")
                                .anyExchange().authenticated())
                .oauth2ResourceServer(oauth2 -> oauth2
                        .jwt(Customizer.withDefaults())
                        .accessDeniedHandler(customAccessDeniedHandler)
                        .authenticationEntryPoint(customAuthenticationEntryPoint)
                )
                /*.exceptionHandling(exceptionHandling -> exceptionHandling
                        .authenticationEntryPoint(customAuthenticationEntryPoint)
                        .accessDeniedHandler(customAccessDeniedHandler)
                )*/
                .build();
    }

}

CustomAuthenticationEntryPoint.class

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.HashMap;
import java.util.Map;

@Component
public class CustomAuthenticationEntryPoint implements ServerAuthenticationEntryPoint {
    private static final Logger logger = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class);

    @Override
    public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException ex) {

        logger.error("Unauthorized error: {}", ex.getMessage());

        var request =  exchange.getRequest();
        var response =  exchange.getResponse();

        response.setStatusCode(HttpStatus.UNAUTHORIZED);

        final Map<String, Object> body = new HashMap<>();
        body.put("status", HttpStatus.UNAUTHORIZED);
        body.put("error", "Unauthorized");
        body.put("message", ex.getMessage());
        body.put("path", request.getURI().getPath());
        response.getHeaders().add(HttpHeaders.CONTENT_TYPE, "application/json");
        byte[] bytes;
        var objectMapper = new ObjectMapper();
        try {
           bytes = objectMapper.writeValueAsBytes(body);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
        DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
        return exchange.getResponse().writeWith(Flux.just(buffer));
    }

}

CustomAccessDeniedHandler.class

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.HashMap;
import java.util.Map;

@Component
public class CustomAccessDeniedHandler implements ServerAccessDeniedHandler {

    private static final Logger logger = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);

    @Override
    public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException ex) {

        logger.error("Denied error: {}", ex.getMessage());
        var request =  exchange.getRequest();
        var response =  exchange.getResponse();

        response.setStatusCode(HttpStatus.FORBIDDEN);

        final Map<String, Object> body = new HashMap<>();
        body.put("status", HttpStatus.FORBIDDEN);
        body.put("error", "FORBIDDEN");
        body.put("message", ex.getMessage());
        body.put("path", request.getURI().getPath());
        response.getHeaders().add(HttpHeaders.CONTENT_TYPE, "application/json");
        byte[] bytes;
        var objectMapper = new ObjectMapper();
        try {
            bytes = objectMapper.writeValueAsBytes(body);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
        DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
        return exchange.getResponse().writeWith(Flux.just(buffer));
    }
}

现象

  • 403错误场景:两个处理器日志同时输出
2024-01-12T12:11:54.324-05:00 ERROR 46086 --- [api-gateway-demo] [  parallel-5] c.e.d.CustomAuthenticationEntryPoint  : Unauthorized error: Not Authenticated
2024-01-12T12:11:54.326-05:00 ERROR 46086 --- [api-gateway-demo] [  parallel-5] c.e.d.CustomAccessDeniedHandler   : Denied error: Access Denied
  • 401错误场景:仅CustomAuthenticationEntryPoint输出日志
2024-01-12T15:41:47.251-05:00 ERROR 50099 --- [api-gateway-demo] [     parallel-1] c.e.d.CustomAuthenticationEntryPoint     : Unauthorized error: Not Authenticated

需求

  • 401未认证错误:仅触发CustomAuthenticationEntryPoint并记录日志
  • 403权限拒绝错误:仅触发CustomAccessDeniedHandler并记录日志

解决方案思路

1. 避免重复配置处理器

不要同时在oauth2ResourceServer和exceptionHandling块中配置这两个处理器,建议统一在exceptionHandling中全局配置,避免WebFlux Security链中异常处理逻辑冲突:

@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http,
                                                        CustomAuthenticationEntryPoint customAuthenticationEntryPoint,
                                                        CustomAccessDeniedHandler customAccessDeniedHandler) {
    return http
            .csrf(ServerHttpSecurity.CsrfSpec::disable)
            .authorizeExchange(
                    (item) -> item
                            .pathMatchers("/order/**").hasRole("user")
                            .anyExchange().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2
                    .jwt(Customizer.withDefaults())
            )
            .exceptionHandling(exceptionHandling -> exceptionHandling
                    .authenticationEntryPoint(customAuthenticationEntryPoint)
                    .accessDeniedHandler(customAccessDeniedHandler)
            )
            .build();
}

2. 在自定义处理器中增加响应状态判断

修改两个自定义处理器,先检查响应是否已经被处理(状态码已设置或响应已提交),如果已处理则直接返回空,避免重复处理:

修改CustomAuthenticationEntryPoint

@Override
public Mono<Void> commence(ServerWebExchange exchange, AuthenticationException ex) {
    // 若响应已处理,直接返回,不重复执行
    if (exchange.getResponse().isCommitted() || exchange.getResponse().getStatusCode() != null) {
        return Mono.empty();
    }

    logger.error("Unauthorized error: {}", ex.getMessage());
    var request = exchange.getRequest();
    var response = exchange.getResponse();

    response.setStatusCode(HttpStatus.UNAUTHORIZED);
    final Map<String, Object> body = new HashMap<>();
    body.put("status", HttpStatus.UNAUTHORIZED);
    body.put("error", "Unauthorized");
    body.put("message", ex.getMessage());
    body.put("path", request.getURI().getPath());
    response.getHeaders().add(HttpHeaders.CONTENT_TYPE, "application/json");
    
    byte[] bytes;
    var objectMapper = new ObjectMapper();
    try {
       bytes = objectMapper.writeValueAsBytes(body);
    } catch (JsonProcessingException e) {
        throw new RuntimeException(e);
    }
    DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
    return exchange.getResponse().writeWith(Flux.just(buffer));
}

修改CustomAccessDeniedHandler

@Override
public Mono<Void> handle(ServerWebExchange exchange, AccessDeniedException ex) {
    // 若响应已处理,直接返回,不重复执行
    if (exchange.getResponse().isCommitted() || exchange.getResponse().getStatusCode() != null) {
        return Mono.empty();
    }

    logger.error("Denied error: {}", ex.getMessage());
    var request = exchange.getRequest();
    var response = exchange.getResponse();

    response.setStatusCode(HttpStatus.FORBIDDEN);
    final Map<String, Object> body = new HashMap<>();
    body.put("status", HttpStatus.FORBIDDEN);
    body.put("error", "FORBIDDEN");
    body.put("message", ex.getMessage());
    body.put("path", request.getURI().getPath());
    response.getHeaders().add(HttpHeaders.CONTENT_TYPE, "application/json");
    
    byte[] bytes;
    var objectMapper = new ObjectMapper();
    try {
        bytes = objectMapper.writeValueAsBytes(body);
    } catch (JsonProcessingException e) {
        throw new RuntimeException(e);
    }
    DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
    return exchange.getResponse().writeWith(Flux.just(buffer));
}

3. 排查异常流转根源

若上述方案无效,可打印异常栈信息,排查403场景下是否有AuthenticationException被先抛出,再转为AccessDeniedException的情况,定位Security链中异常触发的具体环节,调整过滤器顺序或异常处理逻辑。


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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.02 12:23:12