Подтвердить что ты не робот

JSON Web Token (JWT) с основанной на Spring SockJS/STOMP Web Socket

Фон

Я собираюсь создать веб-приложение RESTful с помощью Spring Boot (1.3.0.BUILD-SNAPSHOT), который включает в себя STOMP/SockJS WebSocket, который я намерен использовать в приложении iOS, а также в Интернете браузеры. Я хочу использовать JSON Web Tokens (JWT) для защиты запросов REST и интерфейса WebSocket, но Im имеет трудности с последним.

Приложение защищено с помощью Spring Безопасность: -

@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, зарегистрированный в моем классе WebSecurityConfiguration.

Проблема

Однако я не могу заставить WebSocket быть защищенным с помощью JWT. Я использую SockJS 1.1.0 и STOMP 1.7.1 в браузере и не могу понять, как для передачи токена. Появится сообщение что SockJS не позволяет отправлять параметры с помощью начальных запросов /info и/или рукопожатия.

В документе Spring "Безопасность для WebSockets" указано, что AbstractSecurityWebSocketMessageBrokerConfigurer гарантирует, что:

Любое входящее сообщение CONNECT требует действительного токена CSRF для принудительной реализации той же политики происхождения

Что, по-видимому, подразумевает, что первоначальное рукопожатие должно быть необеспеченным и аутентификация, вызываемая в момент получения сообщения STOMP CONNECT. К сожалению, я не могу найти никакой информации в отношении ее реализации. Кроме того, для этого подхода потребуется дополнительная логика для отключения клиента-изгоя, который открывает соединение WebSocket и никогда не отправляет STOMP CONNECT.

Быть (очень) новым для Spring Я также не уверен, что и как Spring Сессии вписываются в это. Несмотря на то, что документация очень подробно, нет ничего приятного и простого (ака-идиота) руководства о том, как различные компоненты подходят друг другу/взаимодействуют друг с другом.

Вопрос

Как я могу обеспечить безопасность SockJS WebSocket, предоставляя токен JSON Web, желательно в момент рукопожатия (возможно ли это)?

4b9b3361

Ответ 2

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

ОБНОВЛЕНИЕ 2016-12-13: проблема, о которой идет речь ниже, теперь отмечена как фиксированная, поэтому теперь не требуется hack ниже Spring 4.3.5 или выше. См. https://github.com/spring-projects/spring-framework/blob/master/src/docs/asciidoc/web/web-websocket.adoc#token-based-authentication.

Предыдущая ситуация

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

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

Взлом

В то же время я нашел взломан, который хорошо работает при тестировании. Обход встроенного устройства Spring на уровне соединения Spring. Вместо этого установите токен аутентификации на уровне сообщения, отправив его в заголовки 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, выставить их в среду, а затем использовать перехватчик, чтобы обновить их, как если бы он сам выполнял 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 Служба безопасности

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

Ответ 3

С последним 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, будут иметь параметр "? токен = AAA"

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

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

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