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 令牌來強制執行同源策略。 |
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 授權
您需要理解 SUBSCRIBE
和 MESSAGE
型別的訊息之間的區別以及它們在 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 時需要明確保護其應用免受外部域的攻擊。
向 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
並覆蓋 createEvaluationContextInternal
或 createSecurityExpressionRoot
)的情況下非常有用。為了延遲 Authorization
查詢,新的 AuthorizationManager
API 在評估表示式時不呼叫這些方法。
如果您使用 XML,只需不使用 use-authorization-manager
元素或將其設定為 false
,即可使用傳統 API。