Websocket Authentication and Authorization in Spring

前端 未结 2 957
不思量自难忘°
不思量自难忘° 2020-11-29 17:25

I\'ve been struggling a lot to properly implement Stomp (websocket) Authentication and Authorization with Spring-Security. For posterit

相关标签:
2条回答
  • 2020-11-29 17:35

    As stated above the documentation (ATM) is unclear (IMHO), until Spring provide some clear documentation, here is a boilerplate to save you from spending two days trying to understand what the security chain is doing.

    A really nice attempt was made by Rob-Leggett but, he was forking some Springs class and i don't feel comfortable doing so.

    Things to know:

    • Security chain and Security config for http and WebSocket are completely independent.
    • Spring AuthenticationProvider take not part at all in Websocket authentication.
    • The authentication won't happen on HTTP negotiation endpoint because none of the JavaScripts STOMP (websocket) sends the necessary authentication headers along with the HTTP request.
    • Once set on CONNECT request, the user (simpUser) will be stored in the websocket session and no more authentication will be required on further messages.

    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

    The below config register a simple message broker (a simple endpoint that we will later protect).

    @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 security config

    Since the Stomp protocol rely on a first HTTP Request, we'll need to authorize HTTP call to our stomp handshake endpoint.

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

    Then we'll create a service responsible for authenticating users.
    @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
            );
        }
    }
    

    Note that: UsernamePasswordAuthenticationToken MUST have at least one GrantedAuthority, if you use another constructor, Spring will auto-set isAuthenticated = false.


    Almost there, now we need to create an Interceptor that will set the `simpUser` header or throw `AuthenticationException` on CONNECT messages.
    @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;
        }
    }
    

    Note that: preSend() MUST return a UsernamePasswordAuthenticationToken, another element in the spring security chain test this. Note that: If your UsernamePasswordAuthenticationToken was built without passing GrantedAuthority, the authentication will fail, because the constructor without granted authorities auto set authenticated = false THIS IS AN IMPORTANT DETAIL which is not documented in spring-security.


    Finally create two more class to handle respectively Authorization and Authentication.
    @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);
        }
    
    }
    

    Note that: The @Order is CRUCIAL don't forget it, it allows our interceptor to be registered first in the security chain.

    @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;
        }
    }
    
    0 讨论(0)
  • 2020-11-29 17:52

    for java client side use this tested example:

    StompHeaders connectHeaders = new StompHeaders();
    connectHeaders.add("login", "test1");
    connectHeaders.add("passcode", "test");
    stompClient.connect(WS_HOST_PORT, new WebSocketHttpHeaders(), connectHeaders, new MySessionHandler());
    
    0 讨论(0)
提交回复
热议问题