带有基于 Spring 的 SockJS / STOMP Web Socket 的 JSON Web Token (JWT)

新手上路,请多包涵

背景

我正在使用包含 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 abbf75 中注册成功保护

问题

但是我似乎无法使用 JWT 保护 WebSocket。我在浏览器中使用 SockJS 1.1.0STOMP 1.7.1 ,但不知道如何传递令牌。 看起来 SockJS 不允许使用初始 /info 和/或握手请求发送参数。

WebSockets 文档的 Spring Security 声明 AbstractSecurityWebSocketMessageBrokerConfigurer 确保:

任何入站 CONNECT 消息都需要有效的 CSRF 令牌来执行同源策略

这似乎暗示初始握手应该是不安全的,并且在接收到 STOMP CONNECT 消息时调用身份验证。不幸的是,我似乎找不到任何关于实现这个的信息。此外,此方法需要额外的逻辑来断开打开 WebSocket 连接且从不发送 STOMP CONNECT 的流氓客户端。

作为 Spring 的(非常)新手,我也不确定 Spring Sessions 是否或如何适合它。虽然文档非常详细,但似乎没有一个很好且简单(又名白痴)的指南来说明各种组件如何组合在一起/相互交互。

问题

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

原文由 Steve Wilford 发布,翻译遵循 CC BY-SA 4.0 许可协议

阅读 819
2 个回答

现在的情况

更新 2016-12-13 :下面引用的问题现在已标记为已修复,因此在 Spring 4.3.5 或更高版本中不再需要下面的 hack。请参阅 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 基于令牌的 WebSocket SockJS 回退身份验证

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

破解它

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

 stompClient.connect({'X-Authorization': 'token'}, ...);
stompClient.subscribe(..., {'X-Authorization': 'token'});
stompClient.send("/wherever", {'X-Authorization': 'token'}, ...);

在服务器端,使用 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 将用户关联到会话的机制不受 ChannelInterceptor 结果的影响。 Spring WebSocket 假定身份验证是在传输层完成的,而不是消息层,因此忽略了消息级别的身份验证。

无论如何,使这项工作成功的方法是创建我们的实例 DefaultSimpUserRegistryDefaultUserDestinationResolver ,将它们暴露给环境,然后使用拦截器更新它们,就像 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 完全了解身份验证, Principal 注入任何需要它的控制器方法,将其暴露给 Spring Security 4.x 的上下文,并将用户关联到 WebSocket 会话以进行发送给特定用户/会话的消息。

弹簧安全消息

最后,如果您使用 Spring Security 4.x Messaging 支持,请确保将您的 — @OrderAbstractWebSocketMessageBrokerConfigurer 设置为比 Spring Security 的 AbstractSecurityWebSocketMessageBrokerConfigurer -e642d46(-85f9a0743f9fbebbeb45 Ordered.HIGHEST_PRECEDENCE + 50 e642c46 --- 会起作用,如上所示)。这样,您的拦截器会在 Spring Security 执行检查并设置安全上下文之前设置 Principal

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

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

   // validate and convert to a Principal based on your own requirements e.g.
  // authenticationManager.authenticate(JwtAuthentication(token))
  Principal yourAuth = [...];

这几乎超出了问题的范围,因为它不是特定于 Stomp 的,但无论如何我都会对其进行一些扩展,因为它与在 Spring 中使用身份验证令牌有关。使用基于令牌的身份验证时,您需要的 Principal 通常是自定义的 JwtAuthentication 扩展Spring Security的类 AbstractAuthenticationTokenAbstractAuthenticationToken 实现了 Authentication 接口,它扩展了 Principal 接口,并包含大部分机制。将您的令牌与 Spring Security 集成

因此,在 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 是一个 AuthenticationManager 由 Spring 注入到我们的适配器中,并且是 CustomTokenAuthenticationManager 的一个实例 --- 我们在上面定义:

 Principal yourAuth = token == null ? null : authenticationManager.authenticate(new JwtAuthentication(token));

然后将此委托人附加到消息,如上所述。喂!

原文由 Raman 发布,翻译遵循 CC BY-SA 4.0 许可协议

撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题