web-dev-qa-db-ja.com

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();
    }

}

WebSocketを保護するためにAbstractSecurityWebSocketMessageBrokerConfigurerのサブクラスもあります:-

@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を介して正常に保護されます。

問題

ただし、WebSocketをJWTで保護することはできません。ブラウザで SockJS 1.1. および STOMP 1.7.1 を使用していますが、トークンを渡す方法がわかりません。それは 表示される SockJSは、最初の/infoおよび/またはハンドシェイク要求でパラメーターを送信することを許可しません。

Spring Security for WebSocketsドキュメントの状態 は、AbstractSecurityWebSocketMessageBrokerConfigurerが以下を保証することです:

着信CONNECTメッセージには、同一生成元ポリシーを実施するための有効なCSRFトークンが必要です

これは、STOMP CONNECTメッセージを受信した時点で、最初のハンドシェイクを保護せずに認証を呼び出す必要があることを暗示しているようです。残念ながら、これを実装することに関する情報を見つけることができないようです。さらに、このアプローチでは、WebSocket接続を開き、STOMP CONNECTを送信しない不正クライアントを切断するための追加ロジックが必要になります。

Springが(非常に)初めてなので、Spring Sessionsがこれに適合するかどうか、またはどのように適合するかについてもわかりません。ドキュメントは非常に詳細ですが、さまざまなコンポーネントがどのように組み合わされ、相互に作用するかについてのニースでシンプルな(別名バカ)ガイドには表示されません。

質問

できればハンドシェイクの時点で、JSON Web Tokenを提供してSockJS WebSocketを保護するにはどうすればよいですか(それも可能ですか)。

44
Steve Wilford

クエリ文字列のサポートがSockJSクライアントに追加されたようです。 https://github.com/sockjs/sockjs-client/issues/72 を参照してください。

5

現在の状況

UPDATE 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月)、これは、Spring WebSocketサポートの多く(すべて?)を書いた@ rossen-stoyanchevが回答したクエリパラメーターを介して、Springでサポートされていません。 HTTPリファラーの潜在的な漏洩とサーバーログへのトークンの格納のため、クエリパラメーターアプローチが好きではありません。さらに、セキュリティの影響が気にならない場合は、このアプローチが真のWebSocket接続で機能することを発見したことに注意してください。but他のメカニズムへのフォールバックでSockJSを使用している場合、フォールバックに対してdetermineUserメソッドが呼び出されることはありません。 Spring 4.xトークンベースのWebSocket SockJSフォールバック認証 を参照してください。

トークンベースのWebSocket認証のサポートを改善するために、Springの問題を作成しました: https://jira.spring.io/browse/SPR-1469

ハッキング

それまでの間、テストでうまく機能するハックを見つけました。組み込みの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は、認証がメッセージ層ではなくトランスポート層で行われることを前提としているため、メッセージレベルの認証を無視します。

とにかくこれを機能させるためのハックは、DefaultSimpUserRegistryおよび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は認証を完全に認識します。つまり、Principalを必要とするコントローラーメソッドに挿入し、Spring Security 4.xのコンテキストに公開し、メッセージを送信するためのWebSocketセッションにユーザーを関連付けます。特定のユーザー/セッション。

Spring Securityメッセージング

最後に、Spring Security 4.xメッセージングサポートを使用する場合、AbstractWebSocketMessageBrokerConfigurer@OrderをSpring SecurityのAbstractSecurityWebSocketMessageBrokerConfigurerOrdered.HIGHEST_PRECEDENCE + 50が機能するよりも高い値に設定してください。 、上記のように)。このようにして、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は通常、Spring SecurityのJwtAuthenticationクラスを拡張するカスタムAbstractAuthenticationTokenクラスになります。 AbstractAuthenticationTokenは、Authenticationインターフェイスを拡張するPrincipalインターフェイスを実装し、Spring Securityとトークンを統合するためのほとんどの機構を含みます。

したがって、Kotlinコードでは(これをJavaに変換する時間や傾向がありません)、JwtAuthenticationAbstractAuthenticationTokenの単純なラッパーになります。

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!

39
Raman

最新のSockJS 1.0.3では、クエリURLを接続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 = 144648250684

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

次に、Springを使用して、提供されたトークンを使用してセッションを識別するフィルターを設定できます。

7
alextunyk