Saltar al contenido

Autenticación y autorización de Websocket en Spring

Solución:

Como se indicó anteriormente, la documentación (ATM) no está clara (en mi humilde opinión), hasta que Spring proporcione una documentación clara, aquí hay un texto estándar para evitar que pase dos días tratando de comprender qué está haciendo la cadena de seguridad.

Rob-Leggett hizo un intento realmente bueno, pero estaba bifurcando algo de la clase de Springs y no me siento cómodo haciéndolo.

Cosas que saber:

  • Cadena de seguridad y Configuración de seguridad para http y WebSocket son completamente independientes.
  • Primavera AuthenticationProvider no participe en absoluto en la autenticación de Websocket.
  • La autenticación no se realizará en el punto final de negociación HTTP porque ninguno de los JavaScripts STOMP (websocket) envía los encabezados de autenticación necesarios junto con la solicitud HTTP.
  • Una vez configurado en la solicitud CONNECT, el usuario (simpUser) se almacenará en la sesión de websocket y no se requerirá más autenticación en otros mensajes.

Deps Maven

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

Configuración de WebSocket

La siguiente configuración registra un intermediario de mensajes simple (un punto final simple que luego protegeremos).

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

Configuración de seguridad de primavera

Dado que el protocolo Stomp se basa en una primera solicitud HTTP, necesitaremos autorizar la llamada HTTP a nuestro punto final de protocolo de enlace 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();
    }
}

Luego, crearemos un servicio responsable de autenticar a los usuarios.

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

Tenga en cuenta que: UsernamePasswordAuthenticationToken DEBE tener al menos un GrantedAuthority, si usa otro constructor, Spring se autoestablecerá isAuthenticated = false.

Casi allí, ahora necesitamos crear un Interceptor que establecerá el encabezado `simpUser` o lanzará` AuthenticationException` en los mensajes 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;
    }
}

Tenga en cuenta que: preSend() DEBE devolver un UsernamePasswordAuthenticationToken, otro elemento en la cadena de seguridad de primavera prueba esto. Tenga en cuenta que: si su UsernamePasswordAuthenticationToken fue construido sin pasar GrantedAuthority, la autenticación fallará, porque el constructor sin autorizaciones otorgadas se configura automáticamente authenticated = false ESTE ES UN DETALLE IMPORTANTE que no está documentado en spring-security.

Finalmente cree dos clases más para manejar respectivamente la Autorización y la Autenticación.

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

}

Tenga en cuenta que: @Order es CRUCIAL no lo olvides, permite que nuestro interceptor se registre primero en la cadena de seguridad.

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

para el lado del cliente java use este ejemplo probado:

StompHeaders connectHeaders = new StompHeaders();
connectHeaders.add("login", "test1");
connectHeaders.add("passcode", "test");
stompClient.connect(WS_HOST_PORT, new WebSocketHttpHeaders(), connectHeaders, new MySessionHandler());
¡Haz clic para puntuar esta entrada!
(Votos: 0 Promedio: 0)



Utiliza Nuestro Buscador

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *