JSON 基于 Spring 的 SockJS / STOMP Web 套接字的 Web 令牌 (JWT)
JSON Web Token (JWT) with Spring based SockJS / STOMP Web Socket
背景
我正在使用 Spring Boot (1.3.0.BUILD-SNAPSHOT) 设置 RESTful Web 应用程序,其中包括一个 STOMP/SockJS WebSocket,它我打算使用 iOS 应用程序和网络浏览器。我想使用 JSON Web Tokens (JWT) 来保护 REST 请求和 WebSocket 接口,但我在使用后者时遇到困难。
该应用程序受 Spring 安全保护:-
@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
public WebSecurityConfiguration() {
super(true);
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("steve").password("steve").roles("USER");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.exceptionHandling().and()
.anonymous().and()
.servletApi().and()
.headers().cacheControl().and().and()
// Relax CSRF on the WebSocket due to needing direct access from apps
.csrf().ignoringAntMatchers("/ws/**").and()
.authorizeRequests()
//allow anonymous resource requests
.antMatchers("/", "/index.html").permitAll()
.antMatchers("/resources/**").permitAll()
//allow anonymous POSTs to JWT
.antMatchers(HttpMethod.POST, "/rest/jwt/token").permitAll()
// Allow anonymous access to websocket
.antMatchers("/ws/**").permitAll()
//all other request need to be authenticated
.anyRequest().hasRole("USER").and()
// Custom authentication on requests to /rest/jwt/token
.addFilterBefore(new JWTLoginFilter("/rest/jwt/token", authenticationManagerBean()), UsernamePasswordAuthenticationFilter.class)
// Custom JWT based authentication
.addFilterBefore(new JWTTokenFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
WebSocket配置标准:-
@Configuration
@EnableScheduling
@EnableWebSocketMessageBroker
public class WebSocketConfiguration extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").withSockJS();
}
}
我还有一个 AbstractSecurityWebSocketMessageBrokerConfigurer
的子class 来保护 WebSocket:-
@Configuration
public class WebSocketSecurityConfiguration extends AbstractSecurityWebSocketMessageBrokerConfigurer {
@Override
protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
messages.anyMessage().hasRole("USER");
}
@Override
protected boolean sameOriginDisabled() {
// We need to access this directly from apps, so can't do cross-site checks
return true;
}
}
还有几个 @RestController
注释 classes 来处理各种功能,这些通过在我的 WebSecurityConfiguration
中注册的 JWTTokenFilter
成功保护 WebSecurityConfiguration
=55=].
问题
但是我似乎无法使用 JWT 保护 WebSocket。我正在使用 SockJS 1.1.0 and STOMP 1.7.1 in the browser and can't figure out how to pass the token. It would appear that SockJS 不允许在初始 /info
and/or 握手请求中发送参数。
Spring Security for WebSockets documentation states AbstractSecurityWebSocketMessageBrokerConfigurer
确保:
Any inbound CONNECT message requires a valid CSRF token to enforce Same Origin Policy
这似乎暗示初始握手应该是不安全的,并且在接收 STOMP CONNECT 消息时调用身份验证。不幸的是,我似乎找不到任何关于实现这个的信息。此外,这种方法需要额外的逻辑来断开打开 WebSocket 连接并且从不发送 STOMP CONNECT 的恶意客户端。
作为 Spring 的(非常)新手,我也不确定 Spring Sessions 是否或如何适用于此。虽然文档非常详细,但似乎没有关于各种组件如何组合在一起/相互交互的简单(又名白痴)指南。
问题
如何通过提供 JSON Web 令牌来保护 SockJS WebSocket,最好是在握手时(甚至可能)?
似乎已将对查询字符串的支持添加到 SockJS 客户端,请参阅 https://github.com/sockjs/sockjs-client/issues/72。
使用最新的 SockJS 1.0.3,您可以将查询参数作为连接的一部分传递 URL。因此,您可以发送一些 JWT 令牌来授权会话。
var socket = new SockJS('http://localhost/ws?token=AAA');
var stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
stompClient.subscribe('/topic/echo', function(data) {
// topic handler
});
}
}, function(err) {
// connection error
});
现在所有与websocket相关的请求都会有参数“?token=AAA”
http://localhost/ws/info?token=AAA&t=1446482506843
http://localhost/ws/515/z45wjz24/websocket?token=AAA
然后使用 Spring 您可以设置一些过滤器,该过滤器将使用提供的令牌识别会话。
现状
更新 2016-12-13:下面提到的问题现已标记为已修复,因此不再需要下面的 hack Spring 4.3.5 或更高版本.参见 https://github.com/spring-projects/spring-framework/blob/master/src/docs/asciidoc/web/websocket.adoc#token-authentication。
以前的情况
目前(2016 年 9 月),Spring 不支持此功能,@rossen-stoyanchev 回答的查询参数除外,他写了很多(全部?)Spring WebSocket 支持。我不喜欢查询参数方法,因为存在潜在的 HTTP 引荐来源泄漏和令牌在服务器日志中的存储。此外,如果安全后果不打扰您,请注意我发现这种方法适用于真正的 WebSocket 连接,但是 如果您使用 SockJS 并回退到其他机制,determineUser
方法永远不会被调用来进行回退。参见 Spring 4.x token-based WebSocket SockJS fallback authentication。
我创建了一个 Spring 问题来改进对 token-based WebSocket 身份验证的支持:https://jira.spring.io/browse/SPR-14690
破解它
与此同时,我发现了一个在测试中效果很好的 hack。绕过 built-in Spring connection-level Spring 授权机制。相反,通过在客户端的 Stomp headers 中发送它来在 message-level 设置身份验证令牌(这很好地反映了您已经在使用常规 HTTP XHR 调用所做的事情)例如:
stompClient.connect({'X-Authorization': 'token'}, ...);
stompClient.subscribe(..., {'X-Authorization': 'token'});
stompClient.send("/wherever", {'X-Authorization': 'token'}, ...);
在 server-side 上,使用 ChannelInterceptor
从 Stomp 消息中获取令牌
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.setInterceptors(new ChannelInterceptorAdapter() {
Message<*> preSend(Message<*> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
List tokenList = accessor.getNativeHeader("X-Authorization");
String token = null;
if(tokenList == null || tokenList.size < 1) {
return message;
} else {
token = tokenList.get(0);
if(token == null) {
return message;
}
}
// validate and convert to a Principal based on your own requirements e.g.
// authenticationManager.authenticate(JwtAuthentication(token))
Principal yourAuth = [...];
accessor.setUser(yourAuth);
// not documented anywhere but necessary otherwise NPE in StompSubProtocolHandler!
accessor.setLeaveMutable(true);
return MessageBuilder.createMessage(message.payload, accessor.messageHeaders)
}
})
这很简单,我们完成了 85% 的事情,但是,这种方法不支持向特定用户发送消息。这是因为 Spring 将用户关联到 session 的机制不受 ChannelInterceptor
结果的影响。 Spring WebSocket 假设身份验证是在传输层完成的,而不是消息层,因此会忽略 message-level 身份验证。
无论如何,要使这项工作成功,就是创建我们的 DefaultSimpUserRegistry
和 DefaultUserDestinationResolver
实例,将它们暴露给环境,然后使用拦截器更新它们,就好像 [=110] =] 本身就是这样做的。换句话说,类似于:
@Configuration
@EnableWebSocketMessageBroker
@Order(HIGHEST_PRECEDENCE + 50)
class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer() {
private DefaultSimpUserRegistry userRegistry = new DefaultSimpUserRegistry();
private DefaultUserDestinationResolver resolver = new DefaultUserDestinationResolver(userRegistry);
@Bean
@Primary
public SimpUserRegistry userRegistry() {
return userRegistry;
}
@Bean
@Primary
public UserDestinationResolver userDestinationResolver() {
return resolver;
}
@Override
public configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/queue", "/topic");
}
@Override
public registerStompEndpoints(StompEndpointRegistry registry) {
registry
.addEndpoint("/stomp")
.withSockJS()
.setWebSocketEnabled(false)
.setSessionCookieNeeded(false);
}
@Override public configureClientInboundChannel(ChannelRegistration registration) {
registration.setInterceptors(new ChannelInterceptorAdapter() {
Message<*> preSend(Message<*> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
List tokenList = accessor.getNativeHeader("X-Authorization");
accessor.removeNativeHeader("X-Authorization");
String token = null;
if(tokenList != null && tokenList.size > 0) {
token = tokenList.get(0);
}
// validate and convert to a Principal based on your own requirements e.g.
// authenticationManager.authenticate(JwtAuthentication(token))
Principal yourAuth = token == null ? null : [...];
if (accessor.messageType == SimpMessageType.CONNECT) {
userRegistry.onApplicationEvent(SessionConnectedEvent(this, message, yourAuth));
} else if (accessor.messageType == SimpMessageType.SUBSCRIBE) {
userRegistry.onApplicationEvent(SessionSubscribeEvent(this, message, yourAuth));
} else if (accessor.messageType == SimpMessageType.UNSUBSCRIBE) {
userRegistry.onApplicationEvent(SessionUnsubscribeEvent(this, message, yourAuth));
} else if (accessor.messageType == SimpMessageType.DISCONNECT) {
userRegistry.onApplicationEvent(SessionDisconnectEvent(this, message, accessor.sessionId, CloseStatus.NORMAL));
}
accessor.setUser(yourAuth);
// not documented anywhere but necessary otherwise NPE in StompSubProtocolHandler!
accessor.setLeaveMutable(true);
return MessageBuilder.createMessage(message.payload, accessor.messageHeaders);
}
})
}
}
现在 Spring 完全了解身份验证,即将 Principal
注入任何需要它的控制器方法,将其暴露给上下文 Spring 安全性 4.x,并将用户关联到 WebSocket session,以便将消息发送到特定的 users/sessions。
Spring 安全消息
最后,如果您使用 Spring 安全 4.x 消息支持,请确保将 AbstractWebSocketMessageBrokerConfigurer
的 @Order
设置为比 Spring 安全性的 AbstractSecurityWebSocketMessageBrokerConfigurer
(Ordered.HIGHEST_PRECEDENCE + 50
可以工作,如上所示)。这样,您的拦截器会在 Spring 安全执行检查并设置安全上下文之前设置 Principal
。
创建校长(2018 年 6 月更新)
很多人似乎对上面代码中的这一行感到困惑:
// validate and convert to a Principal based on your own requirements e.g.
// authenticationManager.authenticate(JwtAuthentication(token))
Principal yourAuth = [...];
这几乎超出了问题的范围,因为它不是 Stomp-specific,但无论如何我都会稍微扩展一下,因为它与使用 Spring 的身份验证令牌有关.使用 token-based 身份验证时,您需要的 Principal
通常是自定义 JwtAuthentication
class 扩展 Spring 安全性的 AbstractAuthenticationToken
class . AbstractAuthenticationToken
实现扩展 Principal
接口的 Authentication
接口,并包含将您的令牌与 Spring 安全性集成的大部分机制。
所以,在 Kotlin 代码中(抱歉,我没有时间或意愿将其转换回 Java),您的 JwtAuthentication
可能看起来像这样,这是一个简单的包装器大约 AbstractAuthenticationToken
:
import my.model.UserEntity
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.core.GrantedAuthority
class JwtAuthentication(
val token: String,
// UserEntity is your application's model for your user
val user: UserEntity? = null,
authorities: Collection<GrantedAuthority>? = null) : AbstractAuthenticationToken(authorities) {
override fun getCredentials(): Any? = token
override fun getName(): String? = user?.id
override fun getPrincipal(): Any? = user
}
现在您需要一个知道如何处理它的AuthenticationManager
。在 Kotlin 中,这可能类似于以下内容:
@Component
class CustomTokenAuthenticationManager @Inject constructor(
val tokenHandler: TokenHandler,
val authService: AuthService) : AuthenticationManager {
val log = logger()
override fun authenticate(authentication: Authentication?): Authentication? {
return when(authentication) {
// for login via username/password e.g. crash shell
is UsernamePasswordAuthenticationToken -> {
findUser(authentication).let {
//checkUser(it)
authentication.withGrantedAuthorities(it).also { setAuthenticated(true) }
}
}
// for token-based auth
is JwtAuthentication -> {
findUser(authentication).let {
val tokenTypeClaim = tokenHandler.parseToken(authentication.token)[CLAIM_TOKEN_TYPE]
when(tokenTypeClaim) {
TOKEN_TYPE_ACCESS -> {
//checkUser(it)
authentication.withGrantedAuthorities(it).also { setAuthenticated(true) }
}
TOKEN_TYPE_REFRESH -> {
//checkUser(it)
JwtAuthentication(authentication.token, it, listOf(SimpleGrantedAuthority(Authorities.REFRESH_TOKEN)))
}
else -> throw IllegalArgumentException("Unexpected token type claim $tokenTypeClaim.")
}
}
}
else -> null
}
}
private fun findUser(authentication: JwtAuthentication): UserEntity =
authService.login(authentication.token) ?:
throw BadCredentialsException("No user associated with token or token revoked.")
private fun findUser(authentication: UsernamePasswordAuthenticationToken): UserEntity =
authService.login(authentication.principal.toString(), authentication.credentials.toString()) ?:
throw BadCredentialsException("Invalid login.")
@Suppress("unused", "UNUSED_PARAMETER")
private fun checkUser(user: UserEntity) {
// TODO add these and lock account on x attempts
//if(!user.enabled) throw DisabledException("User is disabled.")
//if(user.accountLocked) throw LockedException("User account is locked.")
}
fun JwtAuthentication.withGrantedAuthorities(user: UserEntity): JwtAuthentication {
return JwtAuthentication(token, user, authoritiesOf(user))
}
fun UsernamePasswordAuthenticationToken.withGrantedAuthorities(user: UserEntity): UsernamePasswordAuthenticationToken {
return UsernamePasswordAuthenticationToken(principal, credentials, authoritiesOf(user))
}
private fun authoritiesOf(user: UserEntity) = user.authorities.map(::SimpleGrantedAuthority)
}
注入的 TokenHandler
抽象出 JWT 令牌解析,但应该使用像 jjwt 这样的通用 JWT 令牌库。注入的 AuthService
是您的抽象,它实际上根据令牌中的声明创建您的 UserEntity
,并且可能与您的用户数据库或其他后端系统对话。
现在,回到我们开始的那一行,它可能看起来像这样,其中 authenticationManager
是由 Spring 注入到我们的适配器中的 AuthenticationManager
,并且是一个我们在上面定义的 CustomTokenAuthenticationManager
实例:
Principal yourAuth = token == null ? null : authenticationManager.authenticate(new JwtAuthentication(token));
这个主体然后附加到邮件,如上所述。 HTH!
截至目前,可以将 auth 令牌添加为请求参数并在握手时处理它,或者将其作为 header 添加到 stomp 端点的连接上,并在CONNECT
拦截器中的命令。
最好的办法是使用 header,但问题是您无法在握手步骤中访问本机 header,因此您无法在那里处理身份验证然后.
让我给出一些示例代码:
配置:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-test")
.setHandshakeHandler(new SecDefaultHandshakeHandler())
.addInterceptors(new HttpHandshakeInterceptor())
.withSockJS()
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new JwtChannelInterceptor())
}
}
握手拦截器:
public class HttpHandshakeInterceptor implements HandshakeInterceptor {
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler handler, Map<String, Object> attributes) {
attributes.put("token", request.getServletRequest().getParameter("auth_token")
return true
}
}
握手处理程序:
public class SecDefaultHandshakeHandler extends DefaultHandshakeHandler {
@Override
public Principal determineUser(ServerHttpRequest request, WebSocketHandler handler, Map<String, Object> attributes) {
Object token = attributes.get("token")
//handle authorization here
}
}
频道拦截器:
public class JwtChannelInterceptor implements ChannelInterceptor {
@Override
public void postSend(Message message, MessageChannel channel, Boolean sent) {
MessageHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class)
if (StompCommand.DISCONNECT == accessor.getCommand()) {
//retrieve Principal here via accessor.getUser()
//or get auth header from the accessor and handle authorization
}
}
}
对于可能出现的编译错误,我深表歉意,我是从 Kotlin 代码手动转换的 =)
正如您提到的,您的 WebSocket 既有 Web 客户端也有移动客户端,请注意,为所有客户端维护相同的代码库存在一些困难。请看我的帖子:Spring Websocket ChannelInterceptor not firing CONNECT event
我花了很多时间来寻找简单的解决方案。对我来说,拉曼的解决方案没有用。
您只需要定义自定义 bearerTokenResolver 方法并将访问令牌放入 cookie 或参数中。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors()
.and()
.authorizeRequests()
.antMatchers(HttpMethod.GET, "/user/info", "/api/foos/**")
.hasAuthority("SCOPE_read")
.antMatchers(HttpMethod.POST, "/api/foos")
.hasAuthority("SCOPE_write")
.anyRequest()
.authenticated()
.and()
.oauth2ResourceServer()
.jwt().and().bearerTokenResolver(this::tokenExtractor);
}
...
}
public String tokenExtractor(HttpServletRequest request) {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header != null)
return header.replace("Bearer ", "");
Cookie cookie = WebUtils.getCookie(request, "access_token");
if (cookie != null)
return cookie.getValue();
return null;
}
背景
我正在使用 Spring Boot (1.3.0.BUILD-SNAPSHOT) 设置 RESTful Web 应用程序,其中包括一个 STOMP/SockJS WebSocket,它我打算使用 iOS 应用程序和网络浏览器。我想使用 JSON Web Tokens (JWT) 来保护 REST 请求和 WebSocket 接口,但我在使用后者时遇到困难。
该应用程序受 Spring 安全保护:-
@Configuration
@EnableWebSecurity
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
public WebSecurityConfiguration() {
super(true);
}
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("steve").password("steve").roles("USER");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.exceptionHandling().and()
.anonymous().and()
.servletApi().and()
.headers().cacheControl().and().and()
// Relax CSRF on the WebSocket due to needing direct access from apps
.csrf().ignoringAntMatchers("/ws/**").and()
.authorizeRequests()
//allow anonymous resource requests
.antMatchers("/", "/index.html").permitAll()
.antMatchers("/resources/**").permitAll()
//allow anonymous POSTs to JWT
.antMatchers(HttpMethod.POST, "/rest/jwt/token").permitAll()
// Allow anonymous access to websocket
.antMatchers("/ws/**").permitAll()
//all other request need to be authenticated
.anyRequest().hasRole("USER").and()
// Custom authentication on requests to /rest/jwt/token
.addFilterBefore(new JWTLoginFilter("/rest/jwt/token", authenticationManagerBean()), UsernamePasswordAuthenticationFilter.class)
// Custom JWT based authentication
.addFilterBefore(new JWTTokenFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
WebSocket配置标准:-
@Configuration
@EnableScheduling
@EnableWebSocketMessageBroker
public class WebSocketConfiguration extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").withSockJS();
}
}
我还有一个 AbstractSecurityWebSocketMessageBrokerConfigurer
的子class 来保护 WebSocket:-
@Configuration
public class WebSocketSecurityConfiguration extends AbstractSecurityWebSocketMessageBrokerConfigurer {
@Override
protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
messages.anyMessage().hasRole("USER");
}
@Override
protected boolean sameOriginDisabled() {
// We need to access this directly from apps, so can't do cross-site checks
return true;
}
}
还有几个 @RestController
注释 classes 来处理各种功能,这些通过在我的 WebSecurityConfiguration
中注册的 JWTTokenFilter
成功保护 WebSecurityConfiguration
=55=].
问题
但是我似乎无法使用 JWT 保护 WebSocket。我正在使用 SockJS 1.1.0 and STOMP 1.7.1 in the browser and can't figure out how to pass the token. It would appear that SockJS 不允许在初始 /info
and/or 握手请求中发送参数。
Spring Security for WebSockets documentation states AbstractSecurityWebSocketMessageBrokerConfigurer
确保:
Any inbound CONNECT message requires a valid CSRF token to enforce Same Origin Policy
这似乎暗示初始握手应该是不安全的,并且在接收 STOMP CONNECT 消息时调用身份验证。不幸的是,我似乎找不到任何关于实现这个的信息。此外,这种方法需要额外的逻辑来断开打开 WebSocket 连接并且从不发送 STOMP CONNECT 的恶意客户端。
作为 Spring 的(非常)新手,我也不确定 Spring Sessions 是否或如何适用于此。虽然文档非常详细,但似乎没有关于各种组件如何组合在一起/相互交互的简单(又名白痴)指南。
问题
如何通过提供 JSON Web 令牌来保护 SockJS WebSocket,最好是在握手时(甚至可能)?
似乎已将对查询字符串的支持添加到 SockJS 客户端,请参阅 https://github.com/sockjs/sockjs-client/issues/72。
使用最新的 SockJS 1.0.3,您可以将查询参数作为连接的一部分传递 URL。因此,您可以发送一些 JWT 令牌来授权会话。
var socket = new SockJS('http://localhost/ws?token=AAA');
var stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
stompClient.subscribe('/topic/echo', function(data) {
// topic handler
});
}
}, function(err) {
// connection error
});
现在所有与websocket相关的请求都会有参数“?token=AAA”
http://localhost/ws/info?token=AAA&t=1446482506843
http://localhost/ws/515/z45wjz24/websocket?token=AAA
然后使用 Spring 您可以设置一些过滤器,该过滤器将使用提供的令牌识别会话。
现状
更新 2016-12-13:下面提到的问题现已标记为已修复,因此不再需要下面的 hack Spring 4.3.5 或更高版本.参见 https://github.com/spring-projects/spring-framework/blob/master/src/docs/asciidoc/web/websocket.adoc#token-authentication。
以前的情况
目前(2016 年 9 月),Spring 不支持此功能,@rossen-stoyanchev 回答的查询参数除外,他写了很多(全部?)Spring WebSocket 支持。我不喜欢查询参数方法,因为存在潜在的 HTTP 引荐来源泄漏和令牌在服务器日志中的存储。此外,如果安全后果不打扰您,请注意我发现这种方法适用于真正的 WebSocket 连接,但是 如果您使用 SockJS 并回退到其他机制,determineUser
方法永远不会被调用来进行回退。参见 Spring 4.x token-based WebSocket SockJS fallback authentication。
我创建了一个 Spring 问题来改进对 token-based WebSocket 身份验证的支持:https://jira.spring.io/browse/SPR-14690
破解它
与此同时,我发现了一个在测试中效果很好的 hack。绕过 built-in Spring connection-level Spring 授权机制。相反,通过在客户端的 Stomp headers 中发送它来在 message-level 设置身份验证令牌(这很好地反映了您已经在使用常规 HTTP XHR 调用所做的事情)例如:
stompClient.connect({'X-Authorization': 'token'}, ...);
stompClient.subscribe(..., {'X-Authorization': 'token'});
stompClient.send("/wherever", {'X-Authorization': 'token'}, ...);
在 server-side 上,使用 ChannelInterceptor
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.setInterceptors(new ChannelInterceptorAdapter() {
Message<*> preSend(Message<*> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
List tokenList = accessor.getNativeHeader("X-Authorization");
String token = null;
if(tokenList == null || tokenList.size < 1) {
return message;
} else {
token = tokenList.get(0);
if(token == null) {
return message;
}
}
// validate and convert to a Principal based on your own requirements e.g.
// authenticationManager.authenticate(JwtAuthentication(token))
Principal yourAuth = [...];
accessor.setUser(yourAuth);
// not documented anywhere but necessary otherwise NPE in StompSubProtocolHandler!
accessor.setLeaveMutable(true);
return MessageBuilder.createMessage(message.payload, accessor.messageHeaders)
}
})
这很简单,我们完成了 85% 的事情,但是,这种方法不支持向特定用户发送消息。这是因为 Spring 将用户关联到 session 的机制不受 ChannelInterceptor
结果的影响。 Spring WebSocket 假设身份验证是在传输层完成的,而不是消息层,因此会忽略 message-level 身份验证。
无论如何,要使这项工作成功,就是创建我们的 DefaultSimpUserRegistry
和 DefaultUserDestinationResolver
实例,将它们暴露给环境,然后使用拦截器更新它们,就好像 [=110] =] 本身就是这样做的。换句话说,类似于:
@Configuration
@EnableWebSocketMessageBroker
@Order(HIGHEST_PRECEDENCE + 50)
class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer() {
private DefaultSimpUserRegistry userRegistry = new DefaultSimpUserRegistry();
private DefaultUserDestinationResolver resolver = new DefaultUserDestinationResolver(userRegistry);
@Bean
@Primary
public SimpUserRegistry userRegistry() {
return userRegistry;
}
@Bean
@Primary
public UserDestinationResolver userDestinationResolver() {
return resolver;
}
@Override
public configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/queue", "/topic");
}
@Override
public registerStompEndpoints(StompEndpointRegistry registry) {
registry
.addEndpoint("/stomp")
.withSockJS()
.setWebSocketEnabled(false)
.setSessionCookieNeeded(false);
}
@Override public configureClientInboundChannel(ChannelRegistration registration) {
registration.setInterceptors(new ChannelInterceptorAdapter() {
Message<*> preSend(Message<*> message, MessageChannel channel) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
List tokenList = accessor.getNativeHeader("X-Authorization");
accessor.removeNativeHeader("X-Authorization");
String token = null;
if(tokenList != null && tokenList.size > 0) {
token = tokenList.get(0);
}
// validate and convert to a Principal based on your own requirements e.g.
// authenticationManager.authenticate(JwtAuthentication(token))
Principal yourAuth = token == null ? null : [...];
if (accessor.messageType == SimpMessageType.CONNECT) {
userRegistry.onApplicationEvent(SessionConnectedEvent(this, message, yourAuth));
} else if (accessor.messageType == SimpMessageType.SUBSCRIBE) {
userRegistry.onApplicationEvent(SessionSubscribeEvent(this, message, yourAuth));
} else if (accessor.messageType == SimpMessageType.UNSUBSCRIBE) {
userRegistry.onApplicationEvent(SessionUnsubscribeEvent(this, message, yourAuth));
} else if (accessor.messageType == SimpMessageType.DISCONNECT) {
userRegistry.onApplicationEvent(SessionDisconnectEvent(this, message, accessor.sessionId, CloseStatus.NORMAL));
}
accessor.setUser(yourAuth);
// not documented anywhere but necessary otherwise NPE in StompSubProtocolHandler!
accessor.setLeaveMutable(true);
return MessageBuilder.createMessage(message.payload, accessor.messageHeaders);
}
})
}
}
现在 Spring 完全了解身份验证,即将 Principal
注入任何需要它的控制器方法,将其暴露给上下文 Spring 安全性 4.x,并将用户关联到 WebSocket session,以便将消息发送到特定的 users/sessions。
Spring 安全消息
最后,如果您使用 Spring 安全 4.x 消息支持,请确保将 AbstractWebSocketMessageBrokerConfigurer
的 @Order
设置为比 Spring 安全性的 AbstractSecurityWebSocketMessageBrokerConfigurer
(Ordered.HIGHEST_PRECEDENCE + 50
可以工作,如上所示)。这样,您的拦截器会在 Spring 安全执行检查并设置安全上下文之前设置 Principal
。
创建校长(2018 年 6 月更新)
很多人似乎对上面代码中的这一行感到困惑:
// validate and convert to a Principal based on your own requirements e.g.
// authenticationManager.authenticate(JwtAuthentication(token))
Principal yourAuth = [...];
这几乎超出了问题的范围,因为它不是 Stomp-specific,但无论如何我都会稍微扩展一下,因为它与使用 Spring 的身份验证令牌有关.使用 token-based 身份验证时,您需要的 Principal
通常是自定义 JwtAuthentication
class 扩展 Spring 安全性的 AbstractAuthenticationToken
class . AbstractAuthenticationToken
实现扩展 Principal
接口的 Authentication
接口,并包含将您的令牌与 Spring 安全性集成的大部分机制。
所以,在 Kotlin 代码中(抱歉,我没有时间或意愿将其转换回 Java),您的 JwtAuthentication
可能看起来像这样,这是一个简单的包装器大约 AbstractAuthenticationToken
:
import my.model.UserEntity
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.core.GrantedAuthority
class JwtAuthentication(
val token: String,
// UserEntity is your application's model for your user
val user: UserEntity? = null,
authorities: Collection<GrantedAuthority>? = null) : AbstractAuthenticationToken(authorities) {
override fun getCredentials(): Any? = token
override fun getName(): String? = user?.id
override fun getPrincipal(): Any? = user
}
现在您需要一个知道如何处理它的AuthenticationManager
。在 Kotlin 中,这可能类似于以下内容:
@Component
class CustomTokenAuthenticationManager @Inject constructor(
val tokenHandler: TokenHandler,
val authService: AuthService) : AuthenticationManager {
val log = logger()
override fun authenticate(authentication: Authentication?): Authentication? {
return when(authentication) {
// for login via username/password e.g. crash shell
is UsernamePasswordAuthenticationToken -> {
findUser(authentication).let {
//checkUser(it)
authentication.withGrantedAuthorities(it).also { setAuthenticated(true) }
}
}
// for token-based auth
is JwtAuthentication -> {
findUser(authentication).let {
val tokenTypeClaim = tokenHandler.parseToken(authentication.token)[CLAIM_TOKEN_TYPE]
when(tokenTypeClaim) {
TOKEN_TYPE_ACCESS -> {
//checkUser(it)
authentication.withGrantedAuthorities(it).also { setAuthenticated(true) }
}
TOKEN_TYPE_REFRESH -> {
//checkUser(it)
JwtAuthentication(authentication.token, it, listOf(SimpleGrantedAuthority(Authorities.REFRESH_TOKEN)))
}
else -> throw IllegalArgumentException("Unexpected token type claim $tokenTypeClaim.")
}
}
}
else -> null
}
}
private fun findUser(authentication: JwtAuthentication): UserEntity =
authService.login(authentication.token) ?:
throw BadCredentialsException("No user associated with token or token revoked.")
private fun findUser(authentication: UsernamePasswordAuthenticationToken): UserEntity =
authService.login(authentication.principal.toString(), authentication.credentials.toString()) ?:
throw BadCredentialsException("Invalid login.")
@Suppress("unused", "UNUSED_PARAMETER")
private fun checkUser(user: UserEntity) {
// TODO add these and lock account on x attempts
//if(!user.enabled) throw DisabledException("User is disabled.")
//if(user.accountLocked) throw LockedException("User account is locked.")
}
fun JwtAuthentication.withGrantedAuthorities(user: UserEntity): JwtAuthentication {
return JwtAuthentication(token, user, authoritiesOf(user))
}
fun UsernamePasswordAuthenticationToken.withGrantedAuthorities(user: UserEntity): UsernamePasswordAuthenticationToken {
return UsernamePasswordAuthenticationToken(principal, credentials, authoritiesOf(user))
}
private fun authoritiesOf(user: UserEntity) = user.authorities.map(::SimpleGrantedAuthority)
}
注入的 TokenHandler
抽象出 JWT 令牌解析,但应该使用像 jjwt 这样的通用 JWT 令牌库。注入的 AuthService
是您的抽象,它实际上根据令牌中的声明创建您的 UserEntity
,并且可能与您的用户数据库或其他后端系统对话。
现在,回到我们开始的那一行,它可能看起来像这样,其中 authenticationManager
是由 Spring 注入到我们的适配器中的 AuthenticationManager
,并且是一个我们在上面定义的 CustomTokenAuthenticationManager
实例:
Principal yourAuth = token == null ? null : authenticationManager.authenticate(new JwtAuthentication(token));
这个主体然后附加到邮件,如上所述。 HTH!
截至目前,可以将 auth 令牌添加为请求参数并在握手时处理它,或者将其作为 header 添加到 stomp 端点的连接上,并在CONNECT
拦截器中的命令。
最好的办法是使用 header,但问题是您无法在握手步骤中访问本机 header,因此您无法在那里处理身份验证然后.
让我给出一些示例代码:
配置:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-test")
.setHandshakeHandler(new SecDefaultHandshakeHandler())
.addInterceptors(new HttpHandshakeInterceptor())
.withSockJS()
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new JwtChannelInterceptor())
}
}
握手拦截器:
public class HttpHandshakeInterceptor implements HandshakeInterceptor {
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler handler, Map<String, Object> attributes) {
attributes.put("token", request.getServletRequest().getParameter("auth_token")
return true
}
}
握手处理程序:
public class SecDefaultHandshakeHandler extends DefaultHandshakeHandler {
@Override
public Principal determineUser(ServerHttpRequest request, WebSocketHandler handler, Map<String, Object> attributes) {
Object token = attributes.get("token")
//handle authorization here
}
}
频道拦截器:
public class JwtChannelInterceptor implements ChannelInterceptor {
@Override
public void postSend(Message message, MessageChannel channel, Boolean sent) {
MessageHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class)
if (StompCommand.DISCONNECT == accessor.getCommand()) {
//retrieve Principal here via accessor.getUser()
//or get auth header from the accessor and handle authorization
}
}
}
对于可能出现的编译错误,我深表歉意,我是从 Kotlin 代码手动转换的 =)
正如您提到的,您的 WebSocket 既有 Web 客户端也有移动客户端,请注意,为所有客户端维护相同的代码库存在一些困难。请看我的帖子:Spring Websocket ChannelInterceptor not firing CONNECT event
我花了很多时间来寻找简单的解决方案。对我来说,拉曼的解决方案没有用。 您只需要定义自定义 bearerTokenResolver 方法并将访问令牌放入 cookie 或参数中。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors()
.and()
.authorizeRequests()
.antMatchers(HttpMethod.GET, "/user/info", "/api/foos/**")
.hasAuthority("SCOPE_read")
.antMatchers(HttpMethod.POST, "/api/foos")
.hasAuthority("SCOPE_write")
.anyRequest()
.authenticated()
.and()
.oauth2ResourceServer()
.jwt().and().bearerTokenResolver(this::tokenExtractor);
}
...
}
public String tokenExtractor(HttpServletRequest request) {
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (header != null)
return header.replace("Bearer ", "");
Cookie cookie = WebUtils.getCookie(request, "access_token");
if (cookie != null)
return cookie.getValue();
return null;
}