基于Spring的SockJS / STOMP Web Socket的JSON Web令牌(JWT)

时间:2015-06-17 09:37:41

标签: spring spring-security websocket jwt sockjs

背景

我正在使用包含STOMP / SockJS WebSocket的Spring Boot(1.3.0.BUILD-SNAPSHOT)设置RESTful Web应用程序,我打算从iOS应用程序和Web浏览器中使用它。我想使用JSON Web Tokens(JWT)来保护REST请求和WebSocket接口,但我对后者有困难。

使用Spring Security保护应用程序: -

@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的子类来保护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注释类可以处理各种功能,这些类通过我JWTTokenFilter类中注册的WebSecurityConfiguration成功保护。

问题

但是我似乎无法通过JWT保护WebSocket。我在浏览器中使用SockJS 1.1.0STOMP 1.7.1,无法弄清楚如何传递令牌。它would appear that SockJS不允许使用初始/info和/或握手请求发送参数。

AbstractSecurityWebSocketMessageBrokerConfigurer确保的Spring Security for WebSockets documentation states

  

任何入站CONNECT消息都需要有效的CSRF令牌来强制实施同源策略

这似乎意味着初始握手应该是不安全的,并且在接收STOMP CONNECT消息时调用身份验证。不幸的是,我似乎无法找到有关实施此信息的任何信息。此外,这种方法还需要额外的逻辑来断开打开WebSocket连接的流氓客户端,并且永远不会发送STOMP CONNECT。

对于Spring来说(非常)新手我也不确定Spring Sessions是否适合这一点。虽然文档非常详细,但并没有出现一个很好的简单(又称白痴)指南,指导各个组件如何相互配合/相互作用。

问题

如何通过提供JSON Web令牌来保护SockJS WebSocket,最好是在握手时(甚至可能)?

4 个答案:

答案 0 :(得分:38)

现状

更新2016-12-13 :下面引用的问题现已标记为已修复,因此不再需要以下版本的Spring 4.3.5或更高版本。请参阅https://github.com/spring-projects/spring-framework/blob/master/src/docs/asciidoc/web/websocket.adoc#token-authentication

以前的情况

目前(2016年9月),除了@ rossen-stoyanchev所回答的查询参数之外,Spring不支持这一点,后者写了很多(全部?)Spring WebSocket支持。我不喜欢查询参数方法,因为潜在的HTTP引用漏洞和服务器日志中令牌的存储。此外,如果安全后果不打扰你,请注意我发现这种方法适用于真正的WebSocket连接,但是如果你使用SockJS与其他机制的回退,{{1}永远不会为后备调用方法。请参阅Spring 4.x token-based WebSocket SockJS fallback authentication

我已经创建了一个Spring问题来改进对基于令牌的WebSocket身份验证的支持:https://jira.spring.io/browse/SPR-14690

黑客攻击

与此同时,我发现了一个在测试中运行良好的黑客攻击。绕过内置的Spring连接级Spring auth机器。相反,通过在客户端的Stomp标头中发送身份验证令牌,将其设置在消息级别(这很好地反映了您已经使用常规HTTP XHR调用所做的事情),例如:

determineUser

在服务器端,使用stompClient.connect({'X-Authorization': 'token'}, ...); stompClient.subscribe(..., {'X-Authorization': 'token'}); stompClient.send("/wherever", {'X-Authorization': 'token'}, ...);

从Stomp消息中获取令牌
ChannelInterceptor

这很简单,让我们有85%的方式,但是,这种方法不支持向特定用户发送消息。这是因为Spring将用户与会话相关联的机制不受@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) } }) 结果的影响。 Spring WebSocket假定认证是在传输层而不是消息层完成的,因此忽略了消息级认证。

无论如何要做这项工作的黑客,是创建ChannelInterceptorDefaultSimpUserRegistry的实例,将它们暴露给环境,然后使用拦截器更新那些就像Spring本身正在做的那样。换句话说,比如:

DefaultUserDestinationResolver

现在,Spring完全了解身份验证,即它将@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 Security 4.x的上下文,并将用户与WebSocket会话相关联向特定用户/会话发送消息。

Spring Security Messaging

最后,如果您使用Spring Security 4.x Messaging支持,请务必将Principal的{​​{1}}设置为高于Spring Security @Order的{​​{1}}(AbstractWebSocketMessageBrokerConfigurer会工作,如上所示)。这样,您的拦截器在Spring Security执行其检查之前设置AbstractSecurityWebSocketMessageBrokerConfigurer并设置安全上下文。

创建校长(2018年6月更新)

很多人似乎对上面代码中的这一行感到困惑:

Ordered.HIGHEST_PRECEDENCE + 50

这几乎不在问题的范围内,因为它不是特定于Stomp的,但我会对它进行一些扩展,因为它与使用Spring的令牌相关。使用基于令牌的身份验证时,您需要的Principal通常是一个自定义 // validate and convert to a Principal based on your own requirements e.g. // authenticationManager.authenticate(JwtAuthentication(token)) Principal yourAuth = [...]; 类,它扩展了Spring Security的Principal类。 JwtAuthentication实现扩展AbstractAuthenticationToken接口的AbstractAuthenticationToken接口,并包含将您的令牌与Spring Security集成的大部分机制。

所以,在Kotlin代码中(抱歉,我没有时间或倾向于将其转换回Java),您的Authentication可能看起来像这样,这是Principal的简单包装器:

JwtAuthentication

现在你需要一个知道如何处理它的AbstractAuthenticationToken。在Kotlin中,这看起来可能如下所示:

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抽象出JWT令牌解析,但应该使用像jjwt这样的通用JWT令牌库。注入的@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,并且可以与您的用户数据库或其他后端系统进行通信。

现在,回到我们开始的那一行,它可能看起来像这样,其中AuthServiceUserEntity由Spring注入我们的适配器,并且是{{1}的实例我们在上面定义:

authenticationManager

然后如上所述将该主体附加到消息上。 HTH!

答案 1 :(得分:7)

使用最新的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
  });

现在所有与w​​ebsocket相关的请求都会有参数&#34;?token = AAA&#34;

http://localhost/ws/info?token=AAA&t=1446482506843

http://localhost/ws/515/z45wjz24/websocket?token=AAA

然后使用Spring,您可以设置一些过滤器,使用提供的令牌识别会话。

答案 2 :(得分:4)

似乎支持将查询字符串添加到SockJS客户端,请参阅https://github.com/sockjs/sockjs-client/issues/72

答案 3 :(得分:0)

到目前为止,可以将auth令牌作为请求参数添加并在握手时进行处理,或者将其作为标头添加到与stomp端点的连接上,并通过CONNECT命令进行处理在拦截器中。

最好的方法是使用标头,但是问题是您无法在握手步骤中访问本机标头,因此您将无法在那里进行身份验证。

让我给一些示例代码:

配置:

@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