JSON веб-маркер (JWT) с пружинной основе SockJS / топать веб-сокет

фон

Я нахожусь в процессе настройки веб-приложения RESTful с помощью Spring Boot (1.3.0.BUILD-SNAPSHOT), который включает в себя Stomp/SockJS WebSocket, который я намерен потреблять из приложения iOS, а также веб-браузеров. Я хочу использовать веб-токены JSON (JWT) для защиты остальных запросов и интерфейса 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 зарегистрирован в my WebSecurityConfiguration класса.

3 ответов


похоже, что поддержка строки запроса была добавлена в клиент SockJS, см. https://github.com/sockjs/sockjs-client/issues/72.


Текущая Ситуация

обновление 2016-12-13: проблема, на которую ссылается ниже, Теперь отмечена исправлена, поэтому Хак ниже больше не нужен, который весной 4.3.5 или выше. Смотри https://github.com/spring-projects/spring-framework/blob/master/src/docs/asciidoc/web/web-websocket.adoc#token-based-authentication.

Предыдущие Ситуации

в настоящее время (Sep 2016), это не поддерживается Spring, за исключением параметра запроса как ответил @Россен-stoyanchev, кто пишет много (все?) поддержки весеннего WebSocket. Мне не нравится подход параметров запроса из-за потенциальной утечки http-реферера и хранения токена в журналах сервера. Кроме того, если вас не беспокоят последствия безопасности, обратите внимание, что я обнаружил, что этот подход работает для истинных соединений WebSocket,но если вы используете SockJS с обратными связями с другими механизмами,determineUser метод никогда не вызывается для резервного. Видеть Весна 4.X маркер на основе WebSocket SockJS резервная аутентификация.

я создал весеннюю проблему для улучшения поддержки аутентификации WebSocket на основе токенов:https://jira.spring.io/browse/SPR-14690

Взлома

тем временем, я нашел хак, который хорошо работает в тестировании. Обойдите встроенное машинное оборудование auth весны соединени-уровня весны. Вместо этого установите маркер проверки подлинности на уровне сообщений, отправив это в заголовках Stomp на стороне клиента (это красиво отражает то, что вы уже делаете с регулярными вызовами HTTP XHR), например:

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

на стороне сервера получите токен из сообщения Stomp с помощью 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)
    }
  })

это просто и дает нам 85% пути туда, однако, этот подход не поддерживает отправку сообщений конкретным пользователям. Это связано с тем, что механизм Spring для связывания пользователей с сеансами не зависит от результата ChannelInterceptor. Spring WebSocket предполагает, что аутентификация выполняется на транспортном уровне, а не на уровне сообщений, и поэтому игнорирует аутентификацию на уровне сообщений.

хак, чтобы сделать эту работу в любом случае, чтобы создать наши экземпляры DefaultSimpUserRegistry и DefaultUserDestinationResolver, подвергните их воздействию окружающей среды, а затем используйте перехватчик для обновления, как будто это делает сама весна. Другими словами, что-то типа:

@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 Messaging

наконец, если вы используете Spring Security 4.X поддержка обмена сообщениями, убедитесь, что установить @Order вашего AbstractWebSocketMessageBrokerConfigurer к более высокому значению, чем Spring Security AbstractSecurityWebSocketMessageBrokerConfigurer (Ordered.HIGHEST_PRECEDENCE + 50 будет работать, как показано выше). Таким образом, перехватчик устанавливает Principal перед Spring Security выполняет свою проверку и устанавливает контекст безопасности.

создание участника (обновление июнь 2018)

многие люди, похоже, смущены этой строкой в коде выше:

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

это в значительной степени выходит за рамки вопроса, поскольку он не специфичен для Stomp, но я все равно немного расширю его, потому что он связан с использованием токенов auth с Spring. При использовании проверки подлинности на основе маркеров Principal вам нужно будет настраиваемой JwtAuthentication класс, который расширяет Spring Security в AbstractAuthenticationToken класса. AbstractAuthenticationToken осуществляет Authentication интерфейс, который расширяет Principal интерфейс и содержит большую часть оборудования для интеграции вашего токена с Spring Security.

Итак, в коде Котлина (извините, у меня нет времени или склонности переводить это обратно на 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 это знает, как справиться с этим. Это может выглядеть примерно следующим образом, снова в Котлине:

@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, но должен использовать общую библиотеку токенов JWT, такую как jjwt. Впрыснутый AuthService - это абстракция, которая на самом деле создает свой UserEntity на основе утверждений в токене и может разговаривать с вашей базой данных Пользователя или другим бэкэндом система(ы).

теперь, возвращаясь к линии, с которой мы начали, это может выглядеть примерно так, где authenticationManager это AuthenticationManager впрыснутый в наш переходнику весной, и экземпляр CustomTokenAuthenticationManager мы определили выше:

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

этот Принципал затем присоединяется к сообщению, как описано выше. НТН!


С последним SockJS 1.0.3 вы можете передавать параметры запроса как часть 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?токен=AAA&t=1446482506843

http://localhost/ws/515/z45wjz24/websocket?токен=AAA

затем с помощью Spring вы можете настроить некоторый фильтр, который будет определите сеанс, используя предоставленный токен.