Pregunta Token web JSON (JWT) con SockJS / STOMP Web Socket basado en Spring


Fondo

Estoy en el proceso de configurar una aplicación web RESTful utilizando Spring Boot (1.3.0.BUILD-SNAPSHOT) que incluye un STOMP / SockJS WebSocket, que pretendo consumir desde una aplicación de iOS así como navegadores web. Quiero usar Tokens web JSON (JWT) para asegurar las solicitudes REST y la interfaz WebSocket, pero estoy teniendo dificultades con este último.

La aplicación está asegurada con 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 configuración de WebSocket es estándar:

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

}

También tengo una subclase de AbstractSecurityWebSocketMessageBrokerConfigurer para asegurar el 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;
    }

}

También hay un par de @RestController clases anotadas para manejar varios bits de funcionalidad y estos se aseguran con éxito a través del JWTTokenFilter registrado en mi WebSecurityConfiguration clase.

Problema

Sin embargo, parece que no puedo asegurar que WebSocket esté protegido con JWT. estoy usando SockJS 1.1.0 y STOMP 1.7.1 en el navegador y no puede averiguar cómo pasar el token. Eso parecería que SockJS no permite enviar los parámetros con la inicial /info y / o solicitudes de saludo.

los Seguridad de primavera para documentación de WebSockets estados que el AbstractSecurityWebSocketMessageBrokerConfigurer asegura que:

Cualquier mensaje CONNECT entrante requiere un token CSRF válido para hacer cumplir la política Same Origin

Lo que parece implicar que el protocolo de enlace inicial no debe ser seguro y debe invocarse la autenticación en el momento de recibir un mensaje STOMP CONNECT. Lamentablemente, parece que no puedo encontrar ninguna información con respecto a la implementación de esto. Además, este enfoque requeriría una lógica adicional para desconectar un cliente deshonesto que abre una conexión WebSocket y nunca envía un STOMP CONNECT.

Al ser (muy) nuevo en Spring, tampoco estoy seguro de si las Sesiones de Primavera se ajustan a esto o cómo. Si bien la documentación es muy detallada, no parece haber una guía agradable y simple (también conocida como idiotas) sobre cómo los diversos componentes se unen / interactúan entre sí.

Pregunta

¿Cómo hago para proteger SockJS WebSocket al proporcionar un token web JSON, preferiblemente en el punto de apretón de manos (¿es posible?).


32
2018-06-17 09:37


origen


Respuestas:


Parece que se agregó soporte para una cadena de consulta al cliente SockJS, vea https://github.com/sockjs/sockjs-client/issues/72.


3
2018-06-29 20:19



Situación actual

ACTUALIZACIÓN 2016-12-13 : el problema al que se hace referencia a continuación ahora se marca como fijo, por lo que ya no es necesario realizar el corte a partir de Spring 4.3.5 o superior. Ver https://github.com/spring-projects/spring-framework/blob/master/src/docs/asciidoc/web/web-websocket.adoc#token-based-authentication.

Situación anterior

Actualmente (septiembre de 2016), Spring no admite esto, excepto a través de un parámetro de consulta respondido por @ rossen-stoyanchev, quien escribió mucho (¿todo?) Del soporte Spring WebSocket. No me gusta el enfoque de parámetro de consulta debido a la posible fuga de referencia HTTP y el almacenamiento del token en los registros del servidor. Además, si las ramificaciones de seguridad no le molestan, tenga en cuenta que he encontrado que este enfoque funciona para conexiones WebSocket verdaderas, pero Si está utilizando SockJS con errores de otros mecanismos, el determineUser el método nunca se llama para el retroceso. Ver Spring 4.x token WebSocket SockJS autenticación de respaldo.

Creé un número de Spring para mejorar la compatibilidad con la autenticación WebSocket basada en tokens: https://jira.spring.io/browse/SPR-14690 

Haciéndolo

Mientras tanto, he encontrado un truco que funciona bien en las pruebas. Omita la maquinaria de autenticación de primavera Spring-level incorporada. En su lugar, configure el token de autenticación en el nivel de mensaje enviándolo en los encabezados de Stomp en el lado del cliente (esto refleja bien lo que ya está haciendo con las llamadas HTTP XHR regulares), p. Ej .:

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

En el lado del servidor, obtén el token del mensaje de Stomp usando 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)
    }
  })

Esto es simple y nos lleva el 85% del camino, sin embargo, este enfoque no es compatible con el envío de mensajes a usuarios específicos. Esto se debe a que la maquinaria de Spring para asociar usuarios a sesiones no se ve afectada por el resultado de la ChannelInterceptor. Spring WebSocket supone que la autenticación se realiza en la capa de transporte, no en la capa de mensaje, y por lo tanto ignora la autenticación a nivel de mensaje.

El truco para hacer que esto funcione de todos modos, es crear nuestras instancias de DefaultSimpUserRegistry y DefaultUserDestinationResolver, exponga a aquellos al medio ambiente y luego use el interceptor para actualizarlos como si lo hiciera Spring. En otras palabras, algo así como:

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

Ahora Spring es plenamente consciente de la autenticación, es decir, inyecta el Principal en cualquier método de controlador que lo requiera, lo expone al contexto de Spring Security 4.x y asocia al usuario a la sesión de WebSocket para enviar mensajes a usuarios / sesiones específicos.

Mensajes de seguridad de primavera

Por último, si utiliza Spring Security 4.x Messaging support, asegúrese de configurar el @Order de tu AbstractWebSocketMessageBrokerConfigurer a un valor mayor que el de Spring Security AbstractSecurityWebSocketMessageBrokerConfigurer (Ordered.HIGHEST_PRECEDENCE + 50 funcionaría, como se muestra arriba). De esa forma, su interceptor establece el Principal antes de que Spring Security ejecute su verificación y establezca el contexto de seguridad.

Creando un Principal (Actualización de junio de 2018)

Mucha gente parece confundirse con esta línea en el código anterior:

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

Esto está bastante fuera del alcance de la pregunta, ya que no es específico de Stomp, pero lo ampliaré un poco de todos modos, porque está relacionado con el uso de tokens de autenticación con Spring. Al usar la autenticación basada en token, Principal lo que necesita generalmente será una costumbre JwtAuthentication clase que extiende Spring Security's AbstractAuthenticationToken clase. AbstractAuthenticationToken implementa el Authentication interfaz que extiende la Principal interfaz, y contiene la mayor parte de la maquinaria para integrar su token con Spring Security.

Por lo tanto, en el código de Kotlin (lo siento, no tengo el tiempo o la inclinación para traducir esto de nuevo a Java), su JwtAuthentication podría verse algo como esto, que es una simple envoltura alrededor 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
}

Ahora necesitas una AuthenticationManager que sabe cómo lidiar con eso. Esto podría parecerse a lo siguiente, nuevamente en 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)
}

El inyectado TokenHandler abstrae el análisis de tokens de JWT, pero debería usar una biblioteca de tokens de JWT común como jjwt. El inyectado AuthService es tu abstracción que realmente crea tu UserEntity basado en los reclamos en el token, y puede hablar con su base de datos de usuario u otros sistemas de fondo.

Ahora, volviendo a la línea con la que comenzamos, podría parecerse a esto, donde authenticationManager es un AuthenticationManager inyectado en nuestro adaptador por Spring, y es una instancia de CustomTokenAuthenticationManager definimos arriba:

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

Este principio se adjunta al mensaje como se describe arriba. HTH!


28
2017-09-12 18:10



Con el último SockJS 1.0.3 puede pasar parámetros de consulta como parte de la URL de conexión. Por lo tanto, puedes enviar un token JWT para autorizar una sesión.

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

Ahora todas las solicitudes relacionadas con websocket tendrán el parámetro "? Token = AAA"

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

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

Luego, con Spring, puede configurar algunos filtros que identificarán una sesión utilizando el token proporcionado.


7
2017-11-02 17:17