JSON Web Token (JWT) avec Sockjs / STOMP web Socket à base de ressort

arrière-plan

je suis en train de configurer une application Web RESTful en utilisant Spring Boot (1.3.0.BUILD-SNAPSHOT) qui inclut un Stomp/SockJS WebSocket, que j'ai l'intention de consommer à partir d'une application iOS ainsi que les navigateurs web. Je veux utiliser JSON Web Tokens (JWT) pour sécuriser les requêtes REST et L'interface WebSocket mais j'ai des difficultés avec cette dernière.

l'application est sécurisée par une sécurité à ressort:-

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

}

la configuration WebSocket est standard: -

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

}

j'ai aussi une sous-classe de AbstractSecurityWebSocketMessageBrokerConfigurer pour sécuriser le 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;
    }

}

il y a aussi quelques classes annotées @RestController pour gérer divers bits de fonctionnalité et celles-ci sont sécurisées avec succès via le JWTTokenFilter enregistré dans ma classe WebSecurityConfiguration .

problème

Cependant, je n'arrive pas à sécuriser le WebSocket avec JWT. J'utilise SockJS 1.1.0 et STOMP 1.7.1 dans le navigateur et ne peut pas comprendre comment passer le token. Il apparaîtrait que les SockJS ne permettent pas d'envoyer les paramètres avec les requêtes initiales /info et/ou poignée de main.

le Spring Security for WebSockets documentation stipule que le AbstractSecurityWebSocketMessageBrokerConfigurer assure que:

tout message de connexion entrant nécessite un jeton CSRF valide pour appliquer la même Politique d'origine

, ce qui semble impliquer que la poignée de main initiale devrait être non sécurisée et l'authentification invoquée au point de réception d'un message STOMP CONNECT. Malheureusement, il semble que je ne trouve aucune information concernant la mise en œuvre de cette mesure. En outre, cette approche nécessiterait une logique supplémentaire pour: déconnecter un client voyou qui ouvre une connexion WebSocket et n'envoie jamais de connexion STOMP.

étant (très) nouveau au printemps, Je ne suis pas sûr non plus si ou comment les sessions de printemps s'inscrivent là-dedans. Bien que la documentation soit très détaillée, il ne semble pas à un guide agréable et simple (aka idiots) de la façon dont les différents composants s'emboîtent / interagissent les uns avec les autres.

Question

Comment sécuriser le Sockjs WebSocket par fournir un jeton Web JSON, de préférence au point de poignée de main (est-ce même possible)?

32
demandé sur Steve Wilford 2015-06-17 12:37:41

3 réponses

semble comme support pour une chaîne de requête a été ajoutée au client SockJS, voir https://github.com/sockjs/sockjs-client/issues/72 .

3
répondu Rossen Stoyanchev 2015-06-29 20:19:17

Situation Actuelle

mise à jour 2016-12-13 : la question référencée ci-dessous est maintenant marquée fixe, de sorte que le piratage ci-dessous n'est plus nécessaire quel ressort 4.3.5 ou supérieur. Voir https://github.com/spring-projects/spring-framework/blob/master/src/docs/asciidoc/web/web-websocket.adoc#token-based-authentication .

Situation Antérieure

actuellement (Sep 2016), ce N'est pas supporté par le printemps sauf via le paramètre de requête comme répondu par @rossen-stoyanchev, qui a beaucoup écrit (tous?) du Ressort WebSocket de soutien. Je n'aime pas l'approche par paramètre de requête à cause d'une fuite potentielle de la référence HTTP et du stockage du token dans les journaux du serveur. De plus, si les ramifications de sécurité Ne vous dérangent pas, notez que j'ai trouvé que cette approche fonctionne pour de véritables connexions WebSocket, mais si vous utilisez des Sockj avec des retombées sur d'autres mécanismes, la méthode determineUser n'est jamais appelée pour le repli. Voir Printemps 4.x basée sur les jetons WebSocket SockJS de secours d'authentification .

j'ai créé un numéro de printemps pour améliorer la prise en charge de L'authentification basée sur un token WebSocket: https://jira.spring.io/browse/SPR-14690

Piratage

en attendant, j'ai trouvé un hack qui fonctionne bien dans testing. Contourner le système intégré Printemps au niveau de la connexion Printemps auth machines. Au lieu de cela, définissez le token d'authentification au niveau du message en l'envoyant dans les en-têtes Stomp du côté client (ce qui reflète bien ce que vous faites déjà avec les appels HTTP XHR réguliers) par exemple:

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

du côté du serveur, obtenir le jeton à partir du message Stomp en utilisant un 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)
    }
  })

C'est simple et nous obtient 85% de la façon là-bas, cependant, cette approche ne en charge l'envoi de messages à des utilisateurs spécifiques. Cela est dû au fait que la machinerie de Spring pour associer les utilisateurs aux sessions n'est pas affectée par le résultat du ChannelInterceptor . Spring WebSocket suppose que l'authentification se fait à la couche transport et non à la couche message, ignorant ainsi l'authentification au niveau du message.

le hack pour faire ce travail de toute façon, est de créer nos instances de DefaultSimpUserRegistry et DefaultUserDestinationResolver , les exposer à l'environnement, et puis utiliser l'intercepteur pour mettre à jour ces comme si le Printemps lui-même le faisait. En d'autres termes, quelque chose comme:

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

maintenant Spring est pleinement conscient de l'authentification, c'est-à-dire qu'il injecte le Principal dans toutes les méthodes de controller qui l'exigent, l'exposent au contexte de la sécurité Spring 4.x, et associe l'utilisateur à la session WebSocket pour envoyer des messages à des utilisateurs/sessions spécifiques.

Printemps De Sécurité De La Messagerie

Enfin, si vous utilisez la sécurité printemps 4.soutien de messagerie x, assurez-vous de mettre le @Order de votre AbstractWebSocketMessageBrokerConfigurer à une valeur plus élevée que le AbstractSecurityWebSocketMessageBrokerConfigurer de sécurité de ressort ( Ordered.HIGHEST_PRECEDENCE + 50 fonctionnerait, comme montré ci-dessus). De cette façon, votre intercepteur définit le Principal avant que Spring Security n'exécute sa vérification et ne définisse le contexte de sécurité.

la Création d'une entité (mise à Jour juin 2018)

Beaucoup de gens semblent confus par cette ligne dans le code ci-dessus:

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

C'est à peu près hors de portée pour la question car il n'est pas spécifique Stomp-spécifique, mais je vais développer sur elle un peu de toute façon, parce que c'est lié à l'utilisation de jetons auth avec le ressort. Lorsque vous utilisez l'authentification par token, la classe Principal dont vous avez besoin sera généralement une classe JwtAuthentication personnalisée qui étend la classe AbstractAuthenticationToken de Spring Security. AbstractAuthenticationToken implémente l'interface Authentication qui étend l'interface Principal , et contient la plupart des machines pour intégrer votre token à la sécurité du ressort.

donc, en code Kotlin (désolé je n'ai pas le temps ou l'inclination pour traduire ce retour à Java), votre JwtAuthentication pourrait ressembler à quelque chose comme ceci, qui est un enveloppement simple autour de 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
}

Maintenant vous avez besoin d'un AuthenticationManager qui sait comment le gérer. Cela pourrait ressembler à ce qui suit, encore une fois à 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)
}

injectés TokenHandler abstraction de la JWT jeton de l'analyse, mais doit utiliser un commun JWT jeton de la bibliothèque comme jjwt . Le AuthService injecté est votre abstraction qui crée réellement votre UserEntity basé sur les revendications dans le token, et peut parler à votre base de données utilisateur ou autre système d'arrière-plan.

maintenant, en revenant à la ligne que nous avons commencé avec, il pourrait ressembler à quelque chose comme ceci, où authenticationManager est un AuthenticationManager injecté dans notre adaptateur par ressort, et est un exemple de CustomTokenAuthenticationManager nous avons défini ci-dessus:

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

ce principe est ensuite joint au message tel que décrit ci-dessus. HTH!

30
répondu Raman 2018-06-01 18:24:01

avec la dernière SockJS 1.0.3 vous pouvez passer des paramètres de requête dans le cadre de L'URL de connexion. Ainsi, vous pouvez envoyer un JWT pour autoriser une session.

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

maintenant toutes les requêtes relatives à websocket auront un paramètre"?token=AAA "

http://localhost/ws/info Je ne sais pas.token=AAA&t=1446482506843

http://localhost/ws/515/z45wjz24/websocket Je ne sais pas.token=AAA

ensuite avec Spring vous pouvez configurer un filtre qui identifiera une session en utilisant le token fourni.

6
répondu alextunyk 2015-11-02 17:17:26