I am using SockJS + STOMP + Spring WebSocket with a configuration that receives user credentials during the handshake. The credentials are received in the configureClientInboundChannel method:
#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 (accessor != null && (StompCommand.CONNECT.equals(accessor.getCommand())
|| StompCommand.SEND.equals(accessor.getCommand()))) {
List<String> auth = accessor.getNativeHeader("Authorization");
System.out.printf("Authorization: %s%n", auth.get(0));
}
return message;
}
});
}
I would like to add a Principal based on the token I receive in the header. I have a handler that registers Principals:
public class PrincipalHandshakeHandler extends DefaultHandshakeHandler {
#Override
protected Principal determineUser(ServerHttpRequest request,
WebSocketHandler handler, Map<String, Object> attributes) {
System.out.println(attributes);
return new Principal() {
#Override
public String getName() {
return userId;
}
};
}
}
But how do I get pass the token I get in configureClientInboundChannel to the above handshake handler? Basically, the token should go there in place of userId.
Related
I have problem with #DeleteMapping.
Situation is like below.
If I request to /v1/cache/{cacheEntry} with method DELETE,
It respond with 404, but body was empty. no message, no spring default json 404 response message.
If i request to /v1/cache/{cacheEntry} with method POST,
It respond with 405 and body was below. (This action is correct, not a bug.)
If I change #DeleteMapping to #PostMapping, and request /v1/cache/{cacheEntry} with method POST, It respond success with code 200.
{
"timestamp": 1643348039913,
"status": 405,
"error": "Method Not Allowed",
"message": "",
"path": "/v1/cache/{cacheEntry}"
}
// Controller
#Slf4j
#RestController
#RequestMapping("/v1/cache")
#RequiredArgsConstructor
public class CacheController {
private final CacheService cacheService;
#PostMapping("/{cacheEntry}")
public CacheClearResponse clearCacheEntry(#PathVariable("cacheEntry") CacheChannels cacheEntry) {
try {
log.info("Cache entry :: " + cacheEntry);
cacheService.evictCacheEntry(cacheEntry);
return CacheClearResponse.builder()
.result(
RequestResult.builder()
.code(9200)
.message("SUCCESS")
.build()
)
.common(
Common.builder().build()
)
.date(LocalDateTime.now())
.build();
} catch (Exception e) {
e.printStackTrace();
StringWriter sw = new StringWriter();
e.printStackTrace(new PrintWriter(sw));
return CacheClearResponse.builder()
.result(
RequestResult.builder()
.code(9999)
.message(sw.toString())
.build()
)
.common(
Common.builder().build()
)
.date(LocalDateTime.now())
.build();
}
}
}
}
// CacheService
#Service
#RequiredArgsConstructor
public class CacheService {
private final CacheManager cacheManager;
public void evictCacheEntry(CacheChannels cacheEntry) {
Cache cache = cacheManager.getCache(cacheEntry.getCacheName());
if (cache != null) {
cache.clear();
}
}
public void evictCache(CacheChannels cacheEntry, String cacheKey) {
Cache cache = cacheManager.getCache(cacheEntry.getCacheName());
if (cache != null) {
cache.evict(cacheKey);
}
}
}
// Enum
#Getter
#AllArgsConstructor
public enum CacheChannels {
CACHE_TEN_MIN(Names.CACHE_TEN_MIN, Duration.ofMinutes(10)),
CACHE_HALF_HR(Names.CACHE_HALF_HR, Duration.ofMinutes(30)),
CACHE_ONE_HR(Names.CACHE_ONE_HR, Duration.ofHours(1)),
CACHE_THREE_HR(Names.CACHE_THREE_HR, Duration.ofHours(3)),
CACHE_SIX_HR(Names.CACHE_SIX_HR, Duration.ofHours(6)),
CACHE_ONE_DAY(Names.CACHE_ONE_DAY, Duration.ofDays(1));
private final String cacheName;
private final Duration cacheTTL;
public static CacheChannels from(String value) {
return Arrays.stream(values())
.filter(cacheChannel -> cacheChannel.cacheName.equalsIgnoreCase(value))
.findAny()
.orElse(null);
}
public static class Names {
public static final String CACHE_TEN_MIN = "cache10Minutes";
public static final String CACHE_HALF_HR = "cache30Minutes";
public static final String CACHE_ONE_HR = "cache1Hour";
public static final String CACHE_THREE_HR = "cache3Hours";
public static final String CACHE_SIX_HR = "cache6Hours";
public static final String CACHE_ONE_DAY = "cache1Day";
}
}
// Converter
#Slf4j
public class StringToCacheChannelConverter implements Converter<String, CacheChannels> {
#Override
public CacheChannels convert(String source) {
log.info("Convert Target: " + source);
return CacheChannels.from(source);
}
}
// Security Config
#Configuration
#EnableWebSecurity
#Order(1)
public class APISecurityConfig extends WebSecurityConfigurerAdapter {
#Value("${spring.security.auth-token-header-name:Authorization}")
private String apiKeyHeader;
#Value("${spring.security.secret}")
private String privateApiKey;
#Override
protected void configure(HttpSecurity http) throws Exception {
APIKeyAuthFilter filter = new APIKeyAuthFilter(apiKeyHeader);
filter.setAuthenticationManager(new AuthenticationManager() {
#Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
String requestedApiKey = (String) authentication.getPrincipal();
if (!privateApiKey.equals(requestedApiKey)) {
throw new BadCredentialsException("The API Key was not found or not the expected value");
}
authentication.setAuthenticated(true);
return authentication;
}
});
http
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.addFilter(filter)
.authorizeRequests()
.antMatchers("/v1/cache/**")
.authenticated();
}
}
// Filter
#Slf4j
public class APIKeyAuthFilter extends AbstractPreAuthenticatedProcessingFilter {
private String apiKeyHeader;
public APIKeyAuthFilter(String apiKeyHeader) {
this.apiKeyHeader = apiKeyHeader;
}
#Override
protected Object getPreAuthenticatedPrincipal(HttpServletRequest httpServletRequest) {
log.info("Check authenticated.");
return httpServletRequest.getHeader(apiKeyHeader);
}
#Override
protected Object getPreAuthenticatedCredentials(HttpServletRequest httpServletRequest) {
return "N/A";
}
}
// Web Config
#Configuration
public class WebConfig implements WebMvcConfigurer {
#Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StringToCacheChannelConverter());
}
#Bean
public HiddenHttpMethodFilter hiddenHttpMethodFilter() {
return new HiddenHttpMethodFilter();
}
}
This can be expected the controller was loaded, endpoint was mapped.
I tried change #DeleteMapping to #PostMapping and it was successfully respond against to POST request.
What am I missing?
I found reason why received 404 without any messages.
My tomcat is on remote server. It configured with security-constraint and disable DELETE method for all enpoints.
I just comment out it and It work properly with delete method.
I'm new to Spring boot and trying to learn it. I'm following a tutorial on how to make a simple one-to-one chat app.
Everything is working fine except that messages aren't getting sent between users. Messages get from the sender to the server but when convertAndSentToUser() nothing gets to the other client (recipient).
Here's the message broker configuration:
#Configuration
#EnableWebSocketMessageBroker
public class WebSocketMessageBrokerConfig implements WebSocketMessageBrokerConfigurer {
#Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/user");
config.enableSimpleBroker("/topic", "/queue");
config.setApplicationDestinationPrefixes("/app");
config.setUserDestinationPrefix("/user");
}
#Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/broadcast");
registry.addEndpoint("/broadcast").withSockJS().setHeartbeatTime(60_000);
registry.addEndpoint("/chat").withSockJS();
}
#Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new UserInterceptor());
}
}
And the controller that handles new connections:
#RestController
#Log4j2
public class WebSocketConnectionRestController {
#Autowired
private ActiveUserManager activeSessionManager;
...
#PostMapping("/rest/user-connect")
public String userConnect(HttpServletRequest request, #ModelAttribute("username") String userName) {
String remoteAddr = "";
if (request != null) {
remoteAddr = request.getHeader("Remote_Addr");
if (remoteAddr == null || remoteAddr.isEmpty()) {
remoteAddr = request.getHeader("X-FORWARDED-FOR");
if (remoteAddr == null || "".equals(remoteAddr)) {
remoteAddr = request.getRemoteAddr();
}
}
}
log.info("registered " + userName + " " + remoteAddr);
activeSessionManager.add(userName, remoteAddr);
return remoteAddr;
}
...
}
Finally, here's the controller that handles new messages:
#Controller
#Log4j2
public class WebSocketChatController implements ActiveUserChangeListener {
#Autowired
private SimpMessagingTemplate webSocket;
...
#MessageMapping("/chat")
public void send(#Payload ChatMessage chatMessage) throws Exception {
ChatMessage message = new ChatMessage(chatMessage.getFrom(), chatMessage.getText(), chatMessage.getRecipient());
log.info("sent message to " + chatMessage.getRecipient());
webSocket.convertAndSendToUser(chatMessage.getRecipient(), "/queue/messages", message);
}
...
}
I finally found the problem. The tutorial had a UserInterceptor which links the session id for new connections to the username but it was trying to cast an Arraylist to a LinkedList so I just casted it to a List instead:
public class UserInterceptor implements ChannelInterceptor {
#Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor
= MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
Object raw = message
.getHeaders()
.get(SimpMessageHeaderAccessor.NATIVE_HEADERS);
if (raw instanceof Map) {
Object name = ((Map) raw).get("username");
if (name instanceof List) {
accessor.setUser(new User(((List) name).get(0).toString()));
}
}
}
return message;
}
}
I'm trying to implement a microservice for sending messages via WebSocket.
I can send correctly messages to a client subcribed and authenticated (passing the JWT token to the WebClient server), but now I want send messages only to a particular user.
Following the official spring documentation I can have the client correctly subscribed but I don't get any message.
WebSocket Server configuration:
WebSecurityConfig.java
[...]
#Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
http.cors()
.and().csrf().disable()
.authorizeRequests()
.anyRequest()
.authenticated()
;
}
#Override
public void configure(WebSecurity web) {
web.ignoring()
.antMatchers("/ws/**");
}
[...]
WebSocketConfig.java
#Slf4j
#Configuration
//#EnableScheduling
#EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
#Qualifier("websocket")
private AuthenticationManager authenticationManager;
#Autowired
public WebSocketConfig(AuthenticationManager authenticationManager)
{
this.authenticationManager = authenticationManager;
}
#Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOrigins("*")
registry.addEndpoint("/ws").setAllowedOrigins("*").withSockJS();
}
#Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
#Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor headerAccessor =
MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (StompCommand.CONNECT.equals(headerAccessor.getCommand()))
{
// log.info("TOKEN: {}", headerAccessor.getNativeHeader("token").toString());
Optional.ofNullable(headerAccessor.getNativeHeader("token")).ifPresent(ah ->
{
String bearerToken = ah.get(0).replace("Bearer ", "");
JWSAuthenticationToken token = (JWSAuthenticationToken) authenticationManager
.authenticate(new JWSAuthenticationToken(bearerToken));
headerAccessor.setUser(token);
});
}
return message;
}
});
}
#Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/app");
registry.enableSimpleBroker("/topic", "/queue");
}
}
WebSocketSecurityConfig.java
#Configuration
public class WebSocketSecurityConfig extends AbstractSecurityWebSocketMessageBrokerConfigurer {
#Override
protected void configureInbound(MessageSecurityMetadataSourceRegistry messages) {
messages
.simpTypeMatchers(CONNECT, HEARTBEAT, UNSUBSCRIBE, DISCONNECT).permitAll()
.simpDestMatchers("/app/**", "/topic/**", "/queue/**").authenticated()
.simpSubscribeDestMatchers("/topic/**", "/queue/**", "/user/**").authenticated()
.anyMessage().denyAll();
}
#Override
protected boolean sameOriginDisabled() {
//disable CSRF for websockets for now...
return true;
}
}
In a Java class I tryied both convertAndSend and convertAndSendToUser (probably a better choice) functions for send the message:
simpMessagingTemplate.convertAndSend("/user/" + username + "/queue/private-messages", message);
simpMessagingTemplate.convertAndSendToUser("user","/queue/private-messages", message);
WebSocket Client configurations
WebSecurityConfig.java
[...]
#Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
http.cors()
.and().csrf().disable()
.authorizeRequests()
.anyRequest()
.authenticated()
;
}
[...]
StompClient.java where i use the function createAndConnectClient for create the connection
[...]
public void createAndConnectClient(String accessToken) {
WebSocketClient client = new StandardWebSocketClient();
WebSocketStompClient stompClient = new WebSocketStompClient(client);
stompClient.setMessageConverter(new MappingJackson2MessageConverter());
StompSessionHandler sessionHandler = new MyStompSessionHandler(stompConfig);
// connect with custom headers
final WebSocketHttpHeaders headers = new WebSocketHttpHeaders();
final StompHeaders head = new StompHeaders();
head.add("token", accessToken);
stompClient.connect(serverURL, headers, head, sessionHandler);
}
[...]
MyStompSessionHandler
#Component
public class MyStompSessionHandler extends StompSessionHandlerAdapter {
private final StompConfig stompConfig;
#Autowired
public MyStompSessionHandler(StompConfig stompConfig) {
this.stompConfig = stompConfig;
}
#Override
public void afterConnected(StompSession session, StompHeaders connectedHeaders) {
log.info("New session established : " + session.getSessionId());
session.subscribe("/user/queue/private-messages", this);
log.info("Subscribed to /user/queue/private-messages");
}
#Override
public void handleException(StompSession session, StompCommand command, StompHeaders headers, byte[] payload, Throwable exception) {
log.error("Got an exception", exception);
}
#Override
public Type getPayloadType(StompHeaders headers) {
return Message.class;
}
#Override
public void handleFrame(StompHeaders headers, Object payload) {
if (payload != null) {
Message msg = (Message) payload;
log.info("Received : " + msg.getText() + " from : " + msg.getFrom());
} else {
log.info("NULL Payload");
}
}
}
As I said I can subscribe to the destination but I don't receive any message.
EDIT: I added this code when I send the message
[...]
SimpMessageHeaderAccessor headerAccessor = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE);
JWSAuthenticationToken token = null;
try {
token = userService.getToken(message.getTo());
log.info("TOKEN BEFORE SEND: {}", token);
} catch (NotFoundException e) {
e.printStackTrace();
log.error("User isn't connected");
}
headerAccessor.setUser(token);
headerAccessor.setSessionId(userService.getSessionId(message.getTo()));
log.info("SESSION-ID: {}", headerAccessor.getSessionId());
log.info("HEADER: {}", headerAccessor.toString());
simpMessagingTemplate.convertAndSendToUser(message.getTo(), webSocketConfig.getDestination(), message, headerAccessor.getMessageHeaders());
[...]
and so... I'm sure the user (and the SessionID too) is the same. but still not receinving nothing on my client!
WORKING SOLUTION:
Not the thing I want but... it works! ¯_(ツ)_/¯
(no benefit from STOMP mechanisms for users management)
When I subscribe to destination, in my Client, I add to destionation url the name of the user
"/queue/messages-" + user.getName().
Using convertAndSend on that destination (Server side that send messages and without headers) I can send private messages to user.
I believe this is happening because the DefaultSimpUserRegistry is not getting updated with the user. If you see this line where it is supposed to add the user, it does not add from the STOMP headerAccesssor but from the from the sub-protocol i.e websocket protocol. So you will have to set the user for the websocket session.
private void populateSecurityContextHolder(String
username, List<SimpleGrantedAuthority> grantedAuthorities) {
PreAuthenticatedAuthenticationToken authToken = new PreAuthenticatedAuthenticationToken(
username, null, grantedAuthorities);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
So on setting this, the user registry should get updated with the user and hence when the message is sent to the queue when it finds the user it should be able to send it to the user, rather than discarding the message because the user was not present.
Did you set the UserDestinationPrefix for SimpMessagingTemplate. If not can you please try setting that to '/user & see if it works.
Not the thing I want but... it works! ¯_(ツ)_/¯
(no benefit from STOMP mechanisms for users management)
When I subscribe to destination, in my Client, I add to destionation url the name of the user:
"/queue/messages-" + user.getName().
Using convertAndSend on that destination (Server side that send messages) I can send private messages to user.
I have websocket config class with :
#Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topics");
config.setApplicationDestinationPrefixes("/app");
}
#Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/notifications").setAllowedOrigins("*").withSockJS();
}
As well as ClientInboundChannel:
#Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.setInterceptors(new ChannelInterceptorAdapter() {
#Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
final String xAuthToken = accessor.getFirstNativeHeader(ManipulatesHeaders.X_AUTH_TOKEN_HEADER);
if (xAuthToken == null) {
return message;
}
final UserDetails userDetails = authService.getUserDetails(xAuthToken);
if (StompCommand.CONNECT == accessor.getCommand()) {
final WebSocketPrincipal principal = (...)
userRegistry.registerUser(principal, message);
accessor.setUser(principal);
}
return message;
}
}
}
Now, I would like to send a welcome messsage to every user that subscribe a particular channel. It's obvious that It might be achieved by creating a class that implements ApplicationListener<SessionSubscribeEvent> and provides
#Override
public void onApplicationEvent(final SessionSubscribeEventevent) {
StompHeaderAccessor sha = StompHeaderAccessor.wrap(event.getMessage());
}
Second approach is to perform similar thing in aforementioned ClientInboundChannel (it's the same).
The problem is, that there is no simpUser header in a message when a user send a stomp SUBSCRIBE.
Process is as follows: User sends stomp CONNECT, this line is executed: accessor.setUser(principal) and at this point message has properly setsimpUser header. But when stomp SUBSCRIBE message is received in preSend, the StompHeaderAccessor does not have simpUser header. Thus I am not able to asses whether user is already authenticated or not.
So how could I check if a user that sent a SUBSCRIBE message is already authenticated or not ? (and why authenticated user does not send simpUser header)
In your websocket configuration class change your extend to AbstractSecurityWebSocketMessageBrokerConfigurer
then you can set your security as follows:
#Override
protected void configureInbound(MessageSecurityMetadataSourceRegistry message) {
message
.nullDestMatcher().permitAll()
.simpDestMatchers("/app/**").authenticated()
.simpSubscribeDestMatchers("/topics/**").authenticated()
.anyMessage().denyAll();
}
if you get the error Could not verify the provided CSRF token because your session was not found you will have to override `sameOriginDisabled in the websocket configuration class
#Override
protected boolean sameOriginDisabled() {
return true;
}
for more information on websocket security:
https://docs.spring.io/spring-security/site/docs/current/reference/html/websocket.html
I have a spring application.
I need to put a value to the initial handshake.
The url looks like: ws://localhost:8080/chat?key=value
I need this key=value in my Websocket Handler.
How can I access it?
Websocket Configuration:
#Configuration
#EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
#Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// alle origins erlauben
registry.addHandler(chatWebSocketController(), "/chat").addInterceptors(new HttpSessionHandshakeInterceptor())
.setAllowedOrigins("*");
}
#Bean
public ChatWebSocketController chatWebSocketController() {
return new ChatWebSocketController();
}
}
Websocket Handler method:
#Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
if (session.getAttributes().containsKey("key")) {
List<String> userMap = session.getHandshakeHeaders().get("key");
JwtTokenUtil jwtTokenUtil = new JwtTokenUtil();
String token = userMap.get(0);
if (jwtTokenUtil.validateToken(token)) {
User userToStore = new User(jwtTokenUtil.getUsernameFromToken(token));
userUsernameMap.put(session, userToStore);
LOGGER.info("User with name " + jwtTokenUtil.getUsernameFromToken(token) + "and IP "
+ session.getRemoteAddress() + " successfully connected");
sendConnectMessage(session, userToStore);
}
} else {
session.close(CloseStatus.POLICY_VIOLATION);
}
}
Found the solution by myself. You have to write your own HandshakeInterceptor, there you have access to the http parameter. so you can put this to your attribbutes map.
public class HttpHandshakeInterceptor implements HandshakeInterceptor {
#Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
Map<String, Object> attributes) throws Exception {
if (request instanceof ServletServerHttpRequest) {
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
HttpSession session = servletRequest.getServletRequest().getSession();
attributes.put("sessionId", session.getId());
attributes.put("key", servletRequest.getServletRequest().getParameterMap().get("key"));
}
return true;
}
#Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler,
Exception exception) {
//nothing to do
}
}