From 84c5f3c7b1976835a85c9c50b9dacadbbceb3cc1 Mon Sep 17 00:00:00 2001 From: Harkamal Randhawa Date: Tue, 10 Mar 2026 18:56:18 -0600 Subject: [PATCH] Add chat websocket flow --- .../WebSocketAuthChannelInterceptor.java | 140 ++++++++++++++++++ .../backend/config/WebSocketConfig.java | 15 ++ .../backend/controller/ChatController.java | 8 +- .../controller/ChatWebSocketController.java | 39 +++++ .../backend/security/SecurityConfig.java | 1 + .../backend/service/ChatRealtimeService.java | 75 ++++++++++ .../petshop/backend/service/ChatService.java | 51 ++++--- 7 files changed, 310 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/petshop/backend/config/WebSocketAuthChannelInterceptor.java create mode 100644 src/main/java/com/petshop/backend/controller/ChatWebSocketController.java create mode 100644 src/main/java/com/petshop/backend/service/ChatRealtimeService.java diff --git a/src/main/java/com/petshop/backend/config/WebSocketAuthChannelInterceptor.java b/src/main/java/com/petshop/backend/config/WebSocketAuthChannelInterceptor.java new file mode 100644 index 00000000..2b467025 --- /dev/null +++ b/src/main/java/com/petshop/backend/config/WebSocketAuthChannelInterceptor.java @@ -0,0 +1,140 @@ +package com.petshop.backend.config; + +import com.petshop.backend.entity.User; +import com.petshop.backend.repository.UserRepository; +import com.petshop.backend.security.JwtUtil; +import com.petshop.backend.service.ChatService; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import java.security.Principal; +import java.util.Collections; +import java.util.List; + +@Component +public class WebSocketAuthChannelInterceptor implements ChannelInterceptor { + + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + private final ChatService chatService; + + public WebSocketAuthChannelInterceptor(JwtUtil jwtUtil, UserRepository userRepository, ChatService chatService) { + this.jwtUtil = jwtUtil; + this.userRepository = userRepository; + this.chatService = chatService; + } + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + StompCommand command = accessor.getCommand(); + + if (command == null) { + return message; + } + + if (StompCommand.CONNECT.equals(command)) { + String tokenHeader = firstHeader(accessor, "Authorization"); + String token = extractToken(tokenHeader != null ? tokenHeader : firstHeader(accessor, "token")); + if (token == null || token.isBlank()) { + throw new IllegalArgumentException("Missing websocket token"); + } + + String username = jwtUtil.extractUsername(token); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + if (user.getActive() == null || !user.getActive()) { + throw new IllegalArgumentException("User account is inactive"); + } + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + user.getUsername(), + null, + Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())) + ); + accessor.setUser(authentication); + return message; + } + + Principal principal = accessor.getUser(); + if (principal == null) { + throw new IllegalArgumentException("Unauthenticated websocket session"); + } + + User user = userRepository.findByUsername(principal.getName()) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + if (StompCommand.SUBSCRIBE.equals(command)) { + authorizeSubscription(accessor.getDestination(), user); + } else if (StompCommand.SEND.equals(command)) { + authorizeSend(accessor.getDestination(), user); + } + + return message; + } + + private void authorizeSubscription(String destination, User user) { + if (destination == null || destination.startsWith("/user/queue/")) { + return; + } + + if ("/topic/chat/conversations".equals(destination)) { + if (user.getRole() == User.Role.CUSTOMER) { + throw new IllegalArgumentException("Customers cannot subscribe to staff conversation feed"); + } + return; + } + + Long conversationId = extractConversationId(destination, "/topic/chat/conversations/"); + if (conversationId != null && chatService.hasConversationAccess(conversationId, user.getId(), user.getRole())) { + return; + } + + throw new IllegalArgumentException("Not authorized to subscribe to destination"); + } + + private void authorizeSend(String destination, User user) { + Long conversationId = extractConversationId(destination, "/app/chat/conversations/"); + if (conversationId != null && destination.endsWith("/messages") && chatService.hasConversationAccess(conversationId, user.getId(), user.getRole())) { + return; + } + + throw new IllegalArgumentException("Not authorized to send to destination"); + } + + private Long extractConversationId(String destination, String prefix) { + if (destination == null || !destination.startsWith(prefix)) { + return null; + } + + String suffix = destination.substring(prefix.length()); + String[] parts = suffix.split("/"); + if (parts.length == 0 || parts[0].isBlank()) { + return null; + } + + try { + return Long.parseLong(parts[0]); + } catch (NumberFormatException ex) { + return null; + } + } + + private String firstHeader(StompHeaderAccessor accessor, String name) { + List values = accessor.getNativeHeader(name); + return values == null || values.isEmpty() ? null : values.get(0); + } + + private String extractToken(String rawValue) { + if (rawValue == null || rawValue.isBlank()) { + return null; + } + return rawValue.startsWith("Bearer ") ? rawValue.substring(7) : rawValue; + } +} diff --git a/src/main/java/com/petshop/backend/config/WebSocketConfig.java b/src/main/java/com/petshop/backend/config/WebSocketConfig.java index 84944c25..27526bec 100644 --- a/src/main/java/com/petshop/backend/config/WebSocketConfig.java +++ b/src/main/java/com/petshop/backend/config/WebSocketConfig.java @@ -1,6 +1,7 @@ package com.petshop.backend.config; import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; @@ -10,15 +11,29 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + private final WebSocketAuthChannelInterceptor webSocketAuthChannelInterceptor; + + public WebSocketConfig(WebSocketAuthChannelInterceptor webSocketAuthChannelInterceptor) { + this.webSocketAuthChannelInterceptor = webSocketAuthChannelInterceptor; + } + @Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker("/topic", "/queue"); config.setApplicationDestinationPrefixes("/app"); + config.setUserDestinationPrefix("/user"); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(webSocketAuthChannelInterceptor); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws/chat") + .setAllowedOriginPatterns("*"); + registry.addEndpoint("/ws/chat-sockjs") .setAllowedOriginPatterns("*") .withSockJS(); } diff --git a/src/main/java/com/petshop/backend/controller/ChatController.java b/src/main/java/com/petshop/backend/controller/ChatController.java index 5ffb1444..35541f65 100644 --- a/src/main/java/com/petshop/backend/controller/ChatController.java +++ b/src/main/java/com/petshop/backend/controller/ChatController.java @@ -7,6 +7,7 @@ import com.petshop.backend.dto.chat.MessageResponse; import com.petshop.backend.entity.User; import com.petshop.backend.repository.CustomerRepository; import com.petshop.backend.repository.UserRepository; +import com.petshop.backend.service.ChatRealtimeService; import com.petshop.backend.service.ChatService; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; @@ -24,11 +25,13 @@ import java.util.List; public class ChatController { private final ChatService chatService; + private final ChatRealtimeService chatRealtimeService; private final UserRepository userRepository; private final CustomerRepository customerRepository; - public ChatController(ChatService chatService, UserRepository userRepository, CustomerRepository customerRepository) { + public ChatController(ChatService chatService, ChatRealtimeService chatRealtimeService, UserRepository userRepository, CustomerRepository customerRepository) { this.chatService = chatService; + this.chatRealtimeService = chatRealtimeService; this.userRepository = userRepository; this.customerRepository = customerRepository; } @@ -44,6 +47,7 @@ public class ChatController { public ResponseEntity createConversation(@Valid @RequestBody ConversationRequest request) { User user = getCurrentUser(); ConversationResponse response = chatService.createConversation(user.getId(), request); + chatRealtimeService.publishNewConversation(response); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -70,6 +74,8 @@ public class ChatController { @Valid @RequestBody MessageRequest request) { User user = getCurrentUser(); MessageResponse message = chatService.sendMessage(id, user.getId(), user.getRole(), request); + chatRealtimeService.publishMessage(id, message); + chatRealtimeService.publishConversationUpdate(id); return ResponseEntity.status(HttpStatus.CREATED).body(message); } diff --git a/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java b/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java new file mode 100644 index 00000000..011268e6 --- /dev/null +++ b/src/main/java/com/petshop/backend/controller/ChatWebSocketController.java @@ -0,0 +1,39 @@ +package com.petshop.backend.controller; + +import com.petshop.backend.dto.chat.MessageRequest; +import com.petshop.backend.dto.chat.MessageResponse; +import com.petshop.backend.entity.User; +import com.petshop.backend.repository.UserRepository; +import com.petshop.backend.service.ChatRealtimeService; +import com.petshop.backend.service.ChatService; +import jakarta.validation.Valid; +import org.springframework.messaging.handler.annotation.DestinationVariable; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.annotation.SendToUser; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; + +@Controller +public class ChatWebSocketController { + + private final ChatService chatService; + private final ChatRealtimeService chatRealtimeService; + private final UserRepository userRepository; + + public ChatWebSocketController(ChatService chatService, ChatRealtimeService chatRealtimeService, UserRepository userRepository) { + this.chatService = chatService; + this.chatRealtimeService = chatRealtimeService; + this.userRepository = userRepository; + } + + @MessageMapping("/chat/conversations/{id}/messages") + @SendToUser("/queue/chat/errors") + public void sendMessage(@DestinationVariable Long id, @Valid @Payload MessageRequest request, Authentication authentication) { + User user = userRepository.findByUsername(authentication.getName()) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + MessageResponse message = chatService.sendMessage(id, user.getId(), user.getRole(), request); + chatRealtimeService.publishMessage(id, message); + chatRealtimeService.publishConversationUpdate(id); + } +} diff --git a/src/main/java/com/petshop/backend/security/SecurityConfig.java b/src/main/java/com/petshop/backend/security/SecurityConfig.java index 9841d9fd..38811256 100644 --- a/src/main/java/com/petshop/backend/security/SecurityConfig.java +++ b/src/main/java/com/petshop/backend/security/SecurityConfig.java @@ -38,6 +38,7 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/auth/login", "/api/v1/auth/register").permitAll() .requestMatchers("/api/v1/health").permitAll() + .requestMatchers("/ws/chat/**", "/ws/chat-sockjs/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/pets/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll() diff --git a/src/main/java/com/petshop/backend/service/ChatRealtimeService.java b/src/main/java/com/petshop/backend/service/ChatRealtimeService.java new file mode 100644 index 00000000..9b835823 --- /dev/null +++ b/src/main/java/com/petshop/backend/service/ChatRealtimeService.java @@ -0,0 +1,75 @@ +package com.petshop.backend.service; + +import com.petshop.backend.dto.chat.ConversationResponse; +import com.petshop.backend.dto.chat.MessageResponse; +import com.petshop.backend.entity.Conversation; +import com.petshop.backend.entity.Customer; +import com.petshop.backend.entity.User; +import com.petshop.backend.exception.ResourceNotFoundException; +import com.petshop.backend.repository.ConversationRepository; +import com.petshop.backend.repository.CustomerRepository; +import com.petshop.backend.repository.MessageRepository; +import com.petshop.backend.repository.UserRepository; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class ChatRealtimeService { + + private final SimpMessagingTemplate messagingTemplate; + private final ConversationRepository conversationRepository; + private final MessageRepository messageRepository; + private final CustomerRepository customerRepository; + private final UserRepository userRepository; + + public ChatRealtimeService(SimpMessagingTemplate messagingTemplate, ConversationRepository conversationRepository, MessageRepository messageRepository, CustomerRepository customerRepository, UserRepository userRepository) { + this.messagingTemplate = messagingTemplate; + this.conversationRepository = conversationRepository; + this.messageRepository = messageRepository; + this.customerRepository = customerRepository; + this.userRepository = userRepository; + } + + public void publishNewConversation(ConversationResponse conversation) { + messagingTemplate.convertAndSend("/topic/chat/conversations", conversation); + sendConversationToCustomerQueue(conversation); + } + + public void publishMessage(Long conversationId, MessageResponse message) { + messagingTemplate.convertAndSend("/topic/chat/conversations/" + conversationId, message); + } + + public void publishConversationUpdate(Long conversationId) { + Conversation conversation = conversationRepository.findById(conversationId) + .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); + List messages = messageRepository.findByConversationIdOrderByTimestampAsc(conversationId); + String lastMessage = messages.isEmpty() ? "" : messages.get(messages.size() - 1).getContent(); + ConversationResponse response = ConversationResponse.fromEntity(conversation, lastMessage); + + messagingTemplate.convertAndSend("/topic/chat/conversations", response); + sendConversationToCustomerQueue(response); + sendConversationToStaffQueue(response); + } + + private void sendConversationToCustomerQueue(ConversationResponse conversation) { + Customer customer = customerRepository.findById(conversation.getCustomerId()) + .orElseThrow(() -> new ResourceNotFoundException("Customer not found")); + if (customer.getUserId() == null) { + return; + } + User customerUser = userRepository.findById(customer.getUserId()) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + messagingTemplate.convertAndSendToUser(customerUser.getUsername(), "/queue/chat/conversations", conversation); + } + + private void sendConversationToStaffQueue(ConversationResponse conversation) { + if (conversation.getStaffId() == null) { + return; + } + User staffUser = userRepository.findById(conversation.getStaffId()) + .orElseThrow(() -> new ResourceNotFoundException("User not found")); + messagingTemplate.convertAndSendToUser(staffUser.getUsername(), "/queue/chat/conversations", conversation); + } +} diff --git a/src/main/java/com/petshop/backend/service/ChatService.java b/src/main/java/com/petshop/backend/service/ChatService.java index af5b6332..ac240eb0 100644 --- a/src/main/java/com/petshop/backend/service/ChatService.java +++ b/src/main/java/com/petshop/backend/service/ChatService.java @@ -94,14 +94,11 @@ public class ChatService { Conversation conversation = conversationRepository.findById(conversationId) .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); - if (role == User.Role.CUSTOMER) { - Customer customer = customerRepository.findByUserId(userId) - .orElseThrow(() -> new ResourceNotFoundException("Customer record not found for user")); - if (!conversation.getCustomerId().equals(customer.getCustomerId())) { + if (!hasConversationAccess(conversation, userId, role)) { + if (role == User.Role.CUSTOMER) { throw new AccessDeniedException("You can only view your own conversations"); } - } else if (role == User.Role.STAFF) { - if (conversation.getStaffId() != null && !conversation.getStaffId().equals(userId)) { + if (role == User.Role.STAFF) { throw new AccessDeniedException("You can only view conversations assigned to you or unassigned conversations"); } } @@ -117,14 +114,11 @@ public class ChatService { Conversation conversation = conversationRepository.findById(conversationId) .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); - if (role == User.Role.CUSTOMER) { - Customer customer = customerRepository.findByUserId(userId) - .orElseThrow(() -> new ResourceNotFoundException("Customer record not found for user")); - if (!conversation.getCustomerId().equals(customer.getCustomerId())) { + if (!hasConversationAccess(conversation, userId, role)) { + if (role == User.Role.CUSTOMER) { throw new AccessDeniedException("You can only send messages to your own conversations"); } - } else if (role == User.Role.STAFF) { - if (conversation.getStaffId() != null && !conversation.getStaffId().equals(userId)) { + if (role == User.Role.STAFF) { throw new AccessDeniedException("You can only reply to conversations assigned to you or unassigned conversations"); } } @@ -148,14 +142,11 @@ public class ChatService { Conversation conversation = conversationRepository.findById(conversationId) .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); - if (role == User.Role.CUSTOMER) { - Customer customer = customerRepository.findByUserId(userId) - .orElseThrow(() -> new ResourceNotFoundException("Customer record not found for user")); - if (!conversation.getCustomerId().equals(customer.getCustomerId())) { + if (!hasConversationAccess(conversation, userId, role)) { + if (role == User.Role.CUSTOMER) { throw new AccessDeniedException("You can only view messages from your own conversations"); } - } else if (role == User.Role.STAFF) { - if (conversation.getStaffId() != null && !conversation.getStaffId().equals(userId)) { + if (role == User.Role.STAFF) { throw new AccessDeniedException("You can only view messages from conversations assigned to you or unassigned conversations"); } } @@ -165,4 +156,28 @@ public class ChatService { .map(MessageResponse::fromEntity) .collect(Collectors.toList()); } + + public boolean hasConversationAccess(Long conversationId, Long userId, User.Role role) { + Conversation conversation = conversationRepository.findById(conversationId) + .orElseThrow(() -> new ResourceNotFoundException("Conversation not found")); + return hasConversationAccess(conversation, userId, role); + } + + private boolean hasConversationAccess(Conversation conversation, Long userId, User.Role role) { + if (role == User.Role.ADMIN) { + return true; + } + + if (role == User.Role.CUSTOMER) { + Customer customer = customerRepository.findByUserId(userId) + .orElseThrow(() -> new ResourceNotFoundException("Customer record not found for user")); + return conversation.getCustomerId().equals(customer.getCustomerId()); + } + + if (role == User.Role.STAFF) { + return conversation.getStaffId() == null || conversation.getStaffId().equals(userId); + } + + return false; + } }