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>` 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 任何其他消息都将被拒绝。这是一个好主意,以确保您不会错过任何消息。

迁移 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 AuthorizationResult authorize(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`,客户端可以直接向该端点发送消息并冒充系统。

通常,应用程序会拒绝发送到以 代理前缀 (`/topic/` 或 `/queue/`) 开头的目标的任何 `MESSAGE`。

基于目标的 WebSocket 授权

您还应该了解目标如何转换。

考虑一个聊天应用程序

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

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

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

  • 消息被转换为目标 `/queue/user/messages-`。

使用此聊天应用程序,我们希望允许客户端监听 `/user/queue`,它被转换为 `/queue/user/messages-`。但是,我们不希望客户端能够监听 `/queue/*`,因为那将允许客户端查看每个用户的消息。

通常,应用程序会拒绝发送到以 代理前缀 (`/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 增加了一层额外的保护,以提供 纵深防御

将 CSRF 添加到 Stomp 头部

默认情况下,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) {
  ...

})

在 WebSocket 中禁用 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>

自定义表达式处理器

有时,自定义如何处理 `intercept-message` XML 元素中定义的 `access` 表达式可能很有价值。为此,您可以创建一个类型为 `SecurityExpressionHandler>` 的类,并在 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>` 的 `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 头部中,如 将 CSRF 添加到 Stomp 头部 中所述。

这也意味着我们需要在 Web 层放松 CSRF 保护。具体来说,我们希望禁用 CONNECT 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
                }
            }
            authorizeHttpRequests {
                // ...
            }
            // ...
        }
    }
}

如果使用基于 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.config.http.PathPatternRequestMatcherFactoryBean">
            <b:constructor-arg value="/chat/**"/>
          </b:bean>
        </b:bean>
    </b:constructor-arg>
</b:bean>

传统 WebSocket 配置

`AbstractSecurityWebSocketMessageBrokerConfigurer` 和 `MessageSecurityMetadataSourceRegistry` 从 Spring Security 7 开始已移除。请参阅 5.8 迁移指南 获取指导。

© . This site is unofficial and not affiliated with VMware.