Arian
Arian

Reputation: 7719

Authenticate Websocket connection on handshake using JWT authentication filter

In my spring boot app, there are REST endpoints that are protected using a subclass of AbstractAuthenticationProcessingFilter. I don't use sessions, I use Java web tokens (JWT):

public class JwtAuthenticationFilter extends
        AbstractAuthenticationProcessingFilter {

    @Autowired
    private UserAccountService userAccountService;

    protected JwtAuthenticationFilter() {
        super(SecurityConstants.DEFAULT_FILTER_PROCESS_URL);
    }

    /*
     * Parses the request header and returns authentication token if credentials
     * are valid
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
            HttpServletResponse response) throws AuthenticationException,
            IOException, ServletException {
        String token = request
                .getHeader(SecurityConstants.AUTHENTICATION_HEADER);
        if (token != null) {
            // parse the token
            String username = null;
            try {
                username = Jwts
                        .parser()
                        .setSigningKey(SecurityConstants.JWT_SECRET)
                        .parseClaimsJws(
                                token.replace(SecurityConstants.BEARER, ""))
                        .getBody().getSubject();
            } catch (Exception e) {
                throw new BadCredentialsException(
                        "Bad username/password presented");
            }
            UserAccountEntity userAccount = userAccountService.loadUserByUsername(username);
            if (userAccount != null) {
                return new UsernamePasswordAuthenticationToken(
                        userAccount.getUsername(), userAccount.getPassword(),
                        userAccount.getAuthorities());
            }
        }
        throw new BadCredentialsException("Bad username/password presented");
    }

    /*
     * we must set authentication manager for our custom filter, otherwise it
     * errors out
     */
    @Override
    @Autowired
    public void setAuthenticationManager(
            AuthenticationManager authenticationManager) {
        super.setAuthenticationManager(authenticationManager);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request,
            HttpServletResponse response, AuthenticationException failed)
            throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain,
            Authentication authResult) throws IOException, ServletException {
        SecurityContextHolder.getContext().setAuthentication(authResult);
        chain.doFilter(request, response);
    }}

And the app security configuration class is the following:

@EnableWebSecurity
public class AppSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private UserAccountService userAccountService;

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
        // TODO re-enable csrf after dev is done
        .csrf()
                .disable()
                // we must specify ordering for our custom filter, otherwise it
                // doesn't work
                .addFilterAfter(jwtAuthenticationFilter(),
                        UsernamePasswordAuthenticationFilter.class)
                // we don't need Session, as we are using jwt instead. Sessions
                // are harder to scale and manage
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

    /*
     * Ignores the authentication endpoints (signup and login)
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/api/authentication/**").and().ignoring()
                .antMatchers(HttpMethod.OPTIONS, "/**");
    }

    /*
     * Set user details services and password encoder
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth)
            throws Exception {
        auth.userDetailsService(userAccountService).passwordEncoder(
                passwordEncoder());
    }

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter();
    }

    /*
     * By default, spring boot adds custom filters to the filter chain which
     * affects all requests this should be disabled.
     */
    @Bean
    public FilterRegistrationBean<JwtAuthenticationFilter> rolesAuthenticationFilterRegistrationDisable(
            JwtAuthenticationFilter filter) {
        FilterRegistrationBean<JwtAuthenticationFilter> registration = new FilterRegistrationBean<JwtAuthenticationFilter>(
                filter);
        registration.setEnabled(false);
        return registration;
    }}

I want my Websocket connection to be authenticated on handshake. The subsequent communication should be ok, as they follow an authenticated handshake.

I tried MANY different ways including setting handshake handler:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {
    public static final String IP_ADDRESS = "IP_ADDRESS";

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic"); /*Enable a simple in-memory broker for the clients to subscribe to channels and receive messages*/
        config.setApplicationDestinationPrefixes("/ws"); /*The prefix for the controller's endpoints*/
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/wsocket").setAllowedOrigins("http://localhost:8080").setHandshakeHandler(new DefaultHandshakeHandler() { /* Websocket handshake endpoint*/
            @Override
            protected Principal determineUser(ServerHttpRequest request,
                    WebSocketHandler wsHandler, Map<String, Object> attributes) {
                Principal principal = request.getPrincipal();
                if (principal == null) {
                    throw new InvalidCredentialsException();
                }
                return super.determineUser(request, wsHandler, attributes);
            }
        }).withSockJS().setInterceptors(httpSessionHandshakeInterceptor());
    }

   @Bean
   public HandshakeInterceptor httpSessionHandshakeInterceptor() {
       return new HandshakeInterceptor() {

           @Override
           public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
               if (request instanceof ServletServerHttpRequest) {
                   ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
                   attributes.put(IP_ADDRESS, servletRequest.getRemoteAddress());
               }
               return true;
           }

           @Override
           public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
           }
       };}}

Which I couldn't make it work.

I also tried the following:

@Configuration
public class WebSocketSecurityConfiguration extends AbstractSecurityWebSocketMessageBrokerConfigurer {

    @Override
    protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
        messages
            .simpDestMatchers("/wsocket/*").authenticated();
    }

    /**
     * Disables CSRF for Websockets.
     */
    @Override
    protected boolean sameOriginDisabled() {
        return true;
    }}

I expect my JWTAuthenticationFilter.attemptAuthentication() to be hit, but doesn't.

The following is the websocket configuration class:

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfiguration implements WebSocketMessageBrokerConfigurer {

    public static final String IP_ADDRESS = "IP_ADDRESS";

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic"); /*Enable a simple in-memory broker for the clients to subscribe to channels and receive messages*/
        config.setApplicationDestinationPrefixes("/ws"); /*The prefix for the controller's endpoints*/
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/wsocket").setAllowedOrigins("http://localhost:8080").withSockJS();
    }}

What am I doing wrong here? Is there a way to authenticate the websocket handshake request (which is a HTTP request) with JWT?

TL;DR There is a websocket endpoint, which I would like to be secured by a JWT authentication filter. As far as I know, the websocket handkshake is a regular HTTP request and this should be possible.

Upvotes: 2

Views: 3480

Answers (3)

Arian
Arian

Reputation: 7719

I found a way to make it work. I got rid of DefaultHandshakeHandler, and also WebSocketSecurityConfiguration sub-class.

I changed the websocket URL from /wsocket to /api/wsocket in order to match with the SecurityConstants.DEFAULT_FILTER_PROCESS_URL that is passed to AbstractAuthenticationProcessingFilter from JWTAuthenticationFilter.

I pass the jwt token as a request parameter from the UI. In JWTAuthenticationFilter request parameters are accessible, which makes it possible to authenticate the request.

Upvotes: 1

Martin
Martin

Reputation: 508

I faced the exact same problem some time ago. From what I recall, the first step is the browser trying to stablish a connection with the server, and as far as I remember, the issue was that the browser does not send the authentication header on that request. That is why authentication for websocket is handled separatedly instead of authenticating everything on the same place. I honestly do not remember the details. What follows is my approach to solving that problem. Feel free to ask for clarification and I will try to remember.

1) Allow requests to your websocket endpoint:

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    JWTService jWTService;

    @Override
    protected void configure(final HttpSecurity http) throws Exception {

            http.
                    httpBasic().disable().
                    csrf().disable().
                    authorizeRequests().antMatchers("/ws/**").permitAll().and().
                    sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

}

2) Use a channel interceptor to validate WS requests:

public class ChannelSubscriptionInterceptor extends ChannelInterceptorAdapter {

    JWTService jWTService;

    public ChannelSubscriptionInterceptor(JWTService jwtService) {
        this.jWTService = jwtService;
    }

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(message);
        final StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
        Authentication authenticatedUser = null;

        List<String> authorizationHeader = headerAccessor.getNativeHeader("Authorization");
        if(authorizationHeader != null) {
            authenticatedUser = authenticateUser(authorizationHeader.get(0));
            accessor.setUser(authenticatedUser);
        }

        if (StompCommand.SUBSCRIBE.equals(headerAccessor.getCommand())) {
            if(!validateSubscription(authenticatedUser, headerAccessor.getDestination())) {
                throw new MessagingException("No tiene permiso para suscribirse a este tópico");
            }
        }
        return message;
    }

    private boolean validateSubscription(Principal user, String channel) {
        //User not authenticated
        if (user == null) {
            return false;
        }

        //User trying to subscribe to a channel that doesn't belong to him
        if(channel.startsWith("/user") && !channel.startsWith("/user/" + user.getName() + "/")) {
            return false;
        }

        return true;
    }

    public Authentication authenticateUser(String authHeader) {
        try {
            JWToken token = new JWToken(authHeader);
            Authentication authentication = this.jWTService.validateAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            return authentication;
        } catch (Exception e) {
            return null;
        }
    }

3) Register interceptor:

@Order(HIGHEST_PRECEDENCE + 99)
public class WebSocketMessageBrokerConfigurer extends AbstractWebSocketMessageBrokerConfigurer {

    @Autowired
    JWTService jWTService;

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic", "/queue", "/user");
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.setInterceptors(new ChannelSubscriptionInterceptor(this.jWTService));
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry
                .addEndpoint("/ws")
                .setAllowedOrigins("*")
                .withSockJS()
        ;
    }
}

4) Make sure messages are authenticated:

@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class WebSocketSecurityConfig
        extends AbstractSecurityWebSocketMessageBrokerConfigurer {

    @Override
    protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
        messages
                .simpTypeMatchers(SimpMessageType.CONNECT).permitAll()
                .anyMessage().authenticated();

    }

    @Override
    protected boolean sameOriginDisabled() {
        return true;
    }
}

The

@Order(HIGHEST_PRECEDENCE + 99)

was used because we had some conflicts with other configuration classes, but you might not need it.

Upvotes: 1

StrongPoint
StrongPoint

Reputation: 331

Are you sure that SecurityConstants.DEFAULT_FILTER_PROCESS_URL is actually set to the endpoint your websocket is exposed to? I saw in another post of yours that you had issues with the values not being set on startup. Maybe just hard code it for testing.

attemptAuthentication will only be hit if the url matches the one set in the constructor.

However, I never used AbstractAuthenticationProcessingFilter but usually extend from OncePerRequestFilter. Maybe that could work for you too.

Upvotes: 1

Related Questions