|
此版本仍在开发中,尚未被视为稳定版本。如需最新稳定版本,请使用 Spring Security 7.0.4! |
WebSocket 安全
Spring Security 4 增加了对保护 Spring 的 WebSocket 支持 的功能。 本节将介绍如何使用 Spring Security 的 WebSocket 支持。
WebSocket 身份验证
WebSocket 在建立连接时会复用 HTTP 请求中的认证信息。
这意味着 Principal 中的 HttpServletRequest 将被传递给 WebSocket。
如果你使用的是 Spring Security,Principal 上的 HttpServletRequest 会被自动覆盖。
更具体地说,要确保用户已通过 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 Tokens,以强制实施同源策略。 |
| 2 | 对于任何入站请求,SecurityContextHolder 都会使用 simpUser 头部属性中的用户信息进行填充。 |
| 3 | 我们的消息需要适当的授权。具体来说,任何以 /user/ 开头的入站消息都需要 ROLE_USER 权限。您可以在WebSocket 授权中找到有关授权的更多详细信息。 |
自定义授权
使用 AuthorizationManager 时,自定义非常简单。
例如,您可以发布一个 AuthorizationManager,通过 AuthorityAuthorizationManager 要求所有消息都具有 "USER" 角色,如下所示:
-
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实现,因为这样可以独立进行测试。
然而,为了简化迁移,你也可以使用如下类:<br>
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 中的工作方式。
考虑一个聊天应用:
-
The system can send a notification
MESSAGEto all users through a destination of/topic/system/notifications. -
客户端可以通过订阅
SUBSCRIBE到/topic/system/notifications来接收通知。
我们希望客户端能够订阅/SUBSCRIBE,但并不希望他们能够向该目的地发送//topic/system/notifications。
如果允许向/MESSAGE 发送/MESSAGE,客户端可以直接向该端点发送消息并冒充系统。
一般而言,应用程序会拒绝任何发送到以/topic/ 或 /queue/MESSAGE。
WebSocket 授权目标
您也应该了解目的地是如何进行转换的。
考虑一个聊天应用:
-
用户可以通过向
/app/chat目的地发送消息来给特定用户发送消息。 -
该应用会接收到消息,并确保
from属性被指定为当前用户(我们不能信任客户端)。 -
然后,应用程序通过使用
SimpMessageSendingOperations.convertAndSendToUser("toUser", "/queue/messages", message)将消息发送给接收方。 -
The message gets turned into the destination of
/queue/user/messages-<sessionid>.
通过这个聊天应用程序,我们希望让客户监听/user/queue,这会被转换为/queue/user/messages-<sessionid>。
但是,我们不希望客户端能够监听/queue/*,因为这样会使客户端看到所有用户的消息。
通常,应用程序会拒绝任何发送给以/topic/ 或 /queue/) 开头的消息的 SUBSCRIBE 请求。
我们可能会提供例外情况来处理类似的情况
出站消息
The Spring Framework 参考文档包含一个名为 “消息流” 的部分,该部分描述了消息如何在系统中流动。
注意,Spring Security 仅保护 clientInboundChannel。
Spring Security 不尝试保护 clientOutboundChannel。
这是主要原因之一,即性能。 对于每个传入的消息,通常会发送出很多消息。 与其对传出的消息进行安全保护,我们建议对端点的订阅进行安全保护。
实施同源策略
请注意,浏览器不会强制执行WebSocket连接的同源策略。 这是一项极其重要的考虑因素。
为什么需要同源?
考虑以下场景。
用户访问bank.com并登录其账户。
该用户在同一浏览器中打开另一个标签页,然后访问evil.com。
相同源策略确保evil.com无法从bank.com读取数据或写入数据。
使用 WebSockets 时,同源策略并不适用。 实际上,除非 `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 要求在任何 xref page 消息类型中包含CSRFTokens。
这确保了只有能够访问 CSRF Tokens的站点才能连接。
由于只有相同的源可以访问 CSRF Tokens,外部域名不允许进行连接。
通常我们需要在HTTP头或HTTP参数中包含CSRFTokens。 然而,SockJS并不允许这些选项。 因此,我们必须将Tokens包含在Stomp头部中。
应用程序可以通过访问名为 xref page 的请求属性来 获取 CSRFTokens。
例如,以下内容允许在JSP中访问 CsrfToken:
var headerName = "${_csrf.headerName}";
var token = "${_csrf.token}";
如果使用静态HTML,您可以在REST端点处暴露CsrfToken。
例如,以下内容会在CsrfToken URL 上暴露/csrf:
-
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 和Tokens。
我们现在可以在我们的Stomp客户端中包含Tokens:
...
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>
自定义表达式处理器
有时,您可能需要自定义处理access XML元素中定义的intercept-message表达式的方式。
为此,可以创建一个类型为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"/>
如果您正在从实现websocket-message-broker的遗留SecurityExpressionHandler<Message<?>>迁移过来,您可以:
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 以便允许相同源的框架嵌入内容。
您可以使用X-Frame-Options元素自定义xref page
<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 使用 CONNECT 消息中的 POST 请求来进行任何基于 HTTP 的传输。 通常,我们需要在 HTTP 头部或 HTTP 参数中包含 CSRF Tokens。 然而,SockJS 并不支持这些选项。 相反,我们必须将Tokens包含在 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
}
}
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 迁移指南 获取指导。