Add chat websocket flow
This commit is contained in:
@@ -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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<ConversationResponse> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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<com.petshop.backend.entity.Message> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user