WebSocket 安全
本站(springdoc.cn)中的内容来源于 spring.io ,原始版权归属于 spring.io。由 springdoc.cn 进行翻译,整理。可供个人学习、研究,未经许可,不得进行任何转载、商用或与之相关的行为。 商标声明:Spring 是 Pivotal Software, Inc. 在美国以及其他国家的商标。 |
Spring Security 4增加了对 Spring 的 WebSocket 支持 的安全支持。本节介绍了如何使用Spring Security的WebSocket支持。
WebSocket 认证
WebSockets重用了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>
1 | 任何入站 CONNECT 报文都需要有效的 CSRF token,以执行 同源策略。 |
2 | 对于任何入站请求,SecurityContextHolder 将在 simpUser header 属性中填充 user。 |
3 | 我们的信息需要适当的授权。具体来说,任何以 /user/ 开头的入站消息都需要 ROLE_USER 。你可以在 WebSocket 授权 中找到有关授权的更多详细信息。 |
自定义授权
当使用 AuthorizationManager
时,定制是非常简单的。例如,你可以使用 AuthorityAuthorizationManager
发布一个 AuthorizationManager
,要求所有消息的角色都是 "USER",如下所示。
-
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 | 任何其他信息都会被拒绝。这是一个好主意,以确保你不会错过任何信息。 |
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
目的地发送消息来向特定的用户发送消息。 -
应用程序看到该消息,确保从属性被指定为当前用户(我们不能相信客户端)。
-
然后应用程序通过使用
SimpMessageSendingOperations.convertAndSendToUser("toUser", "/queue/messages", message)
将信息发送给收件人。 -
消息被转到
/queue/user/messages-<sessionid>
的目的地。
在这个聊天应用程序中,我们想让我们的客户收听 /user/queue
,它被转化为 /queue/user/messages-<sessionid>
。然而,我们不希望客户端能够监听 /queue/*
,因为那会让客户端看到每个用户的消息。
一般来说,应用程序通常会拒绝向以 broker 前缀(/topic/
或 /queue/
)开头的消息发送任何 SUBSCRIBE
。
出站信息
Spring框架参考文档中包含一个题为 “Flow of Messages” 的章节,描述了消息如何在系统中流动。请注意,Spring Security只保护 clientInboundChannel
的安全。Spring Security并不试图保护 clientOutboundChannel
。
这方面最重要的原因是性能。每一条进入的消息,通常都会有更多的消息被送出。我们鼓励确保订阅端点的安全,而不是确保出站信息的安全。
强制执行同源策略
请注意,浏览器不对WebSocket连接执行 同源策略。这是一个极其重要的考虑因素。
为什么是同源?
考虑以下情况。一个用户访问 bank.com
并认证了他们的账户。同一个用户在他们的浏览器中打开另一个标签并访问了 evil.com
。同源策略确保 evil.com
不能从 bank.com
读取数据或向 bank.com
写入数据。
对于WebSockets,同源政策并不适用。事实上,除非 bank.com
明确禁止,否则 evil.com
可以代表用户读取和写入数据。这意味着,用户通过 WebSocket 可以做的任何事情(如转账),evil.com
都可以代表该用户做。
由于SockJS试图模拟WebSockets,它也绕过了同源政策。这意味着开发人员在使用SockJS时需要明确地保护他们的应用程序不受外部域的影响。
在Stomp Header中添加CSRF
默认情况下,Spring Security在任何 CONNECT
消息类型中都要求有 CSRF token。这确保只有能够访问CSRF令牌的网站才能连接。由于只有“同源”才能访问CSRF令牌,所以不允许外部域进行连接。
通常情况下,我们需要在HTTP头或HTTP参数中包含CSRF令牌。然而,SockJS不允许有这些选项。相反,我们必须将令牌包含在Stomp header中。
应用程序可以通过访问名为 _csrf
的 request attribute 来 获得CSRF令牌。例如,下面允许在JSP中访问 CsrfToken
。
var headerName = "${_csrf.headerName}";
var token = "${_csrf.token}";
如果你使用静态HTML,你可以将 CsrfToken
暴露在REST端点上。例如,下面将在 /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
和token。
现在我们可以在我们的Stomp客户端中包含该Token。
...
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 {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(new AuthenticationPrincipalArgumentResolver());
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
AuthorizationManager<Message<?>> myAuthorizationRules = AuthenticatedAuthorizationManager.authenticated();
AuthorizationChannelInterceptor authz = new AuthorizationChannelInterceptor(myAuthorizationRules);
AuthorizationEventPublisher publisher = new SpringAuthorizationEventPublisher(this.context);
authz.setAuthorizationEventPublisher(publisher);
registration.interceptors(new SecurityContextChannelInterceptor(), authz);
}
}
@Configuration
open class WebSocketSecurityConfig : WebSocketMessageBrokerConfigurer {
@Override
override fun addArgumentResolvers(argumentResolvers: List<HandlerMethodArgumentResolver>) {
argumentResolvers.add(AuthenticationPrincipalArgumentResolver())
}
@Override
override fun configureClientInboundChannel(registration: ChannelRegistration) {
var myAuthorizationRules: AuthorizationManager<Message<?>> = AuthenticatedAuthorizationManager.authenticated()
var authz: AuthorizationChannelInterceptor = AuthorizationChannelInterceptor(myAuthorizationRules)
var publisher: AuthorizationEventPublisher = SpringAuthorizationEventPublisher(this.context)
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
}
}
自定义表达式处理器
有时,定制如何处理你的 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"/>
如果你是从实现 SecurityExpressionHandler<Message<?>>
的 websocket-message-broker
的传统用法迁移过来的,你可以。
-
另外实现
createEvaluationContext(Supplier, Message)
方法 -
然后在
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 提供了 fallback 的传输方式来支持旧的浏览器。当使用回退选项时,我们需要放松一些安全约束,以使SockJS能够与Spring Security一起工作。
SockJS 和 frame-options
SockJS可能会使用一个 利用iframe的传输。默认情况下,Spring Security会 拒绝 网站被frame加载,以防止点击劫持攻击。为了让SockJS基于框架的传输发挥作用,我们需要配置Spring Security,让同一来源的内容被frame加载。
你可以用 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
SockJS在任何基于HTTP的传输的CONNECT消息上使用POST。通常情况下,我们需要在HTTP头或HTTP参数中包含CSRF令牌。然而,SockJS不允许有这些选项。相反,我们必须在 在Stomp Header中添加CSRF 中描述的Stomp Header 中包含令牌。
这也意味着我们需要放松对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 Header 属性中的用户来填充的。 |
3 | 我们的消息需要适当的授权。具体而言,任何以 "/user/" 开头的入站消息都需要ROLE_USER。关于授权的其他细节可在 WebSocket 授权 中找到。 |
如果你有一个自定义的 SecurityExpressionHandler
,它扩展了 AbstractSecurityExpressionHandler
并重写了 createEvaluationContextInternal
或 createSecurityExpressionRoot
,那么使用传统的配置是有帮助的。为了推迟授权查询,新的 AuthorizationManager
API在评估表达式时不调用这些。
如果你使用的是XML,你只需不使用 use-authorization-manager
元素或将其设置为 false
,就可以使用传统的API。