web-dev-qa-db-fra.com

Jeton Web JSON (JWT) avec socket Web SockJS / STOMP basé sur Spring

Contexte

Je suis en train de configurer une application Web RESTful à l'aide de Spring Boot (1.3.0.BUILD-SNAPSHOT) qui comprend un STOMP/SockJS WebSocket, que j'ai l'intention de consommer à partir d'une application iOS ainsi que de navigateurs Web. Je souhaite utiliser jetons Web JSON (JWT) pour sécuriser les demandes REST et l'interface WebSocket, mais j'ai des difficultés avec ce dernier.

L'application est sécurisée avec 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);
    }

}

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 également 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 existe également quelques classes annotées @RestController Pour gérer divers éléments de fonctionnalité et celles-ci sont sécurisées avec succès via la JWTTokenFilter enregistrée dans ma classe WebSecurityConfiguration.

Problème

Cependant, je n'arrive pas à sécuriser le WebSocket avec JWT. J'utilise SockJS 1.1. et STOMP 1.7.1 dans le navigateur et je n'arrive pas à comprendre comment passer le jeton. Il il semblerait que SockJS n'autorise pas l'envoi de paramètres avec les demandes initiales de /info Et/ou de prise de contact.

La documentation de Spring Security pour WebSockets indique que le AbstractSecurityWebSocketMessageBrokerConfigurer garantit que:

Tout message CONNECT entrant nécessite un jeton CSRF valide pour appliquer la même politique d'origine

Ce qui semble impliquer que la prise de contact initiale doit être non sécurisée et l'authentification invoquée au moment de recevoir un message STOMP CONNECT. Malheureusement, je n'arrive pas à trouver d'informations sur la mise en œuvre de cela. De plus, cette approche nécessiterait une logique supplémentaire pour déconnecter un client non autorisé qui ouvre une connexion WebSocket et n'envoie jamais de STOMP CONNECT.

Étant (très) nouveau au printemps, je ne sais pas non plus si ni comment les sessions de printemps s'intègrent à cela. Bien que la documentation soit très détaillée, il ne semble pas qu'un guide agréable et simple (alias idiots) explique comment les différents composants s'emboîtent/interagissent les uns avec les autres.

Question

Comment puis-je sécuriser le SockJS WebSocket en fournissant un jeton Web JSON, de préférence au moment de la prise de contact (est-ce même possible)?

44
Steve Wilford

On dirait que la prise en charge d'une chaîne de requête a été ajoutée au client SockJS, voir https://github.com/sockjs/sockjs-client/issues/72 .

5
Rossen Stoyanchev

Situation actuelle

MISE À JOUR 2016-12-13 : le problème référencé ci-dessous est maintenant marqué comme corrigé, donc le hack ci-dessous n'est plus nécessaire que Spring 4.3.5 ou supérieur. Voir https://github.com/spring-projects/spring-framework/blob/master/src/docs/asciidoc/web/websocket.adoc#token-authentication .

Situation précédente

Actuellement (septembre 2016), cela n'est pas pris en charge par Spring, sauf via le paramètre de requête comme l'a répondu @ rossen-stoyanchev, qui a écrit beaucoup (tous?) Du support de Spring WebSocket. Je n'aime pas l'approche des paramètres de requête en raison d'une fuite potentielle de référents HTTP et du stockage du jeton 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 les vraies connexions WebSocket, mais si vous utilisez SockJS avec des solutions de secours pour d'autres mécanismes, le determineUser n'est jamais appelée pour le repli. Voir authentification de secours WebSocket SockJS basée sur des jetons Spring 4.x .

J'ai créé un problème Spring pour améliorer la prise en charge de l'authentification WebSocket basée sur des jetons: https://jira.spring.io/browse/SPR-1469

Le pirater

En attendant, j'ai trouvé un hack qui fonctionne bien dans les tests. Contournez les machines d'authentification Spring intégrées au niveau de la connexion Spring. Au lieu de cela, définissez le jeton d'authentification au niveau du message en l'envoyant dans les en-têtes Stomp du côté client (cela 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'}, ...);

Côté serveur, obtenez le jeton à partir du message Stomp à l'aide d'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 cela nous permet d'atteindre 85% du chemin, cependant, cette approche ne prend pas en charge l'envoi de messages à des utilisateurs spécifiques. En effet, le mécanisme de Spring pour associer les utilisateurs aux sessions n'est pas affecté par le résultat de ChannelInterceptor. Spring WebSocket suppose que l'authentification est effectuée au niveau de la couche transport, et non de la couche message, et ignore donc l'authentification au niveau du message.

Le hack pour que cela fonctionne de toute façon, est de créer nos instances de DefaultSimpUserRegistry et DefaultUserDestinationResolver, de les exposer à l'environnement, puis d'utiliser l'intercepteur pour les mettre à jour comme si Spring le faisait lui-même. 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 contrôleur qui l'exigent, l'expose au contexte de Spring Security 4.x et associe l'utilisateur à la session WebSocket pour l'envoi de messages à des utilisateurs/sessions spécifiques.

Messagerie de sécurité Spring

Enfin, si vous utilisez la prise en charge de la messagerie Spring Security 4.x, assurez-vous de définir le @Order de votre AbstractWebSocketMessageBrokerConfigurer à une valeur supérieure à celle de AbstractSecurityWebSocketMessageBrokerConfigurer de Spring Security (Ordered.HIGHEST_PRECEDENCE + 50 fonctionnerait, comme indiqué ci-dessus). De cette façon, votre intercepteur définit le Principal avant que Spring Security n'exécute sa vérification et définit le contexte de sécurité.

Création d'un principal (mise à jour juin 2018)

Beaucoup de gens semblent être 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 = [...];

Ceci est à peu près hors de portée pour la question car il n'est pas spécifique à Stomp, mais je développerai un peu quand même, car il est lié à l'utilisation de jetons d'authentification avec Spring. Lorsque vous utilisez l'authentification basée sur des jetons, la 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 avec Spring Security.

Donc, dans le code Kotlin (désolé, je n'ai pas le temps ni l'envie de le traduire en Java), votre JwtAuthentication pourrait ressembler à ceci, qui est un simple wrapper 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
}

Vous avez maintenant besoin d'un AuthenticationManager qui sait comment y faire face. Cela pourrait ressembler à ceci, encore une fois dans 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)
}

Le TokenHandler injecté résume l'analyse du jeton JWT, mais doit utiliser une bibliothèque de jetons JWT commune comme jjwt . Le AuthService injecté est votre abstraction qui crée réellement votre UserEntity en fonction des revendications du jeton, et peut parler à votre base de données utilisateur ou à un ou plusieurs autres systèmes backend.

Maintenant, revenant à la ligne avec laquelle nous avons commencé, cela pourrait ressembler à ceci, où authenticationManager est un AuthenticationManager injecté dans notre adaptateur par Spring et est une instance de CustomTokenAuthenticationManager nous avons défini ci-dessus:

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

Ce principal est ensuite attaché au message comme décrit ci-dessus. HTH!

39
Raman

Avec la dernière version de SockJS 1.0.3, vous pouvez transmettre des paramètres de requête dans le cadre de l'URL de connexion. Ainsi, vous pouvez envoyer un jeton 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 liées à websocket auront le paramètre "? Token = AAA"

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

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

Ensuite, avec Spring, vous pouvez configurer un filtre qui identifiera une session à l'aide du jeton fourni.

7
alextunyk