Reputation: 7872
I've been struggling a lot to properly implement Stomp (websocket) Authentication and Authorization with Spring-Security. For posterity i'll answer my own question to provide a guide.
Spring WebSocket documentation (for Authentication) looks unclear ATM (IMHO). And i couldn't understand how to properly handle Authentication and Authorization.
Principal
available in controllers.Upvotes: 89
Views: 89784
Reputation: 1504
It also can be done with @EnableWebSocketMessageBroker
and disabled csrf protection (in case if you don't need it). And in that case, you configuration will be quite simple.
Here is my example. It's on Kotlin, but I think it's still easy to get the idea.
Security configuration:
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.messaging.Message
import org.springframework.security.authorization.AuthorizationManager
import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity
import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager
@Configuration
@EnableWebSocketSecurity
class SecurityConfig {
@Bean
fun messageAuthorizationManager(messages: MessageMatcherDelegatingAuthorizationManager.Builder): AuthorizationManager<Message<*>> {
messages.nullDestMatcher().authenticated()
.simpSubscribeDestMatchers("/app/notifications").permitAll()
.simpSubscribeDestMatchers("/queue/tasks/**").hasAuthority("SCOPE_tasks.read")
.simpDestMatchers("/app/**").hasRole("ADMIN")
return messages.build()
}
}
Custom CSRF interceptor:
import org.springframework.messaging.Message
import org.springframework.messaging.MessageChannel
import org.springframework.messaging.support.ChannelInterceptor
import org.springframework.stereotype.Component
@Component("csrfChannelInterceptor")
class CustomCsrfChannelInterceptor : ChannelInterceptor {
override fun preSend(message: Message<*>, channel: MessageChannel): Message<*>? {
return message;
}
}
And that's it. You can configure authorization/authentication politics in the config, using all the advantages of @EnableWebSocketMessageBroker
and you don't have to deal with the CSRF protection if you don't need it. Unfortunately, at present, there is no easier way to disable the protection.
Link to the comprehensive Spring's guide: https://docs.spring.io/spring-security/reference/servlet/integrations/websocket.html#websocket-authorization
Upvotes: 3
Reputation: 297
If you are using this Stomp Client library for Android then you actually can add Http headers to initial handshake request.
Here is an example:
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Your auth token");
StompClient stompClient = Stomp.over(Stomp.ConnectionProvider.OKHTTP,
"ws://example.com/api/websocket", headers);
Upvotes: 0
Reputation: 7872
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 before you start:
AuthenticationProvider
take not part at all in Websocket authentication.simpUser
) will be stored in the websocket session and no more authentication will be required on further messages.<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>
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("*")
}
}
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();
}
}
@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
.
@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.
@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;
}
}
Upvotes: 152
Reputation: 1735
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.
@Component
public 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:
@Configuration
public class SomeConfig {
@Bean
public FilterRegistrationBean<CustomAuthenticationFilter> securityFilter(
CustomAuthenticationFilter customAuthenticationFilter){
FilterRegistrationBean<CustomAuthenticationFilter> registrationBean
= new FilterRegistrationBean<>();
registrationBean.setFilter(customAuthenticationFilter);
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
}
Upvotes: 1
Reputation: 71
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());
Upvotes: 4