WebSocket 安全性
Spring Security 4 新增了對保護 Spring 的 WebSocket 支援的支援。本節說明如何使用 Spring Security 的 WebSocket 支援。
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 token,以強制執行同源政策。 |
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 | 任何其他訊息都會被拒絕。為了確保您不會遺漏任何訊息,這是一個好主意。 |
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-<sessionid>
的目標。
對於此聊天應用程式,我們希望讓我們的用戶端監聽 /user/queue
,它會轉換為 /queue/user/messages-<sessionid>
。但是,我們不希望用戶端能夠監聽 /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 時需要明確保護其應用程式免受外部網域的侵害。
將 CSRF 新增至 Stomp 標頭
預設情況下,Spring Security 要求任何 CONNECT
訊息類型中都包含CSRF token。這可確保只有有權存取 CSRF token 的網站才能連線。由於只有同源可以存取 CSRF token,因此不允許外部網域建立連線。
通常,我們需要將 CSRF token 包含在 HTTP 標頭或 HTTP 參數中。但是,SockJS 不允許這些選項。相反,我們必須將 token 包含在 Stomp 標頭中。
應用程式可以通過存取名為 _csrf
的請求屬性來取得 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
和 token。
我們現在可以在我們的 Stomp 用戶端中包含 token
...
var headers = {};
headers[headerName] = token;
stompClient.connect(headers, function(frame) {
...
})
在 WebSocket 中停用 CSRF
目前,使用 @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>
另一方面,如果您正在使用舊版 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
}
}
自訂表達式處理器
有時,自訂如何處理在您的 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"/>
如果您要從實作 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 設定來自訂框架選項,方法是使用以下程式碼
-
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。通常,我們需要將 CSRF token 包含在 HTTP 標頭或 HTTP 參數中。但是,SockJS 不允許這些選項。相反,我們必須將 token 包含在 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 token,以強制執行同源政策 |
2 | SecurityContextHolder 會使用 simpUser 標頭屬性中的使用者資訊來填充任何傳入的請求。 |
3 | 我們的訊息需要適當的授權。具體來說,任何以 "/user/" 開頭的傳入訊息都需要 ROLE_USER 角色。有關授權的其他詳細資訊,請參閱WebSocket 授權 |
如果您有自訂 SecurityExpressionHandler
擴展 AbstractSecurityExpressionHandler
並覆寫 createEvaluationContextInternal
或 createSecurityExpressionRoot
,則使用舊版設定會很有幫助。為了延遲 Authorization
查找,新的 AuthorizationManager
API 在評估表達式時不會調用這些方法。
如果您正在使用 XML,您可以通過不使用 use-authorization-manager
元素或將其設定為 false
來簡單地使用舊版 API。