WebSocket 安全
Spring Security 4 新增了对 Spring WebSocket 支持 的安全保护功能。本节将介绍如何使用 Spring Security 的 WebSocket 支持。
WebSocket 认证
WebSocket 会复用建立 WebSocket 连接时 HTTP 请求中的认证信息。这意味着 HttpServletRequest 中的 Principal 将被传递给 WebSocket。如果您正在使用 Spring Security,HttpServletRequest 中的 Principal 会被自动覆盖。
更具体地说,要确保用户已通过WebSocket应用程序的身份验证,只需确保将Spring Security配置为对基于HTTP的Web应用程序进行身份验证即可。
WebSocket 授权
Spring Security 4.0 通过 Spring Messaging 抽象引入了对 WebSockets 的授权支持。
在 Spring Security 5.8 中,此支持已更新为使用 AuthorizationManager API。
要使用 Java 配置来配置授权,只需包含 @EnableWebSocketSecurity 注解并发布一个 AuthorizationManager<Message<?>> bean,或者在 XML 中使用 use-authorization-manager 属性。一种实现方式是使用 AuthorizationManagerMessageMatcherRegistry 来指定端点模式,如下所示:
- Java
- Kotlin
- Xml
@Configuration
@EnableWebSocketSecurity // <1> // <2>
public class WebSocketSecurityConfig {
@Bean
AuthorizationManager<Message<?>> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages) {
messages
.simpDestMatchers("/user/**").hasRole("USER") 3
return messages.build();
}
}
@Configuration
@EnableWebSocketSecurity // <1> // <2>
open class WebSocketSecurityConfig { // <1> // <2>
@Bean
fun messageAuthorizationManager(messages: MessageMatcherDelegatingAuthorizationManager.Builder): AuthorizationManager<Message<*>> {
messages.simpDestMatchers("/user/**").hasRole("USER") // <3>
return messages.build()
}
}
<websocket-message-broker use-authorization-manager="true"> // <1> // <2>
<intercept-message pattern="/user/**" access="hasRole('USER')"/> // <3>
</websocket-message-broker>
任何入站 CONNECT 消息都需要有效的 CSRF 令牌来强制执行同源策略。
对于任何入站请求,
SecurityContextHolder都会使用simpUser头部属性中的用户信息进行填充。我们的消息需要适当的授权。具体来说,任何以
/user/开头的入站消息都需要ROLE_USER权限。您可以在WebSocket 授权中找到更多关于授权的详细信息。
自定义授权
在使用 AuthorizationManager 时,定制化非常简单。例如,你可以发布一个 AuthorizationManager,要求所有消息都具有 "USER" 角色,这可以通过 AuthorityAuthorizationManager 实现,如下所示:
- Java
- Kotlin
- Xml
@Configuration
@EnableWebSocketSecurity // <1> // <2>
public class WebSocketSecurityConfig {
@Bean
AuthorizationManager<Message<?>> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages) {
return AuthorityAuthorizationManager.hasRole("USER");
}
}
@Configuration
@EnableWebSocketSecurity // <1> // <2>
open class WebSocketSecurityConfig {
@Bean
fun messageAuthorizationManager(messages: MessageMatcherDelegatingAuthorizationManager.Builder): AuthorizationManager<Message<*>> {
return AuthorityAuthorizationManager.hasRole("USER") 3
}
}
<bean id="authorizationManager" class="org.example.MyAuthorizationManager"/>
<websocket-message-broker authorization-manager-ref="myAuthorizationManager"/>
有多种方法可以进一步匹配消息,如下面的更高级示例所示:
- Java
- Kotlin
- Xml
@Configuration
public class WebSocketSecurityConfig {
@Bean
public AuthorizationManager<Message<?>> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages) {
messages
.nullDestMatcher().authenticated() 1
.simpSubscribeDestMatchers("/user/queue/errors").permitAll() 2
.simpDestMatchers("/app/**").hasRole("USER") 3
.simpSubscribeDestMatchers("/user/**", "/topic/friends/*").hasRole("USER") 4
.simpTypeMatchers(MESSAGE, SUBSCRIBE).denyAll() 5
.anyMessage().denyAll(); 6
return messages.build();
}
}
@Configuration
open class WebSocketSecurityConfig {
fun messageAuthorizationManager(messages: MessageMatcherDelegatingAuthorizationManager.Builder): AuthorizationManager<Message<*>> {
messages
.nullDestMatcher().authenticated() 1
.simpSubscribeDestMatchers("/user/queue/errors").permitAll() 2
.simpDestMatchers("/app/**").hasRole("USER") // <3>
.simpSubscribeDestMatchers("/user/**", "/topic/friends/*").hasRole("USER") // <4>
.simpTypeMatchers(MESSAGE, SUBSCRIBE).denyAll() // <5>
.anyMessage().denyAll() // <6>
return messages.build();
}
}
<websocket-message-broker use-authorization-manager="true">
1
<intercept-message type="CONNECT" access="permitAll" />
<intercept-message type="UNSUBSCRIBE" access="permitAll" />
<intercept-message type="DISCONNECT" access="permitAll" />
<intercept-message pattern="/user/queue/errors" type="SUBSCRIBE" access="permitAll" /> 2
<intercept-message pattern="/app/**" access="hasRole('USER')" /> // <3>
// <4>
<intercept-message pattern="/user/**" type="SUBSCRIBE" access="hasRole('USER')" />
<intercept-message pattern="/topic/friends/*" type="SUBSCRIBE" access="hasRole('USER')" />
// <5>
<intercept-message type="MESSAGE" access="denyAll" />
<intercept-message type="SUBSCRIBE" access="denyAll" />
<intercept-message pattern="/**" access="denyAll" /> // <6>
</websocket-message-broker>
这将确保:
任何没有目标地址的消息(即除 MESSAGE 或 SUBSCRIBE 类型之外的消息)都需要用户进行身份验证
任何人都可以订阅 /user/queue/errors
任何目标地址以 "/app/" 开头的消息都需要用户拥有 ROLE_USER 角色
任何以 "/user/" 或 "/topic/friends/" 开头且类型为 SUBSCRIBE 的消息都需要 ROLE_USER 角色
任何其他类型为 MESSAGE 或 SUBSCRIBE 的消息都将被拒绝。由于第 6 条的存在,我们不需要此步骤,但它说明了如何匹配特定的消息类型。
任何其他消息都将被拒绝。这是一个好主意,以确保不会遗漏任何消息。
迁移SpEL表达式
如果你正在从旧版本的 Spring Security 迁移,你的目标匹配器可能包含 SpEL 表达式。建议将这些表达式改为使用 AuthorizationManager 的具体实现,因为这样可以独立进行测试。
然而,为了简化迁移过程,你也可以使用如下类:
public final class MessageExpressionAuthorizationManager implements AuthorizationManager<MessageAuthorizationContext<?>> {
private SecurityExpressionHandler<Message<?>> expressionHandler = new DefaultMessageSecurityExpressionHandler();
private Expression expression;
public MessageExpressionAuthorizationManager(String expressionString) {
Assert.hasText(expressionString, "expressionString cannot be empty");
this.expression = this.expressionHandler.getExpressionParser().parseExpression(expressionString);
}
@Override
public AuthorizationResult authorize(Supplier<Authentication> authentication, MessageAuthorizationContext<?> context) {
EvaluationContext ctx = this.expressionHandler.createEvaluationContext(authentication, context.getMessage());
boolean granted = ExpressionUtils.evaluateAsBoolean(this.expression, ctx);
return new ExpressionAuthorizationDecision(granted, this.expression);
}
}
并为你无法迁移的每个匹配器指定一个实例:
- Java
- Kotlin
@Configuration
public class WebSocketSecurityConfig {
@Bean
public AuthorizationManager<Message<?>> messageAuthorizationManager(MessageMatcherDelegatingAuthorizationManager.Builder messages) {
messages
// ...
.simpSubscribeDestMatchers("/topic/friends/{friend}").access(new MessageExpressionAuthorizationManager("#friends == 'john"));
// ...
return messages.build();
}
}
@Configuration
open class WebSocketSecurityConfig {
fun messageAuthorizationManager(messages: MessageMatcherDelegatingAuthorizationManager.Builder): AuthorizationManager<Message<?> {
messages
// ..
.simpSubscribeDestMatchers("/topic/friends/{friends}").access(MessageExpressionAuthorizationManager("#friends == 'john"))
// ...
return messages.build()
}
}
WebSocket 授权说明
要正确保护您的应用程序,您需要了解 Spring 的 WebSocket 支持。
WebSocket 消息类型授权说明
你需要理解 SUBSCRIBE 和 MESSAGE 这两种消息类型的区别,以及它们在 Spring 框架中是如何工作的。
考虑一个聊天应用:
-
系统可以通过目标地址
/topic/system/notifications向所有用户发送MESSAGE通知。 -
客户端可以通过
SUBSCRIBE订阅/topic/system/notifications来接收通知。
虽然我们希望客户端能够订阅 /topic/system/notifications,但我们不希望允许它们向该目的地发送消息。如果允许向 /topic/system/notifications 发送消息,客户端将能够直接向该端点发送消息并冒充系统。
通常,应用程序会拒绝发送到以代理前缀(/topic/ 或 /queue/)开头的目的地的任何 MESSAGE。
WebSocket 对目的地的授权
您还需要了解目的地是如何转换的。
考虑一个聊天应用:
-
用户可以通过向
/app/chat目标发送消息来向特定用户发送消息。 -
应用程序接收到消息后,会确保
from属性被指定为当前用户(我们不能信任客户端)。 -
然后,应用程序通过使用
SimpMessageSendingOperations.convertAndSendToUser("toUser", "/queue/messages", message)将消息发送给接收者。 -
该消息会被转换为
/queue/user/messages-<sessionid>这样的目标地址。
通过这个聊天应用,我们希望让客户端监听 /user/queue,该地址会被转换为 /queue/user/messages-<sessionid>。但是,我们不希望客户端能够监听 /queue/*,因为那样会让客户端看到所有用户的消息。
通常,应用程序会拒绝任何发送给以代理前缀(/topic/ 或 /queue/)开头的消息的 SUBSCRIBE 请求。我们可能会提供一些例外情况,以处理诸如
出站消息
Spring Framework 参考文档中包含一个名为"消息流"的章节,描述了消息在系统中的流动方式。请注意,Spring Security 仅保护 clientInboundChannel。Spring Security 不会尝试保护 clientOutboundChannel。
最主要的原因在于性能。对于每条输入消息,通常会产生更多输出消息。与其保护出站消息,我们建议保护对端点的订阅。
强制同源策略
请注意,浏览器不会对 WebSocket 连接强制执行同源策略。这是一个极其重要的考量因素。
为何需要同源?
考虑以下场景。用户访问 bank.com 并登录其账户。同一用户在浏览器中打开另一个标签页并访问 evil.com。同源策略确保 evil.com 无法读取或写入 bank.com 的数据。
使用 WebSocket 时,同源策略并不适用。实际上,除非 bank.com 明确禁止,否则 evil.com 可以代表用户读取和写入数据。这意味着,用户通过 WebSocket 能做的任何事情(例如转账),evil.com 都可以代表该用户执行。
由于 SockJS 试图模拟 WebSocket,它同样绕过了同源策略。这意味着当开发者使用 SockJS 时,需要明确保护其应用程序免受外部域名的访问。
Spring WebSocket 允许的来源
为 Stomp 头部添加 CSRF 保护
默认情况下,Spring Security 要求在任何 CONNECT 消息类型中都包含 CSRF 令牌。这确保了只有能够访问 CSRF 令牌的站点才能建立连接。由于只有同源站点才能访问 CSRF 令牌,因此不允许外部域建立连接。
通常我们需要将CSRF令牌包含在HTTP头部或HTTP参数中。然而,SockJS不支持这些选项。因此,我们必须将令牌包含在Stomp头部中。
应用程序可以通过访问名为 _csrf 的请求属性来获取 CSRF 令牌。例如,以下代码允许在 JSP 中访问 CsrfToken:
var headerName = "${_csrf.headerName}";
var token = "${_csrf.token}";
如果您使用静态HTML,可以在REST端点上暴露CsrfToken。例如,以下代码将在/csrf URL上暴露CsrfToken:
- Java
- Kotlin
@RestController
public class CsrfController {
@RequestMapping("/csrf")
public CsrfToken csrf(CsrfToken token) {
return token;
}
}
@RestController
class CsrfController {
@RequestMapping("/csrf")
fun csrf(token: CsrfToken): CsrfToken {
return token
}
}
JavaScript 可以向该端点发起 REST 调用,并使用响应来填充 headerName 和令牌。
现在,我们可以在 Stomp 客户端中包含令牌:
...
var headers = {};
headers[headerName] = token;
stompClient.connect(headers, function(frame) {
...
})
在 WebSocket 中禁用 CSRF
目前,在使用 @EnableWebSocketSecurity 时,CSRF 是不可配置的,不过这个功能可能会在未来的版本中添加。
要禁用 CSRF,你可以不使用 @EnableWebSocketSecurity,而是采用 XML 配置支持或自行添加 Spring Security 组件,如下所示:
- Java
- Kotlin
- Xml
@Configuration
public class WebSocketSecurityConfig implements WebSocketMessageBrokerConfigurer {
private final ApplicationContext applicationContext;
private final AuthorizationManager<Message<?>> authorizationManager;
public WebSocketSecurityConfig(ApplicationContext applicationContext, AuthorizationManager<Message<?>> authorizationManager) {
this.applicationContext = applicationContext;
this.authorizationManager = authorizationManager;
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(new AuthenticationPrincipalArgumentResolver());
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
AuthorizationChannelInterceptor authz = new AuthorizationChannelInterceptor(authorizationManager);
AuthorizationEventPublisher publisher = new SpringAuthorizationEventPublisher(applicationContext);
authz.setAuthorizationEventPublisher(publisher);
registration.interceptors(new SecurityContextChannelInterceptor(), authz);
}
}
@Configuration
open class WebSocketSecurityConfig(val applicationContext: ApplicationContext, val authorizationManager: AuthorizationManager<Message<*>>) : WebSocketMessageBrokerConfigurer {
@Override
override fun addArgumentResolvers(argumentResolvers: List<HandlerMethodArgumentResolver>) {
argumentResolvers.add(AuthenticationPrincipalArgumentResolver())
}
@Override
override fun configureClientInboundChannel(registration: ChannelRegistration) {
var authz: AuthorizationChannelInterceptor = AuthorizationChannelInterceptor(authorizationManager)
var publisher: AuthorizationEventPublisher = SpringAuthorizationEventPublisher(applicationContext)
authz.setAuthorizationEventPublisher(publisher)
registration.interceptors(SecurityContextChannelInterceptor(), authz)
}
}
<websocket-message-broker use-authorization-manager="true" same-origin-disabled="true">
<intercept-message pattern="/**" access="authenticated"/>
</websocket-message-broker>
自定义表达式处理器
有时,可能需要自定义在 intercept-message XML 元素中定义的 access 表达式的处理方式。为此,您可以创建一个类型为 SecurityExpressionHandler<MessageAuthorizationContext<?>> 的类,并在 XML 定义中引用它,如下所示:
<websocket-message-broker use-authorization-manager="true">
<expression-handler ref="myRef"/>
...
</websocket-message-broker>
<b:bean ref="myRef" class="org.springframework.security.messaging.access.expression.MessageAuthorizationContextSecurityExpressionHandler"/>
如果你正在从旧版 websocket-message-broker 迁移,并且它实现了 SecurityExpressionHandler<Message<?>>,你可以:1. 额外实现 createEvaluationContext(Supplier, Message) 方法,然后 2. 将该值包装在 MessageAuthorizationContextSecurityExpressionHandler 中,如下所示:
<websocket-message-broker use-authorization-manager="true">
<expression-handler ref="myRef"/>
...
</websocket-message-broker>
<b:bean ref="myRef" class="org.springframework.security.messaging.access.expression.MessageAuthorizationContextSecurityExpressionHandler">
<b:constructor-arg>
<b:bean class="org.example.MyLegacyExpressionHandler"/>
</b:constructor-arg>
</b:bean>
使用 SockJS
SockJS 提供了后备传输方案以支持旧版浏览器。在使用这些后备选项时,我们需要放宽一些安全限制,以便 SockJS 能与 Spring Security 协同工作。
SockJS 与 frame-options
SockJS 可能会使用基于 iframe 的传输方式。默认情况下,Spring Security 禁止网站被嵌入框架,以防止点击劫持攻击。为了让 SockJS 基于框架的传输方式正常工作,我们需要配置 Spring Security 以允许同源框架加载内容。
你可以通过 frame-options 元素自定义 X-Frame-Options。例如,以下配置指示 Spring Security 使用 X-Frame-Options: SAMEORIGIN,该设置允许同域名下的 iframe:
<http>
<!-- ... -->
<headers>
<frame-options
policy="SAMEORIGIN" />
</headers>
</http>
同样,您可以通过以下方式在Java配置中自定义框架选项以使用相同的源:
- Java
- Kotlin
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ...
.headers((headers) -> headers
.frameOptions((frameOptions) -> frameOptions
.sameOrigin()
)
);
return http.build();
}
}
@Configuration
@EnableWebSecurity
open class WebSecurityConfig {
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
// ...
headers {
frameOptions {
sameOrigin = true
}
}
}
return http.build()
}
}
SockJS 与放宽 CSRF 限制
SockJS 在任何基于 HTTP 的传输中,对 CONNECT 消息使用 POST 请求。通常,我们需要在 HTTP 头或 HTTP 参数中包含 CSRF 令牌。然而,SockJS 不允许使用这些选项。因此,我们必须按照在 Stomp 头中添加 CSRF 中所述,将令牌包含在 Stomp 头中。
这也意味着我们需要在 Web 层放宽 CSRF 防护。具体来说,我们希望为连接 URL 禁用 CSRF 保护。但我们不希望为所有 URL 都禁用 CSRF 保护,否则我们的网站将容易受到 CSRF 攻击。
我们可以通过提供一个CSRF RequestMatcher 轻松实现这一点。我们的Java配置让这变得很简单。例如,如果我们的STOMP端点是 /chat,我们可以通过以下配置仅对以 /chat/ 开头的URL禁用CSRF保护:
- Java
- Kotlin
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf((csrf) -> csrf
// ignore our stomp endpoints since they are protected using Stomp headers
.ignoringRequestMatchers("/chat/**")
)
.headers((headers) -> headers
// allow same origin to frame our site to support iframe SockJS
.frameOptions((frameOptions) -> frameOptions
.sameOrigin()
)
)
.authorizeHttpRequests((authorize) -> authorize
...
)
...
}
}
@Configuration
@EnableWebSecurity
open class WebSecurityConfig {
@Bean
open fun filterChain(http: HttpSecurity): SecurityFilterChain {
http {
csrf {
ignoringRequestMatchers("/chat/**")
}
headers {
frameOptions {
sameOrigin = true
}
}
authorizeHttpRequests {
// ...
}
// ...
}
}
}
如果使用基于XML的配置,我们可以使用csrf@request-matcher-ref。
<http ...>
<csrf request-matcher-ref="csrfMatcher"/>
<headers>
<frame-options policy="SAMEORIGIN"/>
</headers>
...
</http>
<b:bean id="csrfMatcher"
class="AndRequestMatcher">
<b:constructor-arg value="#{T(org.springframework.security.web.csrf.CsrfFilter).DEFAULT_CSRF_MATCHER}"/>
<b:constructor-arg>
<b:bean class="org.springframework.security.web.util.matcher.NegatedRequestMatcher">
<b:bean class="org.springframework.security.config.http.PathPatternRequestMatcherFactoryBean">
<b:constructor-arg value="/chat/**"/>
</b:bean>
</b:bean>
</b:constructor-arg>
</b:bean>
旧版 WebSocket 配置
AbstractSecurityWebSocketMessageBrokerConfigurer 和 MessageSecurityMetadataSourceRegistry 已在 Spring Security 7 中移除。请参阅 5.8 迁移指南 以获取指导。