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
相关产品推荐
相关产品推荐

