WebSocket 安全

Spring Security 4 添加了对保护 Spring 的 WebSocket 支持 的支持。本节介绍如何使用 Spring Security 的 WebSocket 支持。

直接 JSR-356 支持

Spring Security 没有提供直接的 JSR-356 支持,因为这样做几乎没有价值。这是因为格式未知,并且 Spring 几乎无法保护未知格式。此外,JSR-356 没有提供拦截消息的方法,因此安全性将具有侵入性。

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 时,自定义非常简单。例如,您可以发布一个 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>

这将确保

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 消息类型上的授权

您需要理解SUBSCRIBEMESSAGE消息类型之间的区别,以及它们在Spring中是如何工作的。

考虑一个聊天应用程序

  • 系统可以通过/topic/system/notifications目标向所有用户发送MESSAGE通知。

  • 客户端可以通过SUBSCRIBE订阅/topic/system/notifications来接收通知。

虽然我们希望客户端能够SUBSCRIBE订阅/topic/system/notifications,但我们不希望允许他们向该目标发送MESSAGE。如果我们允许向/topic/system/notifications发送MESSAGE,客户端可以直接向该端点发送消息并伪装成系统。

通常,应用程序会拒绝发送到以代理前缀/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框架参考文档包含一个名为“消息流”的部分,描述了消息如何在系统中流动。请注意,Spring Security仅保护clientInboundChannel。Spring Security不会尝试保护clientOutboundChannel

这样做的主要原因是性能。对于每条传入的消息,通常会有更多消息传出。我们鼓励保护对端点的订阅,而不是保护出站消息。

强制执行同源策略

请注意,浏览器不会对WebSocket连接强制执行同源策略。这是一个极其重要的考虑因素。

为什么是同源策略?

考虑以下场景。用户访问bank.com并对其帐户进行身份验证。同一用户在浏览器中打开另一个选项卡并访问evil.com。同源策略确保evil.com无法读取或写入bank.com的数据。

使用WebSockets时,同源策略不适用。事实上,除非bank.com明确禁止,否则evil.com可以代表用户读取和写入数据。这意味着用户可以通过WebSocket执行的任何操作(例如转账),evil.com都可以代表该用户执行。

由于SockJS尝试模拟WebSockets,因此它也绕过了同源策略。这意味着开发人员在使用SockJS时需要明确地保护其应用程序免受外部域的攻击。

Spring WebSocket允许的来源

幸运的是,从Spring 4.1.5开始,Spring的WebSocket和SockJS支持限制了对当前域的访问。Spring Security增加了额外的保护层以提供纵深防御

向Stomp头添加CSRF

默认情况下,Spring Security要求任何CONNECT消息类型都包含CSRF令牌。这确保只有可以访问CSRF令牌的站点才能连接。由于只有**同源**可以访问CSRF令牌,因此不允许外部域建立连接。

通常,我们需要在HTTP头或HTTP参数中包含CSRF令牌。但是,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,可以使用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用法迁移,您可以: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头中包含令牌,如向Stomp头添加CSRF中所述。

这也意味着我们需要放宽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并覆盖了createEvaluationContextInternalcreateSecurityExpressionRoot,则使用旧版配置很有帮助。为了延迟Authorization查找,新的AuthorizationManager API在评估表达式时不会调用这些方法。

如果您使用XML,则只需不使用use-authorization-manager元素或将其设置为false即可使用旧版API。