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.