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
-
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-
通常,應用程式會拒絕傳送到以 代理字首 (`/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 令牌。這確保只有有權訪問 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
<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 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 遷移指南 獲取指導。