diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageController.kt index 683b6c9..500ddef 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageController.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageController.kt @@ -6,6 +6,8 @@ import com.back.koreaTravelGuide.domain.userChat.chatmessage.dto.ChatMessageSend import com.back.koreaTravelGuide.domain.userChat.chatmessage.service.ChatMessageService import org.springframework.http.ResponseEntity import org.springframework.messaging.simp.SimpMessagingTemplate +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -23,14 +25,16 @@ class ChatMessageController( @GetMapping("/{roomId}/messages") fun listMessages( @PathVariable roomId: Long, + @AuthenticationPrincipal requesterId: Long?, @RequestParam(required = false) after: Long?, @RequestParam(defaultValue = "50") limit: Int, ): ResponseEntity>> { + val memberId = requesterId ?: throw AccessDeniedException("인증이 필요합니다.") val messages = if (after == null) { - messageService.getlistbefore(roomId, limit) + messageService.getlistbefore(roomId, limit, memberId) } else { - messageService.getlistafter(roomId, after) + messageService.getlistafter(roomId, after, memberId) } val responseMessages = messages.map(ChatMessageResponse::from) return ResponseEntity.ok(ApiResponse(msg = "메시지 조회", data = responseMessages)) @@ -39,9 +43,11 @@ class ChatMessageController( @PostMapping("/{roomId}/messages") fun sendMessage( @PathVariable roomId: Long, + @AuthenticationPrincipal senderId: Long?, @RequestBody req: ChatMessageSendRequest, ): ResponseEntity> { - val saved = messageService.send(roomId, req.senderId, req.content) + val memberId = senderId ?: throw AccessDeniedException("인증이 필요합니다.") + val saved = messageService.send(roomId, memberId, req.content) val response = ChatMessageResponse.from(saved) messagingTemplate.convertAndSend( "/topic/userchat/$roomId", diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageSocketController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageSocketController.kt index 1f5eb08..c2f9203 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageSocketController.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageSocketController.kt @@ -8,7 +8,9 @@ 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.SimpMessagingTemplate +import org.springframework.security.access.AccessDeniedException import org.springframework.stereotype.Controller +import java.security.Principal @Controller class ChatMessageSocketController( @@ -18,9 +20,11 @@ class ChatMessageSocketController( @MessageMapping("/userchat/{roomId}/messages") fun handleMessage( @DestinationVariable roomId: Long, + principal: Principal, @Payload req: ChatMessageSendRequest, ) { - val saved = chatMessageService.send(roomId, req.senderId, req.content) + val senderId = principal.name.toLongOrNull() ?: throw AccessDeniedException("인증이 필요합니다.") + val saved = chatMessageService.send(roomId, senderId, req.content) val response = ChatMessageResponse.from(saved) messagingTemplate.convertAndSend( "/topic/userchat/$roomId", diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/dto/ChatMessageSendRequest.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/dto/ChatMessageSendRequest.kt index 370535c..9284f8d 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/dto/ChatMessageSendRequest.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/dto/ChatMessageSendRequest.kt @@ -1,6 +1,5 @@ package com.back.koreaTravelGuide.domain.userChat.chatmessage.dto data class ChatMessageSendRequest( - val senderId: Long, val content: String, ) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/service/ChatMessageService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/service/ChatMessageService.kt index f768dc3..b8f7f9c 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/service/ChatMessageService.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/service/ChatMessageService.kt @@ -2,9 +2,12 @@ package com.back.koreaTravelGuide.domain.userChat.chatmessage.service import com.back.koreaTravelGuide.domain.userChat.chatmessage.entity.ChatMessage import com.back.koreaTravelGuide.domain.userChat.chatmessage.repository.ChatMessageRepository +import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository +import org.springframework.security.access.AccessDeniedException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.util.NoSuchElementException @Service class ChatMessageService( @@ -15,13 +18,21 @@ class ChatMessageService( fun getlistbefore( roomId: Long, limit: Int, - ): List = messageRepository.findTop50ByRoomIdOrderByIdDesc(roomId).asReversed() + requesterId: Long, + ): List { + loadAuthorizedRoom(roomId, requesterId) + return messageRepository.findTop50ByRoomIdOrderByIdDesc(roomId).asReversed() + } @Transactional(readOnly = true) fun getlistafter( roomId: Long, afterId: Long, - ): List = messageRepository.findByRoomIdAndIdGreaterThanOrderByIdAsc(roomId, afterId) + requesterId: Long, + ): List { + loadAuthorizedRoom(roomId, requesterId) + return messageRepository.findByRoomIdAndIdGreaterThanOrderByIdAsc(roomId, afterId) + } @Transactional fun send( @@ -29,10 +40,20 @@ class ChatMessageService( senderId: Long, content: String, ): ChatMessage { + val room = loadAuthorizedRoom(roomId, senderId) val saved = messageRepository.save(ChatMessage(roomId = roomId, senderId = senderId, content = content)) - roomRepository.findById(roomId).ifPresent { - roomRepository.save(it.copy(updatedAt = saved.createdAt, lastMessageId = saved.id)) - } + roomRepository.save(room.copy(updatedAt = saved.createdAt, lastMessageId = saved.id)) return saved } + + private fun loadAuthorizedRoom( + roomId: Long, + memberId: Long, + ): ChatRoom { + val room = roomRepository.findById(roomId).orElseThrow { NoSuchElementException("room not found: $roomId") } + if (room.guideId != memberId && room.userId != memberId) { + throw AccessDeniedException("user $memberId cannot access room $roomId") + } + return room + } } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt index dbe9647..6ff7328 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt @@ -1,11 +1,12 @@ package com.back.koreaTravelGuide.domain.userChat.chatroom.controller import com.back.koreaTravelGuide.common.ApiResponse -import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomDeleteRequest import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomResponse import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomStartRequest import com.back.koreaTravelGuide.domain.userChat.chatroom.service.ChatRoomService import org.springframework.http.ResponseEntity +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -22,26 +23,31 @@ class ChatRoomController( // 같은 페어는 방 재사용 @PostMapping("/start") fun startChat( + @AuthenticationPrincipal requesterId: Long?, @RequestBody req: ChatRoomStartRequest, ): ResponseEntity> { - val room = roomService.exceptOneToOneRoom(req.guideId, req.userId) + val authenticatedId = requesterId ?: throw AccessDeniedException("인증이 필요합니다.") + val room = roomService.checkOneToOneRoom(req.guideId, req.userId, authenticatedId) return ResponseEntity.ok(ApiResponse(msg = "채팅방 시작", data = ChatRoomResponse.from(room))) } @DeleteMapping("/{roomId}") fun deleteRoom( @PathVariable roomId: Long, - @RequestBody req: ChatRoomDeleteRequest, + @AuthenticationPrincipal requesterId: Long?, ): ResponseEntity> { - roomService.deleteByOwner(roomId, req.userId) + val authenticatedId = requesterId ?: throw AccessDeniedException("인증이 필요합니다.") + roomService.deleteByOwner(roomId, authenticatedId) return ResponseEntity.ok(ApiResponse("채팅방 삭제 완료")) } @GetMapping("/{roomId}") fun get( @PathVariable roomId: Long, + @AuthenticationPrincipal requesterId: Long?, ): ResponseEntity> { - val room = roomService.get(roomId) + val authenticatedId = requesterId ?: throw AccessDeniedException("인증이 필요합니다.") + val room = roomService.get(roomId, authenticatedId) return ResponseEntity.ok(ApiResponse(msg = "채팅방 조회", data = ChatRoomResponse.from(room))) } } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/dto/ChatRoomDeleteRequest.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/dto/ChatRoomDeleteRequest.kt deleted file mode 100644 index b4925c7..0000000 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/dto/ChatRoomDeleteRequest.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.back.koreaTravelGuide.domain.userChat.chatroom.dto - -data class ChatRoomDeleteRequest( - val userId: Long, -) diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt index 553e927..e9dcf1d 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt @@ -3,9 +3,11 @@ package com.back.koreaTravelGuide.domain.userChat.chatroom.service import com.back.koreaTravelGuide.domain.userChat.chatmessage.repository.ChatMessageRepository import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository +import org.springframework.security.access.AccessDeniedException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.Instant +import java.util.NoSuchElementException @Service class ChatRoomService( @@ -13,10 +15,12 @@ class ChatRoomService( private val messageRepository: ChatMessageRepository, ) { @Transactional - fun exceptOneToOneRoom( + fun checkOneToOneRoom( guideId: Long, userId: Long, + requesterId: Long, ): ChatRoom { + checkParticipant(guideId, userId, requesterId) // 1) 기존 방 재사용 roomRepository.findOneToOneRoom(guideId, userId)?.let { return it } @@ -28,21 +32,46 @@ class ChatRoomService( } @Transactional(readOnly = true) - fun get(roomId: Long): ChatRoom = - roomRepository.findById(roomId) - .orElseThrow { NoSuchElementException("room not found: $roomId") } + fun get( + roomId: Long, + requesterId: Long, + ): ChatRoom { + val room = + roomRepository.findById(roomId) + .orElseThrow { NoSuchElementException("room not found: $roomId") } + checkMember(room, requesterId) + return room + } @Transactional fun deleteByOwner( roomId: Long, requesterId: Long, ) { - val room = get(roomId) + val room = get(roomId, requesterId) if (room.userId != requesterId) { - // 예외처리 임시 - throw IllegalArgumentException("채팅방 생성자만 삭제할 수 있습니다.") + throw AccessDeniedException("채팅방 생성자만 삭제할 수 있습니다.") } messageRepository.deleteByRoomId(roomId) roomRepository.deleteById(roomId) } + + private fun checkParticipant( + guideId: Long, + userId: Long, + requesterId: Long, + ) { + if (guideId != requesterId && userId != requesterId) { + throw AccessDeniedException("채팅방은 참여자만 생성할 수 있습니다.") + } + } + + private fun checkMember( + room: ChatRoom, + requesterId: Long, + ) { + if (room.guideId != requesterId && room.userId != requesterId) { + throw AccessDeniedException("채팅방에 접근할 수 없습니다.") + } + } } diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatStompAuthChannelInterceptor.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatStompAuthChannelInterceptor.kt new file mode 100644 index 0000000..9553bce --- /dev/null +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatStompAuthChannelInterceptor.kt @@ -0,0 +1,38 @@ +package com.back.koreaTravelGuide.domain.userChat.config + +import com.back.koreaTravelGuide.common.security.JwtTokenProvider +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.messaging.support.MessageHeaderAccessor +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException +import org.springframework.stereotype.Component + +@Component +class UserChatStompAuthChannelInterceptor( + private val jwtTokenProvider: JwtTokenProvider, +) : ChannelInterceptor { + override fun preSend( + message: Message<*>, + channel: MessageChannel, + ): Message<*>? { + val accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor::class.java) ?: return message + + if (accessor.command == StompCommand.CONNECT) { + val rawHeader = + accessor.getFirstNativeHeader("Authorization") + ?: throw AuthenticationCredentialsNotFoundException("Authorization header is missing") + val token = rawHeader.removePrefix("Bearer ").trim() + if (!jwtTokenProvider.validateToken(token)) { + throw AuthenticationCredentialsNotFoundException("Invalid JWT token") + } + accessor.user = jwtTokenProvider.getAuthentication(token) + } else if (accessor.user == null) { + throw AuthenticationCredentialsNotFoundException("Unauthenticated STOMP request") + } + + return message + } +} diff --git a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatWebSocketConfig.kt b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatWebSocketConfig.kt index 41e7e71..5a43aaa 100644 --- a/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatWebSocketConfig.kt +++ b/src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatWebSocketConfig.kt @@ -1,6 +1,7 @@ package com.back.koreaTravelGuide.domain.userChat.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,7 +11,9 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo @Configuration @EnableWebSocketMessageBroker -class UserChatWebSocketConfig : WebSocketMessageBrokerConfigurer { +class UserChatWebSocketConfig( + private val userChatStompAuthChannelInterceptor: UserChatStompAuthChannelInterceptor, +) : WebSocketMessageBrokerConfigurer { override fun registerStompEndpoints(registry: StompEndpointRegistry) { registry.addEndpoint("/ws/userchat") .setAllowedOriginPatterns("*") @@ -21,4 +24,8 @@ class UserChatWebSocketConfig : WebSocketMessageBrokerConfigurer { registry.enableSimpleBroker("/topic") registry.setApplicationDestinationPrefixes("/pub") } + + override fun configureClientInboundChannel(registration: ChannelRegistration) { + registration.interceptors(userChatStompAuthChannelInterceptor) + } }