WebSocket Security

Spring Security 4 添加了对保护 Spring’s WebSocket support 的支持。本部分描述如何使用 Spring Security 的 WebSocket 支持。 .Direct JSR-356 Support Spring Security 不提供直接的 JSR-356 支持,因为这样做几乎没有价值。这是因为该格式未知,且 little Spring can do to secure an unknown format。此外,JSR-356 没有提供一种拦截消息的方式,因此安全性将具有侵入性。

WebSocket Authentication

WebSocket 会在建立 WebSocket 连接时重复使用 HTTP 请求中找到的相同身份验证信息。这意味着 HttpServletRequest 中的 Principal 将被传递给 WebSocket。如果你正在使用 Spring Security,HttpServletRequest 中的 Principal 会自动被覆盖。

更具体地说,要确保用户已对你 WebSocket 应用程序进行身份验证,所有必需的操作就是确保你设置 Spring Security 以对基于 HTTP 的 Web 应用程序进行身份验证。

WebSocket Authorization

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 令牌以便执行 Same Origin Policy
2 `SecurityContextHolder`中填充了 `simpUser`标题属性中的任何入站请求内的用户。
3 我们的消息需要适当的授权。具体来说,任何以 /user/`开头的入站消息都将需要 `ROLE_USER。您可以在 WebSocket Authorization中找到有关授权的其他详细信息

Custom Authorization

使用 AuthorizationManager 时,自定义非常简单。例如,你可以发布一个 AuthorizationManager,要求所有消息都使用 AuthorityAuthorizationManager 具有 “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 Authorization Notes

为正确保护你的应用程序,你需要了解 Spring 的 WebSocket 支持。

WebSocket Authorization on Message Types

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

考虑聊天应用程序:

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

  • 客户端可以通过 `SUBSCRIBE`接收发往 `/topic/system/notifications`的通知。

虽然我们希望客户端能够 SUBSCRIBE/topic/system/notifications,但我们不想让它们能够向该目标发送 MESSAGE。如果我们允许向 /topic/system/notifications 发送 MESSAGE,则客户端可以将消息直接发送到该端点并冒充系统。

一般来说,对于应用程序来说,拒绝发送到以 broker prefix (/topic//queue/) 开头的目的地的任何 MESSAGE 是很常见的。

WebSocket Authorization on Destinations

你应该了解目标是如何转变的。

考虑聊天应用程序:

  • 用户可以通过向 `/app/chat`目标发送消息来向特定用户发送消息。

  • 应用程序看到消息后,确保 `from`属性指定为当前用户(我们不能信任客户端)。

  • 然后,应用程序使用 `SimpMessageSendingOperations.convertAndSendToUser("toUser", "/queue/messages", message)`将消息发送给收件人。

  • 消息变成 `/queue/user/messages-&lt;sessionid&gt;`的目标。

对于此聊天应用程序,我们希望允许客户端侦听 /user/queue,该消息被转换成了 /queue/user/messages-<sessionid>。但是,我们不希望客户端能够侦听 /queue/*,因为这样客户端就可以看到每个用户的消息。

一般来说,对于应用程序来说,拒绝发送到以 broker prefix (/topic//queue/) 开头的消息的任何 SUBSCRIBE 是很常见的。我们可能会提供例外,以考虑诸如此类的事情

Outbound Messages

Spring Framework 参考文档包含一个名为 “Flow of Messages” 的部分,其中介绍了消息如何通过该系统传输。请注意,Spring Security 只保护 clientInboundChannel。Spring Security 不会尝试保护 clientOutboundChannel

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

Enforcing Same Origin Policy

请注意,浏览器不会为 WebSocket 连接强制执行 Same Origin Policy。这是一个极其重要的考虑因素。

Why Same Origin?

考虑以下情景。用户访问 bank.com 并验证其帐户。同个用户在浏览器中打开另一个标签页并访问 evil.com。同源策略可确保 evil.com 无法读取 bank.com 中的数据或向其写入数据。

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

由于 SockJS 试图模拟 WebSocket,因此它也绕过了同源策略。这意味着,当开发人员使用 SockJS 时,需要明确保护其应用程序免遭外部域的侵害。

Spring WebSocket Allowed Origin

幸运的是,由于 Spring 4.1.5 Spring 的 WebSocket 和 SockJS 支持限制了对 current domain 的访问。Spring Security 添加了一层额外的保护,以提供 defense in depth

Adding CSRF to Stomp Headers

默认情况下,Spring Security 在任何 `CONNECT`消息类型中都需要 CSRF token。这确保只有可以访问 CSRF 令牌的站点才能连接。由于只有 *same origin*可以访问 CSRF 令牌,因此不允许外部域进行连接。

通常情况下,我们需要在 HTTP 标头或 HTTP 参数中包含 CSRF 令牌。但是,SockJS 不允许使用这些选项。相反,我们必须在 Stomp 标头中包含令牌。

应用程序可以通过访问名为 _csrf`的请求属性来 obtain a CSRF token。例如,以下内容允许在 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) {
  ...

})

Disable CSRF within WebSockets

考虑到这一点,当使用 @EnableWebSocketSecurity 时,CSRF 是不可配置的,不过这可能会在未来的版本中得到添加。

要禁用 CSRF,请使用 XML 支持或自行添加 Spring Security 组件,方法如下,而不是使用 @EnableWebSocketSecurity:

  • 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>

另一方面,如果您正在使用 legacy 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
    }
}

Custom Expression Handler

有时候,您可能会认为有必要自定义处理 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>

Working with SockJS

SockJS 提供备用传输以支持旧版浏览器。在使用备用选项时,我们需要放宽一些安全限制,以允许 SockJS 与 Spring Security 配合使用。

SockJS & frame-options

SockJS 可能会使用 “@1”。默认情况下,Spring Security “@2” 该网站防止点击劫持攻击。为了允许基于 SockJS 框架的传输工作,我们需要配置 Spring Security 允许相同的原点填充内容。

您可以使用 “@5” 元素来自定义 “@3”。例如,以下内容指示 Spring Security 使用 “@4”,它允许属于同一域内的 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 & Relaxing CSRF

SockJS 对任何基于 HTTP 的传输在 CONNECT 消息上使用 POST。通常情况下,我们需要在 HTTP 标头或 HTTP 参数中包含 CSRF 令牌。但是,SockJS 不允许使用这些选项。相反,我们必须像 Adding CSRF to Stomp Headers 中所述,将其包含在 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 的配置,则可以使用“@6”。

<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>

Legacy WebSocket Configuration

在 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 令牌以便执行 Same Origin Policy
2 SecurityContextHolder 中填写了任何入站请求中 simpUser 头属性中的用户。
3 我们的消息需要适当的授权。具体来说,任何以“/user/”开头的入站消息都将需要 ROLE_USER。可以在 WebSocket Authorization中找到有关授权的其他详细信息

在你有自定义 SecurityExpressionHandler 且扩展了 AbstractSecurityExpressionHandler 并覆盖 createEvaluationContextInternalcreateSecurityExpressionRoot 的情况下,使用旧版配置很有帮助。为了延迟 Authorization 查找,新的 AuthorizationManager API 在评估表达式时不会调用这些内容。

如果你正在使用 XML,你可以简单地不使用 use-authorization-manager 元素或将其设置为 false,即可使用旧版 API。