1. Overview
In this tutorial, we will talk about how to authenticate your Spring websocket server using JWT Spring Secutiry. Suppose that you already have a stateless microservice with an authorisation server up and running. Now you would want that all of your websocket service instances have to connect to the authorisation service to validate user's access token before allowing them to establish a websocket connection to your server. The official Spring document [1] has mentioned about this. However, they don't clarify how can we obtain the Authentication object. One can write a custom token validation mechanism and validate the user header token manually. But in our opinion, it is best to avoid doing this, as it may introduce some security vulnerabilities.
In this tutorial, we will walk you though step-by-step of how to validate user's access token by reusing the JwtAuthentication provided by Spring Security framework.
2. Gradle Dependencies
Since this is a Gradle-based project, we first add the required dependencies to the build.gradle:
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-reactor-netty'
implementation 'org.springframework.boot:spring-boot-starter-websocket'3. Enable WebSocket in Spring
First, we enable the WebSocket capabilities by adding a configuration to our application and annotate this class with
@EnableWebSocketMessageBroker, as follow:@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/websocket");
registry.addEndpoint("/websocket").withSockJS();
}
}
Now, what we want to do is to secure the STOMP endpoint /websocket to ensure that any one who wants to establish a websocket connection to our service has to be authenticated. 4. Configure WebSocket authentication
Since our services are stateless, therefore we can't rely on cookie session to validate user. Instead, we can validate the access token in the header of the
CONNECT handshake request. In order to do so, we can define an interceptor to intercept this request and validate it as follow:
@Configuration
@EnableWebSocketMessageBroker
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Autowired
private AuthenticationProvider authenticationProvider;
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message preSend(Message message, MessageChannel channel) {
StompHeaderAccessor accessor =
MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String token = accessor.getFirstNativeHeader("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
throw new AccessDeniedException("Unauthorised");
}
token = token.substring(7);
BearerTokenAuthenticationToken authenticationRequest = new BearerTokenAuthenticationToken(token);
Authentication user = authenticationProvider.authenticate(authenticationRequest);
if (!user.isAuthenticated()) {
throw new AccessDeniedException("Unauthorised");
}
accessor.setUser(user);
}
return message;
}
});
}
}
Here we have configured our service to validate user's access token before establising the websocket connection. We use a bean's object of class AuthenticationProvider to validate the Authorization header of the connecting user. If and only if the user is validated, then we allow the user to establish a websocket connection, otherwise, we reject by throwing an AccessDeniedException. 5. Configure Spring Security
Here, we define the authentication logic for our websocket service. The first thing we need to do is configuring our security context to reject all but the websocket endpoints.
@EnableWebSecurity
public class WebSecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeRequests((authorize) -> authorize
.antMatchers("/websocket").permitAll()
.antMatchers("/websocket/**").permitAll()
.anyRequest().denyAll());
return http.build();
}
}/websocket and /websocket/** since these are the endpoints that will be used by Spring Websocket and will be authenticated by the interceptor that we have defined in the previous section. Any other requests will be denied.Next, we will define the AuthenticationProvider that we have mentioned earlier. From the same
WebSercurityConfig class, we define the configurations as follow:@EnableWebSecurity
public class WebSecurityConfig {
private final JWSAlgorithm jwsAlgorithm = JWSAlgorithm.RS256;
private final JWEAlgorithm jweAlgorithm = JWEAlgorithm.RSA_OAEP_256;
private final EncryptionMethod encryptionMethod = EncryptionMethod.A256GCM;
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
URL jwkSetUri;
@Value("${auth.jwe-key-value}")
RSAPrivateKey key;
@Bean
public AuthenticationProvider getJwsAuthenticationProvider(JwtDecoder decoder) {
JwtAuthenticationProvider provider = new JwtAuthenticationProvider(decoder);
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
provider.setJwtAuthenticationConverter(converter);
return provider;
}
@Bean
JwtDecoder jwtDecoder() {
return new NimbusJwtDecoder(jwtProcessor());
}
private JWTProcessor jwtProcessor() {
JWKSource jwsJwkSource = new RemoteJWKSet<>(this.jwkSetUri);
JWSKeySelector jwsKeySelector = new JWSVerificationKeySelector<>(this.jwsAlgorithm,
jwsJwkSource);
JWKSource jweJwkSource = new ImmutableJWKSet<>(new JWKSet(rsaKey()));
JWEKeySelector jweKeySelector = new JWEDecryptionKeySelector<>(this.jweAlgorithm,
this.encryptionMethod, jweJwkSource);
ConfigurableJWTProcessor jwtProcessor = new DefaultJWTProcessor<>();
jwtProcessor.setJWSKeySelector(jwsKeySelector);
jwtProcessor.setJWEKeySelector(jweKeySelector);
return jwtProcessor;
}
private RSAKey rsaKey() {
RSAPrivateCrtKey crtKey = (RSAPrivateCrtKey) this.key;
Base64URL n = Base64URL.encode(crtKey.getModulus());
Base64URL e = Base64URL.encode(crtKey.getPublicExponent());
return new RSAKey.Builder(n, e).privateKey(this.key).keyUse(KeyUse.ENCRYPTION).build();
}
}
It can be seen that we have reused the Spring's built-in JwtAuthenticationProvider with JWTProcessor as the JWTDecoder to decode the JWT access token. The JWTProcessor decode the JWT access token by checking with the authorisation server via jwkSetUri using it own RSAPrivateKey.With this setup, your websocket service is now able to connect to your authorisation server to verify user's access token.
A simple implementation of this tutorial can be found here: https://github.com/dispatcher-servlet/websocket-jwt-oauth2
For testing this JWT websocket authentication, please refer to https://dispatcherservlet.blogspot.com/2022/10/spring-websocket-integration-test-with.html
[1] https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#websocket-stomp-authentication-token-based
Comments
Post a Comment