如何通过Spring Cloud Gateway保障微服务WebSocket端点安全?
背景
基于微服务架构,前端部署Spring Cloud Gateway,安全控制集中在网关层:
- 已配置OIDC+OAuth2实现认证授权,网关同时作为OAuth客户端和资源服务器,采用授权码流程
- 通过TokenRelay过滤器将认证信息传递给下游服务,已启用CORS和默认CSRF防护
核心问题
常规HTTP请求正常工作:下游服务通过TokenRelay添加的Authorization header认证,并跳过CSRF校验(信任网关)。但WebSocket连接无法复用该逻辑:
- 启用
@EnableWebsocketSecurity时,强制CSRF校验,但传递网关生成的token会触发InvalidCsrfTokenException(下游服务有独立token仓库) - 禁用WebSocket安全时,隐式关闭CSRF,网关token不影响连接
期望:让WebSocket实现与HTTP请求一致的安全逻辑,不想搭建繁琐的集中式token仓库。当前临时方案是禁用微服务的CSRF,依赖Spring Security/Spring Websockets的服务器端Origin头校验(同源策略)。
补充疑问:HTTP请求中存在Bearer token时,CsrfFilter会跳过校验(因微服务是OAuth2资源服务器),但WebSocket无此逻辑,这是设计如此还是Bug?
相关代码
下游服务配置
@Configuration @EnableWebSecurity @EnableMethodSecurity @EnableWebSocketSecurity public class DownstreamServiceSecurityConfiguration { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) .csrf(csrfSpec -> csrfSpec.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) .oauth2ResourceServer((oauth2) -> oauth2.jwt(withDefaults())); return http.build(); } .....
网关配置
@Configuration @EnableWebFluxSecurity public class GatewaySecurityConfiguration { @Autowired public SecurityConfiguration(ReactiveClientRegistrationRepository clientRegistrationRepository) { this.clientRegistrationRepository = clientRegistrationRepository; } ReactiveClientRegistrationRepository clientRegistrationRepository; private ServerLogoutSuccessHandler serverLogoutSuccessHandler() { OidcClientInitiatedServerLogoutSuccessHandler successHandler = new OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository); //needs to match the one declared at auth-server successHandler.setPostLogoutRedirectUri("{baseUrl}/api-docs"); return successHandler; } @Bean public SecurityWebFilterChain filterChain(ServerHttpSecurity http){ http.cors(withDefaults()) // add csrf token that can be handled by js clients by using CookieServerCsrfTokenRepository.withHttpOnlyFalse() .csrf(csrfSpec -> csrfSpec.csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse()) .csrfTokenRequestHandler(new SpaServerCsrfTokenRequestHandler())) .authorizeExchange(authorizeExchangeSpec -> authorizeExchangeSpec.pathMatchers("/api-docs/**", "/swagger-ui.html", "/webjars/swagger-ui/**", "/actuator/**", "/oidc/**").permitAll() .pathMatchers("/notification/ws-connect").hasAuthority("SCOPE_customer-read") .anyExchange().authenticated()) //handle oidc authentication by redirecting to auth server login page .oauth2Login(withDefaults()) //make gateway also a resource server that supports jwt bearer token, as the default configuration does not kick in because we also have the client dependency on the classpath .oauth2ResourceServer(oAuth2ResourceServerSpec -> oAuth2ResourceServerSpec.jwt(withDefaults())) .logout(httpSecurityLogoutConfigurer -> httpSecurityLogoutConfigurer.logoutSuccessHandler(serverLogoutSuccessHandler())); return http.build(); } //When storing the expected CSRF token in a cookie, JavaScript applications will only have access to the plain token value and will not have access to the encoded value. //A customized request handler for resolving the actual token value will need to be provided. static final class SpaServerCsrfTokenRequestHandler extends ServerCsrfTokenRequestAttributeHandler { private final ServerCsrfTokenRequestAttributeHandler delegate = new XorServerCsrfTokenRequestAttributeHandler(); @Override public void handle(ServerWebExchange exchange, Mono<CsrfToken> csrfToken) { // Always use XorCsrfTokenRequestAttributeHandler to provide BREACH protection of the CsrfToken when it is rendered in the response body. this.delegate.handle(exchange, csrfToken); } @Override public Mono<String> resolveCsrfTokenValue(ServerWebExchange exchange, CsrfToken csrfToken) { final var hasHeader = exchange.getRequest().getHeaders().get(csrfToken.getHeaderName()) !=null; return hasHeader ? super.resolveCsrfTokenValue(exchange, csrfToken) : this.delegate.resolveCsrfTokenValue(exchange, csrfToken); } } @Bean //Needed in order to set the XSRF-TOKEN cookie public WebFilter csrfCookieWebFilter() { return (exchange, chain) -> { exchange.getAttributeOrDefault(CsrfToken.class.getName(), Mono.empty()).subscribe(o -> ((CsrfToken)o).getToken()); return chain.filter(exchange); }; } @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(List.of("http://localhost:63342")); configuration.setAllowedMethods(List.of(CorsConfiguration.ALL)); configuration.setAllowCredentials(true); configuration.setAllowedHeaders(List.of(CorsConfiguration.ALL)); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } }
客户端代码
const stompClient = new StompJs.Client({ brokerURL: 'ws://localhost:9990/notification/ws-connect', connectHeaders : {"X-XSRF-TOKEN":csrfToken} });
错误信息
org.springframework.security.web.csrf.InvalidCsrfTokenException: Invalid CSRF Token 'd1f38e4e-71cc-4241-8c7e-3b01c1937c7c' was found on the request parameter '_csrf' or header 'X-XSRF-TOKEN'
架构示意图

解决方案建议
方案1:下游服务WebSocket安全中跳过CSRF校验(信任网关)
既然HTTP请求中已通过TokenRelay传递JWT,且下游服务信任网关的认证,那么可以对WebSocket请求做同样的信任处理:
在下游服务的WebSocket安全配置中,添加自定义CSRF校验逻辑,当请求带有网关转发的JWT时,跳过CSRF校验。
修改下游服务配置:
@Configuration @EnableWebSocketSecurity public class WebSocketSecurityConfig { @Bean public WebSocketSecurityFilterChain webSocketSecurityFilterChain(WebSocketSecurity webSocket) throws Exception { webSocket.authorizeMessages(messages -> messages.anyMessage().authenticated()) .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // 自定义CSRF校验器,当存在Authorization头时跳过校验 .csrfTokenValidator(new CsrfTokenValidator() { @Override public void validate(HttpServletRequest request, CsrfToken csrfToken) { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { // 存在JWT,跳过CSRF校验 return; } // 否则执行默认校验 new DefaultCsrfTokenValidator().validate(request, csrfToken); } })); return webSocket.build(); } }
方案2:网关统一处理WebSocket的CSRF,下游服务禁用WebSocket CSRF
保持网关的CSRF校验,下游服务对WebSocket请求关闭CSRF校验,同时依赖Origin头校验和JWT认证保障安全:
- 下游服务移除
@EnableWebSocketSecurity,或在WebSocket安全配置中禁用CSRF:
@Configuration @EnableWebSocketSecurity public class WebSocketSecurityConfig { @Bean public WebSocketSecurityFilterChain webSocketSecurityFilterChain(WebSocketSecurity webSocket) throws Exception { webSocket.authorizeMessages(messages -> messages.anyMessage().authenticated()) .csrf(csrf -> csrf.disable()); return webSocket.build(); } }
- 确保网关已对WebSocket路径做权限校验(如网关配置中的
pathMatchers("/notification/ws-connect").hasAuthority("SCOPE_customer-read")),且下游服务仅接受来自网关的请求(可通过网络策略或IP白名单加固)。
关于补充疑问的解答
这是设计如此:Spring Security中,HTTP的CsrfFilter会检测请求是否携带Bearer Token(OAuth2资源服务器场景),并自动跳过CSRF校验,因为JWT本身已具备抗CSRF的能力(无法通过Cookie自动携带)。但WebSocket的安全逻辑是独立实现的,目前没有内置的类似跳过逻辑,需要手动配置。
内容的提问来源于stack exchange,提问作者IonutB

