Аутентификация и авторизация Websocket в Spring

Я много пытаюсь правильно внедрить Stomp (websocket) Аутентификация и Авторизация с помощью Spring -Security. Для потомков я отвечу на свой вопрос, чтобы предоставить руководство.


Проблема

Spring Документация WebSocket (для аутентификации) выглядит нечетко ATM (IMHO). И я не мог понять, как правильно обрабатывать Аутентификация и Авторизация.


Что я хочу

  • Аутентифицировать пользователей с логином/паролем.
  • Предотвращение анонимных пользователей CONNECT через WebSocket.
  • Добавить уровень авторизации (пользователь, admin,...).
  • Наличие Principal доступно в контроллерах.


Что я не хочу

  • Аутентификация на конечных точках согласования HTTP (так как большинство библиотек JavaScript не отправляет заголовки аутентификации вместе с вызовом согласования HTTP).

Ответ 1

Как указывалось выше, документация (АТМ) неясна, пока Spring не предоставит четкую документацию, вот пример, который поможет вам не тратить два дня, пытаясь понять, что делает цепочка безопасности.

Роб-Леггетт предпринял действительно хорошую попытку, но он порвал несколько занятий в Спрингсе, и я чувствую себя некомфортно.

Что нужно знать:

  • Цепочка безопасности и конфигурация безопасности для http и WebSocket полностью независимы.
  • Spring AuthenticationProvider вообще не участвует в аутентификации Websocket.
  • Аутентификация не будет происходить на конечной точке согласования HTTP, потому что ни один из STOMP (websocket) JavaScripts не отправляет заголовки аутентификации вместе с HTTP-запросом.
  • После установки запроса CONNECT пользователь (simpUser) будет сохранен в сеансе websocket, и для дальнейших сообщений больше не потребуется аутентификация.

Maven Deps

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-messaging</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-messaging</artifactId>
</dependency>

Конфигурация WebSocket

Приведенная ниже конфигурация регистрирует простого посредника сообщений (обратите внимание, что он не имеет ничего общего с аутентификацией или авторизацией).

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends WebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(final MessageBrokerRegistry config) {
        // These are endpoints the client can subscribes to.
        config.enableSimpleBroker("/queue/topic");
        // Message received with one of those below destinationPrefixes will be automatically router to controllers @MessageMapping
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(final StompEndpointRegistry registry) {
        // Handshake endpoint
        registry.addEndpoint("stomp"); // If you want to you can chain setAllowedOrigins("*")
    }
}

Конфигурация безопасности Spring

Поскольку протокол Stomp основан на первом HTTP-запросе, нам нужно авторизовать HTTP-вызов для нашей конечной точки рукопожатия Stomp.

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(final HttpSecurity http) throws Exception {
        // This is not for websocket authorization, and this should most likely not be altered.
        http
                .httpBasic().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                .authorizeRequests().antMatchers("/stomp").permitAll()
                .anyRequest().denyAll();
    }
}


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

@Component
public class WebSocketAuthenticatorService {
    // This method MUST return a UsernamePasswordAuthenticationToken instance, the spring security chain is testing it with 'instanceof' later on. So don't use a subclass of it or any other class
    public UsernamePasswordAuthenticationToken getAuthenticatedOrFail(final String  username, final String password) throws AuthenticationException {
        if (username == null || username.trim().isEmpty()) {
            throw new AuthenticationCredentialsNotFoundException("Username was null or empty.");
        }
        if (password == null || password.trim().isEmpty()) {
            throw new AuthenticationCredentialsNotFoundException("Password was null or empty.");
        }
        // Add your own logic for retrieving user in fetchUserFromDb()
        if (fetchUserFromDb(username, password) == null) {
            throw new BadCredentialsException("Bad credentials for user " + username);
        }

        // null credentials, we do not pass the password along
        return new UsernamePasswordAuthenticationToken(
                username,
                null,
                Collections.singleton((GrantedAuthority) () -> "USER") // MUST provide at least one role
        );
    }
}

Обратите внимание, что: UsernamePasswordAuthenticationToken ДОЛЖЕН иметь GrantedAuthorities, если вы используете другой конструктор, Spring автоматически установит isAuthenticated = false.


Почти там, теперь нам нужно создать Interceptor, который будет устанавливать заголовок simpUser или генерировать simpUser AuthenticationException для сообщений CONNECT.

@Component
public class AuthChannelInterceptorAdapter extends ChannelInterceptor {
    private static final String USERNAME_HEADER = "login";
    private static final String PASSWORD_HEADER = "passcode";
    private final WebSocketAuthenticatorService webSocketAuthenticatorService;

    @Inject
    public AuthChannelInterceptorAdapter(final WebSocketAuthenticatorService webSocketAuthenticatorService) {
        this.webSocketAuthenticatorService = webSocketAuthenticatorService;
    }

    @Override
    public Message<?> preSend(final Message<?> message, final MessageChannel channel) throws AuthenticationException {
        final StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

        if (StompCommand.CONNECT == accessor.getCommand()) {
            final String username = accessor.getFirstNativeHeader(USERNAME_HEADER);
            final String password = accessor.getFirstNativeHeader(PASSWORD_HEADER);

            final UsernamePasswordAuthenticationToken user = webSocketAuthenticatorService.getAuthenticatedOrFail(username, password);

            accessor.setUser(user);
        }
        return message;
    }
}

Обратите внимание, что: preSend() ДОЛЖЕН возвращать UsernamePasswordAuthenticationToken, другой элемент в цепочке безопасности Spring проверяет это. Обратите внимание: если ваш UsernamePasswordAuthenticationToken был создан без прохождения GrantedAuthority, аутентификация завершится неудачно, поскольку конструктор без предоставленных полномочий автоматически устанавливает authenticated = false ЭТО ВАЖНАЯ ДЕТАЛИ, которая не задокументирована в Spring-Security.


Наконец, создайте еще два класса для обработки соответственно авторизации и аутентификации.

@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class WebSocketAuthenticationSecurityConfig extends  WebSocketMessageBrokerConfigurer {
    @Inject
    private AuthChannelInterceptorAdapter authChannelInterceptorAdapter;

    @Override
    public void registerStompEndpoints(final StompEndpointRegistry registry) {
        // Endpoints are already registered on WebSocketConfig, no need to add more.
    }

    @Override
    public void configureClientInboundChannel(final ChannelRegistration registration) {
        registration.setInterceptors(authChannelInterceptorAdapter);
    }

}

Обратите внимание: @Order является CRUCIAL, не забывайте его, он позволяет нашему перехватчику быть зарегистрированным первым в цепочке безопасности.


@Configuration
public class WebSocketAuthorizationSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {
    @Override
    protected void configureInbound(final MessageSecurityMetadataSourceRegistry messages) {
        // You can customize your authorization mapping here.
        messages.anyMessage().authenticated();
    }

    // TODO: For test purpose (and simplicity) i disabled CSRF, but you should re-enable this and provide a CRSF endpoint.
    @Override
    protected boolean sameOriginDisabled() {
        return true;
    }
}

Удачи !

Ответ 2

для клиентской стороны java используйте этот пример:

StompHeaders connectHeaders = new StompHeaders();
connectHeaders.add("login", "test1");
connectHeaders.add("passcode", "test");
stompClient.connect(WS_HOST_PORT, new WebSocketHttpHeaders(), connectHeaders, new MySessionHandler);