|
对于最新的稳定版本,请使用 Spring Security 6.5.3! |
WebSocket 安全性
Spring Security 4 添加了对保护 Spring 的 WebSocket 支持的支持。 本节介绍如何使用 Spring Security 的 WebSocket 支持。
WebSocket 身份验证
WebSocket 重用建立 WebSocket 连接时在 HTTP 请求中找到的相同身份验证信息。
这意味着Principal在HttpServletRequest将移交给 WebSockets。
如果您使用的是 Spring Security,则Principal在HttpServletRequest会自动覆盖。
更具体地说,要确保用户已对您的 WebSocket 应用程序进行身份验证,所需要做的就是确保您设置 Spring Security 来验证基于 HTTP 的 Web 应用程序。
WebSocket 授权
Spring Security 4.0 通过 Spring Messaging 抽象引入了对 WebSockets 的授权支持。
在 Spring Security 5.8 中,此支持已刷新为使用AuthorizationManager应用程序接口。
要使用 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在simpUserheader 属性。 |
| 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 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 中的工作方式。
考虑一个聊天应用程序:
-
系统可以发送通知
MESSAGE通过目标向所有用户发送/topic/system/notifications. -
客户可以通过以下方式接收通知
SUBSCRIBE到/topic/system/notifications.
虽然我们希望客户能够SUBSCRIBE自/topic/system/notifications,我们不想让他们发送MESSAGE到那个目的地。
如果我们允许发送MESSAGE自/topic/system/notifications,客户端可以直接向该终结点发送消息并模拟系统。
一般来说,应用程序通常会拒绝任何MESSAGE发送到以代理前缀开头的目标 (/topic/或/queue/).
目标上的WebSocket授权
您还应该了解目的地是如何转换的。
考虑一个聊天应用程序:
-
用户可以通过向
/app/chat目的地。 -
应用程序看到消息,确保
from属性被指定为当前用户(我们不能信任客户端)。 -
然后,应用程序使用
SimpMessageSendingOperations.convertAndSendToUser("toUser", "/queue/messages", message). -
消息将转换为
/queue/user/messages-<sessionid>.
通过这个聊天应用程序,我们希望让我们的客户倾听/user/queue,它会转换为/queue/user/messages-<sessionid>.
但是,我们不希望客户端能够收听/queue/*,因为这将使客户端看到每个用户的消息。
一般来说,应用程序通常会拒绝任何SUBSCRIBE发送到以代理前缀开头的消息 (/topic/或/queue/).
我们可能会提供例外情况,以考虑以下情况
出站消息
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 尝试模拟 WebSockets,因此它也绕过了同源策略。 这意味着开发人员在使用 SockJS 时需要明确保护他们的应用程序免受外部域的影响。
将 CSRF 添加到 Stomp 标头
默认情况下,Spring Security 需要任何CONNECT消息类型。
这确保只有有权访问 CSRF Tokens的站点才能连接。
由于只有同一源才能访问 CSRF Tokens,因此不允许外部域建立连接。
通常,我们需要在 HTTP 标头或 HTTP 参数中包含 CSRF Tokens。 但是,SockJS 不允许这些选项。 相反,我们必须在 Stomp 标头中包含Tokens。
应用程序可以通过访问名为_csrf.
例如,以下内容允许访问CsrfToken在 JSP 中:
var headerName = "${_csrf.headerName}";
var token = "${_csrf.token}";
如果使用静态 HTML,则可以将CsrfToken在 REST 端点上。
例如,以下内容将公开CsrfToken在/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
此时,CSRF 在使用@EnableWebSocketSecurity,尽管这可能会在未来的版本中添加。 |
要禁用 CSRF,而不是使用@EnableWebSocketSecurity,您可以使用 XML 支持或自己添加 Spring Security 组件,如下所示:
-
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
}
}
自定义表达式处理程序
有时,自定义如何access表达式在intercept-messageXML 元素。
为此,您可以创建一个类型为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 和框架选项
SockJS 可以使用利用 iframe 的传输。 默认情况下,Spring Security 拒绝对网站进行框架以防止点击劫持攻击。 为了允许 SockJS 基于帧的传输工作,我们需要配置 Spring Security 以让同一源对内容进行框架。
您可以自定义X-Frame-Options使用 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 Tokens。 但是,SockJS 不允许这些选项。 相反,我们必须将Tokens包含在 Stomp 标头中,如 将 CSRF 添加到 Stomp 标头中所述。
这也意味着我们需要放松对 Web 层的 CSRF 保护。 具体来说,我们希望禁用连接 URL 的 CSRF 保护。 我们不想为每个 URL 禁用 CSRF 保护。 否则,我们的网站很容易受到 CSRF 攻击。
我们可以通过提供 CSRF 轻松实现这一目标RequestMatcher.
我们的 Java 配置使这变得简单。
例如,如果我们的 stomp 端点是/chat,我们只能对以/chat/使用以下配置:
-
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 Configuration 配置消息传递授权的方法是将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 Tokens才能强制执行同源策略 |
| 2 | SecurityContextHolder 在 simpUser 标头属性中填充了任何入站请求的用户。 |
| 3 | 我们的消息需要适当的授权。具体来说,任何以“/user/”开头的入站消息都需要ROLE_USER。有关授权的其他详细信息,请参阅 WebSocket 授权 |
使用旧版配置在您有自定义SecurityExpressionHandler延伸AbstractSecurityExpressionHandler和覆盖createEvaluationContextInternal或createSecurityExpressionRoot.
为了延期Authorizationlookup,新的AuthorizationManagerAPI 在计算表达式时不会调用这些。
如果您使用的是 XML,则只需不使用use-authorization-manager元素或将其设置为false.