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。