I. Things to Know
- The security chain and security configuration of HTTP and WebSocket are completely independent.
- SpringAuthenticationProvider is not involved in WebSocket authentication at all.
- In the examples given, authentication will not occur on the HTTP negotiation endpoint, because the JavaScript STOMP (websocket) libraries do not send the necessary authentication headers along with the HTTP request.
- Once set on the CONNECT request, the user (simpUser) will be stored in the websocket session, and subsequent messages will no longer need authentication.
II. Dependencies
<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>
III. WebSocket Configuration
3.1, Simple Message Broker
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(final MessageBrokerRegistry config) {
config.enableSimpleBroker("/queue/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(final StompEndpointRegistry registry) {
registry.addEndpoint("stomp");
setAllowedOrigins("*")
}
}
3.2, Spring Security Configuration
Since the Stomp protocol relies on the first HTTP request, authorization for the stomp handshake endpoint HTTP call is required.
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(final HttpSecurity http) throws Exception
http.httpBasic().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
.authorizeRequests().antMatchers("/stomp").permitAll()
.anyRequest().denyAll();
}
}
Then create a service responsible for verifying user identity.
@Component
public class WebSocketAuthenticatorService {
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.");
}
if (fetchUserFromDb(username, password) == null) {
throw new BadCredentialsException("Bad credentials for user " + username);
}
return new UsernamePasswordAuthenticationToken(
username,
null,
Collections.singleton((GrantedAuthority) () -> "USER") // Must give at least one role
);
}
}
Next you need to create an interceptor that will set the "simpUser" header or throw an "AuthenticationException" on the CONNECT message.
@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: preSend() must return UsernamePasswordAuthenticationToken, this will be tested in the Spring security chain. If the UsernamePasswordAuthenticationToken construction does not pass GrantedAuthority, authentication will fail, because the constructor without granted authorities automatically sets authenticated = false. This is an important detail not documented in spring-security.
Finally, create two more classes to handle authorization and authentication separately.
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class WebSocketAuthenticationSecurityConfig extends WebSocketMessageBrokerConfigurer {
@Inject
private AuthChannelInterceptorAdapter authChannelInterceptorAdapter;
@Override
public void registerStompEndpoints(final StompEndpointRegistry registry) {
// Nothing to do here
}
@Override
public void configureClientInboundChannel(final ChannelRegistration registration) {
registration.setInterceptors(authChannelInterceptorAdapter);
}
}
Note: This @Order is critical, it allows our interceptor to register first in the security chain.
@Configuration
public class WebSocketAuthorizationSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {
@Override
protected void configureInbound(final MessageSecurityMetadataSourceRegistry messages) {
// Add your own mappings
messages.anyMessage().authenticated();
}
// Modify as needed
@Override
protected boolean sameOriginDisabled() {
return true;
}
}
After that, write the client to connect, we can specify the client to send messages like this.
@MessageMapping("/greeting")
public void greetingReturn(@Payload Object ojd){
simpMessagingTemplate.convertAndSendToUser(username,"/topic/greeting",ojd);
}
Comments