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 抽象引入了对 WebSocket 的授权支持。
在 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>
1 | 任何入站 CONNECT 消息都需要有效的 CSRF 令牌来强制执行同源策略。 |
2 | SecurityContextHolder 在任何入站请求中都会填充来自 simpUser 头属性的用户。 |
3 | 我们的消息需要适当的授权。具体来说,任何以 /user/ 开头的入站消息都需要 ROLE_USER 角色。您可以在WebSocket 授权中找到有关授权的更多详细信息。 |
自定义授权
在使用 AuthorizationManager
时,自定义非常简单。例如,您可以发布一个要求所有消息都具有 "USER" 角色的 AuthorizationManager
,使用 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>
这将确保:
1 | 任何没有目标的消息(即除了 MESSAGE 或 SUBSCRIBE 类型之外的任何消息)将要求用户已认证。 |
2 | 任何人都可以订阅 /user/queue/errors。 |
3 | 任何目的地以 "/app/" 开头的消息将要求用户拥有 ROLE_USER 角色。 |
4 | 任何以 "/user/" 或 "/topic/friends/" 开头的 SUBSCRIBE 类型消息将要求 ROLE_USER 角色。 |
5 | 任何其他 MESSAGE 或 SUBSCRIBE 类型的消息将被拒绝。由于第 6 条,我们不需要这一步,但这说明了如何匹配特定的消息类型。 |
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 AuthorizationDecision check(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
来接收通知。
虽然我们希望客户端能够 SUBSCRIBE
到 /topic/system/notifications
,但我们不希望他们能够向该目的地发送 MESSAGE
。如果我们允许向 /topic/system/notifications
发送 MESSAGE
,客户端就可以直接向该端点发送消息并冒充系统。
一般来说,应用通常会拒绝任何发送到以 broker 前缀(/topic/
或 /queue/
)开头的目的地的 MESSAGE
。
基于目的地的 WebSocket 授权
您还应该了解目的地是如何转换的。
考虑一个聊天应用:
-
用户可以通过向
/app/chat
目的地发送消息来向特定用户发送消息。 -
应用看到消息,并确保
from
属性指定为当前用户(我们不能相信客户端)。 -
然后应用使用
SimpMessageSendingOperations.convertAndSendToUser("toUser", "/queue/messages", message)
将消息发送给接收者。 -
消息将被转换为目的地
/queue/user/messages-<sessionid>
。
使用此聊天应用,我们希望允许客户端监听 /user/queue
,该目的地被转换为 /queue/user/messages-<sessionid>
。但是,我们不希望客户端能够监听 /queue/*
,因为这将允许客户端查看所有用户的消息。
一般来说,应用通常会拒绝任何发送到以 broker 前缀(/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 时需要明确保护其应用免受外部域的攻击。
向 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) {
...
})
在 WebSockets 中禁用 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>
另一方面,如果您正在使用传统的 AbstractSecurityWebSocketMessageBrokerConfigurer
,并且希望允许其他域访问您的网站,您可以禁用 Spring Security 的保护。例如,在 Java 配置中,您可以使用以下代码:
-
Java
-
Kotlin
@Configuration
public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {
...
@Override
protected boolean sameOriginDisabled() {
return true;
}
}
@Configuration
open class WebSocketSecurityConfig : AbstractSecurityWebSocketMessageBrokerConfigurer() {
// ...
override fun sameOriginDisabled(): Boolean {
return true
}
}
自定义表达式处理器
有时,自定义如何处理 XML 元素 intercept-message
中定义的 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"/>
如果您正在从实现 SecurityExpressionHandler<Message<?>>
的传统用法 websocket-message-broker
迁移,您可以: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 配置自定义 frame 选项以使用同源,如下所示:
-
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
对于任何基于 HTTP 的传输,SockJS 在 CONNECT 消息上使用 POST 方法。通常,我们需要将 CSRF 令牌包含在 HTTP 头或 HTTP 参数中。然而,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
}
}
authorizeRequests {
// ...
}
// ...
}
}
}
如果我们使用基于 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.web.util.matcher.AntPathRequestMatcher">
<b:constructor-arg value="/chat/**"/>
</b:bean>
</b:bean>
</b:constructor-arg>
</b:bean>
传统 WebSocket 配置
在 Spring Security 5.8 之前,使用 Java 配置配置消息传递授权的方法是继承 AbstractSecurityWebSocketMessageBrokerConfigurer
并配置 MessageSecurityMetadataSourceRegistry
。例如:
-
Java
-
Kotlin
@Configuration
public class WebSocketSecurityConfig
extends AbstractSecurityWebSocketMessageBrokerConfigurer { (1) (2)
protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
messages
.simpDestMatchers("/user/**").authenticated() (3)
}
}
@Configuration
open class WebSocketSecurityConfig : AbstractSecurityWebSocketMessageBrokerConfigurer() { (1) (2)
override fun configureInbound(messages: MessageSecurityMetadataSourceRegistry) {
messages.simpDestMatchers("/user/**").authenticated() (3)
}
}
这将确保:
1 | 任何入站 CONNECT 消息需要有效的 CSRF 令牌来强制执行同源策略。 |
2 | SecurityContextHolder 在任何入站请求中都填充来自 simpUser 头属性的用户。 |
3 | 我们的消息需要适当的授权。具体来说,任何以 "/user/" 开头的入站消息都需要 ROLE_USER。可以在WebSocket 授权中找到有关授权的更多详细信息。 |
使用传统配置在您拥有自定义 SecurityExpressionHandler
(继承 AbstractSecurityExpressionHandler
并覆盖 createEvaluationContextInternal
或 createSecurityExpressionRoot
)的情况下非常有用。为了延迟 Authorization
查找,新的 AuthorizationManager
API 在评估表达式时不调用这些方法。
如果您使用 XML,只需不使用 use-authorization-manager
元素或将其设置为 false
,即可使用传统 API。