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 时,自定义非常简单。例如,您可以发布一个要求所有消息都具有 "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 授权

您需要理解 SUBSCRIBEMESSAGE 类型的消息之间的区别以及它们在 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 时需要明确保护其应用免受外部域的攻击。

Spring WebSocket 允许的来源

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

向 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 并覆盖 createEvaluationContextInternalcreateSecurityExpressionRoot)的情况下非常有用。为了延迟 Authorization 查找,新的 AuthorizationManager API 在评估表达式时不调用这些方法。

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