Reputation: 5313
I am working on a Spring-MVC application in which we have Spring-security for authentication and authorization. We are working on migrating to Spring websockets, but we are having an issue with getting the authenticated user inside a websocket connection. The security context simply doesn't exist in the websocket connection, but works fine with regular HTTP. What are we doing wrong?
WebsocketConfig :
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/app").withSockJS();
}
}
In the controller below, we are trying to get the currently authenticated user and it's always null
@Controller
public class OnlineStatusController extends MasterController{
@MessageMapping("/onlinestatus")
public void onlineStatus(String status) {
Person user = this.personService.getCurrentlyAuthenticatedUser();
if(user!=null){
this.chatService.setOnlineStatus(status, user.getId());
}
}
}
security-applicationContext.xml :
<security:http pattern="/resources/**" security="none"/>
<security:http pattern="/org/**" security="none"/>
<security:http pattern="/jquery/**" security="none"/>
<security:http create-session="ifRequired" use-expressions="true" auto-config="false" disable-url-rewriting="true">
<security:form-login login-page="/login" username-parameter="j_username" password-parameter="j_password"
login-processing-url="/j_spring_security_check" default-target-url="/canvaslisting"
always-use-default-target="false" authentication-failure-url="/login?error=auth"/>
<security:remember-me key="_spring_security_remember_me" user-service-ref="userDetailsService"
token-validity-seconds="1209600" data-source-ref="dataSource"/>
<security:logout delete-cookies="JSESSIONID" invalidate-session="true" logout-url="/j_spring_security_logout"/>
<security:csrf disabled="true"/>
<security:intercept-url pattern="/cometd/**" access="permitAll" />
<security:intercept-url pattern="/app/**" access="hasAnyRole('ROLE_ADMIN','ROLE_USER')" />
<!-- <security:intercept-url pattern="/**" requires-channel="https"/>-->
<security:port-mappings>
<security:port-mapping http="80" https="443"/>
</security:port-mappings>
<security:logout logout-url="/logout" logout-success-url="/" success-handler-ref="myLogoutHandler"/>
<security:session-management session-fixation-protection="newSession">
<security:concurrency-control session-registry-ref="sessionReg" max-sessions="5" expired-url="/login"/>
</security:session-management>
</security:http>
Upvotes: 4
Views: 4475
Reputation: 4171
I spent a day dealing with this - so here is my updated answer - hopefully it helps someone:
ChannelInterceptor
:public final class WebsocketContextChannelInterceptor implements ChannelInterceptor {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
if (message instanceof GenericMessage<?> genericMessage &&
genericMessage.getHeaders().get("simpUser") instanceof PreAuthenticatedAuthenticationToken token) {
SecurityContextHolder.getContext().setAuthentication(token);
}
return message;
}
}
Here the issue I had is that apparently, interceptor sets it in a parent thread, and controller that then might want to read the Principal is in child thread. Therefore I had to set SecurityContext to be inheritable (with SecurityContextHolder.MODE_INHERITABLETHREADLOCAL
)
Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/**
* Required so that Controller threads (child threads) can access SecurityContext that WebSocket set for them (parent thread)
*/
@PostConstruct
void setGlobalSecurityContext() {
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}
/**
* Interceptor that adds SecurityContext from the message header
*/
@Bean
public ChannelInterceptor websocketContextChannelInterceptor() {
return new WebsocketContextChannelInterceptor();
}
/**
* Setting the interceptor {{@link #websocketContextChannelInterceptor()}}
*/
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(websocketContextChannelInterceptor());
}
/**
* Adding websocket endpoint
* @param registry
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/websocket").setAllowedOrigins("*");
}
}
Upvotes: 0
Reputation: 370
I remember stumbling across the very same problem in a project I was working on. As I could not figure out the solution using the Spring documentation - and other answers on Stack Overflow were not working for me - I ended up creating a workaround.
The trick is essentially to force the application to authenticate the user on a WebSocket connection request. To do that, you need a class which intercepts such events and then once you have control of that, you can call your authentication logic.
Create a class which implements Spring's ChannelInterceptorAdapter
. Inside this class, you can inject any beans you need to perform the actual authentication. My example uses basic auth:
@Component
public class WebSocketAuthInterceptorAdapter extends ChannelInterceptorAdapter {
@Autowired
private DaoAuthenticationProvider userAuthenticationProvider;
@Override
public Message<?> preSend(final Message<?> message, final MessageChannel channel) throws AuthenticationException {
final StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
StompCommand cmd = accessor.getCommand();
if (StompCommand.CONNECT == cmd || StompCommand.SEND == cmd) {
Authentication authenticatedUser = null;
String authorization = accessor.getFirstNativeHeader("Authorization:");
String credentialsToDecode = authorization.split("\\s")[1];
String credentialsDecoded = StringUtils.newStringUtf8(Base64.decodeBase64(credentialsToDecode));
String[] credentialsDecodedSplit = credentialsDecoded.split(":");
final String username = credentialsDecodedSplit[0];
final String password = credentialsDecodedSplit[1];
authenticatedUser = userAuthenticationProvider.authenticate(new UsernamePasswordAuthenticationToken(username, password));
if (authenticatedUser == null) {
throw new AccessDeniedException();
}
SecurityContextHolder.getContext().setAuthentication(authenticatedUser);
accessor.setUser(authenticatedUser);
}
return message;
}
}
Then, in your WebSocketConfig
class, you need to register your interceptor. Add the above class as a bean and register it. After these changes, your class would look like this:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Autowired
private WebSocketAuthInterceptorAdapter authInterceptorAdapter;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/app").withSockJS();
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.setInterceptors(authInterceptorAdapter);
super.configureClientInboundChannel(registration);
}
}
Obviously, the details of the authentication logic are up to you. You can call a JWT service or whatever you are using.
Upvotes: 5
Reputation: 165
If you are using SockJS + Stomp and configured your security correctly, you should be able to connect via regular username/pw authenticator like @AlgorithmFromHell and do
accessor.setUser(authentication.getPrincipal()) // stomp header accessor
You can also connect via http://{END_POINT}/access_token={ACCESS_TOKEN}. Spring security should be able to pick it and do loadAuthentication(access_token) via ResourceServerTokenServices. When this is done, you can get your principal by adding this to your impl of AbstractSessionWebSocketMessageBrokerConfigurer or WebSocketMessageBrokerConfigurer. When doing this, for some reason, the loaded Pricipal is saved in "simpUser" header instead.
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(final Message<?> message, final MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
if (message.getHeaders().get("simpUser") != null && message.getHeaders().get("simpUser") instanceof OAuth2Authentication) { // or Authentication depending on your impl of security
OAuth2Authentication authentication = (OAuth2Authentication) message.getHeaders().get("simpUser");
accessor.setUser(authentication != null ? (UserDetails) authentication.getPrincipal() : null);
}
}
return message;
}
});
}
Upvotes: 1