Websocket Authentication and Authorization in Spring
As stated above the documentation looks 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@EnableWebSocketMessageBrokerpublic 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.
@Configurationpublic 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.
@Componentpublic 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.
@Componentpublic 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.
@Configurationpublic 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; }}
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());
Going with spring authentication is a pain. You can do it in a simple way. Create a web Filter and read the Authorization token by yourself, then perform the authentication.
@Componentpublic class CustomAuthenticationFilter implements Filter { @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { if (servletRequest instanceof HttpServletRequest) { HttpServletRequest request = (HttpServletRequest) servletRequest; String authorization = request.getHeader("Authorization"); if (/*Your condition here*/) { // logged filterChain.doFilter(servletRequest, servletResponse); } else { HttpServletResponse response = (HttpServletResponse) servletResponse; response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE); response.getWriter().write("{\"message\": "\Bad login\"}"); } } } @Override public void init(FilterConfig filterConfig) throws ServletException { } @Override public void destroy() { }}
Then in your configuration define the filter using the spring mechanism:
@Configurationpublic class SomeConfig { @Bean public FilterRegistrationBean<CustomAuthenticationFilter> securityFilter( CustomAuthenticationFilter customAuthenticationFilter){ FilterRegistrationBean<CustomAuthenticationFilter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(customAuthenticationFilter); registrationBean.addUrlPatterns("/*"); return registrationBean; }}