3

I have a Spring Boot application that uses Spring Security to secure endpoints using JWT, which all works correctly for standard REST controllers. The app also has WebSocket connections, which I have successfully secured using the same JWT.

The Problem

If I try to inject the Principal into the WebSocket controller method, the Principal is null by default. I have found many solutions of setting the Principal by overriding the DefaultHandshakeHandler, however most of these solutions generate a random Principal name. I would rather use the existing logged in username to generate the Principal name so that I can see which user is sending data to the WebSocket and handle the response accordingly.

There is a reply here that I thought would get me closer by adding , but when applying the logic to my preSend method I get a null username even once I've added it as a parameter of my StompJS connection method: WebSocket Stomp over SockJS - http custom headers

What I also find strange is within the interceptor itself I am able to access the Principal with the correct username, but once it reaches the controller it is either null or whatever was defined in the preSend handshake method.

Here is the method used to connect to the WebSocket:

  _connect() {
    console.log("Initialize WebSocket Connection");
    if (this.authenticationService.isUserLoggedIn()) {
      let ws = new SockJS(this.webSocketEndPoint);
      this.stompClient = Stomp.over(ws);
      const _this = this;
      _this.stompClient.connect({
        'Authorization': "Bearer " + this.authenticationService.getLoggedInUserToken(),
        'username': this.authenticationService.getLoggedInUserName()
      }, function (frame) {
          _this.stompClient.subscribe(_this.board, function (data) {
              _this.onDataReceived(data);
          });
      }, this.errorCallBack);
    } else {
      console.log("Unable to connect: must be logged in.");
    }
  }

And this is my current handshake code with commented results:

    public class MyHandshakeHandler extends DefaultHandshakeHandler {
        @Override
        protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, 
                                          Map<String, Object> attributes) {
            Principal principal = SecurityContextHolder.getContext().getAuthentication();
            System.out.println("PRINCIPAL HANDSHAKE START:" + principal); //prints annonymous user
            final String ATTR_PRINCIPAL = "__principal__";
            
            ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
            HttpServletRequest httpServletRequest = servletRequest.getServletRequest();
            String username = httpServletRequest.getParameter("username");
            
            //System.out.println("Username is: " + username); // null
            final String name;
            if (!attributes.containsKey(ATTR_PRINCIPAL)) {
                name = "My made up name"; //Would like this to be the logged in user, but is currently null
                attributes.put(ATTR_PRINCIPAL, name);
            } else {
                name = (String) attributes.get(ATTR_PRINCIPAL);
            }
            return new Principal() {
                @Override
                public String getName() {
                    return name;
                }
            };
        }
    }

And finally my interceptor that authorises WebSocket requests and shows the correct logged in user:

@Configuration
@EnableWebSocketMessageBroker
@Order(Ordered.HIGHEST_PRECEDENCE + 99) 
public class WebSocketAuthenticationConfig implements WebSocketMessageBrokerConfigurer {
    
    @Autowired
    private JwtUtil jwtUtil;
    
    @Autowired
    private UserService userService;

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new ChannelInterceptor() {
            
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                Principal principal = SecurityContextHolder.getContext().getAuthentication();
                System.out.println("PRINCIPAL PRE-SEND START:" + principal); // shows annonymous user
                
                StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
                List<String> tokenList = accessor.getNativeHeader("Authorization");
                String jwt = null;
                if (tokenList == null || tokenList.size() < 1) {
                  return message;
                } else {
                    jwt = tokenList.get(0).substring(7);
                  if (jwt == null) {
                    return message;
                  }
                }
                String username = jwtUtil.extractUsername(jwt); //Decoder class that can retrieve username from token
                
                UserDetails userDetails = userService.loadUserByUsername(username);
                if (jwtUtil.validateToken(jwt, userDetails)) {
                    UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = 
                            new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                    SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
                    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
                    
                    //Used only to see which principal is found at this stage
                    principal = SecurityContextHolder.getContext().getAuthentication();
                    System.out.println("PRINCIPAL PRE-SEND END:" + principal); // shows correct logged in user
                    
                    accessor.setUser(authentication);
                }
                return message;
            }
        });
    }
}

And here's the Controller method currently being tested for the Principal:

    @MessageMapping("/test")
    public void test(@Payload String message, Principal principal) throws Exception {
        System.out.println("PRINCIPAL TEST INJECTED: " + principal.getName());
        Principal retrievedPrincipal = SecurityContextHolder.getContext().getAuthentication();
        System.out.println("PRINCIPAL TEST RETRIEVED: " + retrievedPrincipal); //null
        webSocket.convertAndSend("/topic/test", new ResponseMessage("TestSuccess", "Thanks for the request " + principal.getName())); // shows "made up username" from handshake
    }

Here is the console output when a logged in user called "example" connects to the WebSocket and tries to access the /app/test endpoint:

PRINCIPAL HANDSHAKE START:AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]]
PRINCIPAL PRE-SEND START:null
PRINCIPAL PRE-SEND END:UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=example, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]], Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_USER]]
PRINCIPAL PRE-SEND START:null
PRINCIPAL PRE-SEND START:null
PRINCIPAL TEST INJECTED: My made up name
PRINCIPAL TEST RETRIEVED: SecurityContextImpl [Null authentication]

Please let me know if you need any further code for clarity.

whereami
  • 53
  • 6
  • Were you able to resolve this issues? I am also getting authentication null as well as principal null, i do set accessor.setUser() to a valid spring boot UserDetails – silentsudo Jan 08 '22 at 18:17

1 Answers1

1

Here is what I have done after a lot of research. I arrived at this location(https://coderedirect.com/questions/310917/secure-spring-webscoket-using-spring-security-and-access-principal-from-websocke) from searching google @MessageMapping Principal is null as query to see what is different from our implementation and i found that instead of

StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);

When I try

StompHeaderAccessor accessor = MessageHeaderAccessor
                               .getAccessor(message, StompHeaderAccessor.class);

Principal inside the controller is not null now. I am not sure about in-depth of spring-messaging and MessageHeaderAccessor

Little more reference from the initial thread i searched is JSON Web Token (JWT) with Spring based SockJS / STOMP Web Socket which finally arrives at the source of truth for the main answer

https://github.com/spring-projects/spring-framework/blob/main/src/docs/asciidoc/web/websocket.adoc#token-authentication

silentsudo
  • 6,730
  • 6
  • 39
  • 81