Skip to content

Commit 4992302

Browse files
authored
feat(be): WebSocket 인증 작업 (#68)
* feat(be): Add JWT authentication to WebSocket * refactor(be): Rename method * fix(be): Fix JWT import after pull
1 parent ec25aa3 commit 4992302

File tree

9 files changed

+133
-28
lines changed

9 files changed

+133
-28
lines changed

src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageController.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import com.back.koreaTravelGuide.domain.userChat.chatmessage.dto.ChatMessageSend
66
import com.back.koreaTravelGuide.domain.userChat.chatmessage.service.ChatMessageService
77
import org.springframework.http.ResponseEntity
88
import org.springframework.messaging.simp.SimpMessagingTemplate
9+
import org.springframework.security.access.AccessDeniedException
10+
import org.springframework.security.core.annotation.AuthenticationPrincipal
911
import org.springframework.web.bind.annotation.GetMapping
1012
import org.springframework.web.bind.annotation.PathVariable
1113
import org.springframework.web.bind.annotation.PostMapping
@@ -23,14 +25,16 @@ class ChatMessageController(
2325
@GetMapping("/{roomId}/messages")
2426
fun listMessages(
2527
@PathVariable roomId: Long,
28+
@AuthenticationPrincipal requesterId: Long?,
2629
@RequestParam(required = false) after: Long?,
2730
@RequestParam(defaultValue = "50") limit: Int,
2831
): ResponseEntity<ApiResponse<List<ChatMessageResponse>>> {
32+
val memberId = requesterId ?: throw AccessDeniedException("인증이 필요합니다.")
2933
val messages =
3034
if (after == null) {
31-
messageService.getlistbefore(roomId, limit)
35+
messageService.getlistbefore(roomId, limit, memberId)
3236
} else {
33-
messageService.getlistafter(roomId, after)
37+
messageService.getlistafter(roomId, after, memberId)
3438
}
3539
val responseMessages = messages.map(ChatMessageResponse::from)
3640
return ResponseEntity.ok(ApiResponse(msg = "메시지 조회", data = responseMessages))
@@ -39,9 +43,11 @@ class ChatMessageController(
3943
@PostMapping("/{roomId}/messages")
4044
fun sendMessage(
4145
@PathVariable roomId: Long,
46+
@AuthenticationPrincipal senderId: Long?,
4247
@RequestBody req: ChatMessageSendRequest,
4348
): ResponseEntity<ApiResponse<ChatMessageResponse>> {
44-
val saved = messageService.send(roomId, req.senderId, req.content)
49+
val memberId = senderId ?: throw AccessDeniedException("인증이 필요합니다.")
50+
val saved = messageService.send(roomId, memberId, req.content)
4551
val response = ChatMessageResponse.from(saved)
4652
messagingTemplate.convertAndSend(
4753
"/topic/userchat/$roomId",

src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/controller/ChatMessageSocketController.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import org.springframework.messaging.handler.annotation.DestinationVariable
88
import org.springframework.messaging.handler.annotation.MessageMapping
99
import org.springframework.messaging.handler.annotation.Payload
1010
import org.springframework.messaging.simp.SimpMessagingTemplate
11+
import org.springframework.security.access.AccessDeniedException
1112
import org.springframework.stereotype.Controller
13+
import java.security.Principal
1214

1315
@Controller
1416
class ChatMessageSocketController(
@@ -18,9 +20,11 @@ class ChatMessageSocketController(
1820
@MessageMapping("/userchat/{roomId}/messages")
1921
fun handleMessage(
2022
@DestinationVariable roomId: Long,
23+
principal: Principal,
2124
@Payload req: ChatMessageSendRequest,
2225
) {
23-
val saved = chatMessageService.send(roomId, req.senderId, req.content)
26+
val senderId = principal.name.toLongOrNull() ?: throw AccessDeniedException("인증이 필요합니다.")
27+
val saved = chatMessageService.send(roomId, senderId, req.content)
2428
val response = ChatMessageResponse.from(saved)
2529
messagingTemplate.convertAndSend(
2630
"/topic/userchat/$roomId",
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package com.back.koreaTravelGuide.domain.userChat.chatmessage.dto
22

33
data class ChatMessageSendRequest(
4-
val senderId: Long,
54
val content: String,
65
)

src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatmessage/service/ChatMessageService.kt

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package com.back.koreaTravelGuide.domain.userChat.chatmessage.service
22

33
import com.back.koreaTravelGuide.domain.userChat.chatmessage.entity.ChatMessage
44
import com.back.koreaTravelGuide.domain.userChat.chatmessage.repository.ChatMessageRepository
5+
import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom
56
import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository
7+
import org.springframework.security.access.AccessDeniedException
68
import org.springframework.stereotype.Service
79
import org.springframework.transaction.annotation.Transactional
10+
import java.util.NoSuchElementException
811

912
@Service
1013
class ChatMessageService(
@@ -15,24 +18,42 @@ class ChatMessageService(
1518
fun getlistbefore(
1619
roomId: Long,
1720
limit: Int,
18-
): List<ChatMessage> = messageRepository.findTop50ByRoomIdOrderByIdDesc(roomId).asReversed()
21+
requesterId: Long,
22+
): List<ChatMessage> {
23+
loadAuthorizedRoom(roomId, requesterId)
24+
return messageRepository.findTop50ByRoomIdOrderByIdDesc(roomId).asReversed()
25+
}
1926

2027
@Transactional(readOnly = true)
2128
fun getlistafter(
2229
roomId: Long,
2330
afterId: Long,
24-
): List<ChatMessage> = messageRepository.findByRoomIdAndIdGreaterThanOrderByIdAsc(roomId, afterId)
31+
requesterId: Long,
32+
): List<ChatMessage> {
33+
loadAuthorizedRoom(roomId, requesterId)
34+
return messageRepository.findByRoomIdAndIdGreaterThanOrderByIdAsc(roomId, afterId)
35+
}
2536

2637
@Transactional
2738
fun send(
2839
roomId: Long,
2940
senderId: Long,
3041
content: String,
3142
): ChatMessage {
43+
val room = loadAuthorizedRoom(roomId, senderId)
3244
val saved = messageRepository.save(ChatMessage(roomId = roomId, senderId = senderId, content = content))
33-
roomRepository.findById(roomId).ifPresent {
34-
roomRepository.save(it.copy(updatedAt = saved.createdAt, lastMessageId = saved.id))
35-
}
45+
roomRepository.save(room.copy(updatedAt = saved.createdAt, lastMessageId = saved.id))
3646
return saved
3747
}
48+
49+
private fun loadAuthorizedRoom(
50+
roomId: Long,
51+
memberId: Long,
52+
): ChatRoom {
53+
val room = roomRepository.findById(roomId).orElseThrow { NoSuchElementException("room not found: $roomId") }
54+
if (room.guideId != memberId && room.userId != memberId) {
55+
throw AccessDeniedException("user $memberId cannot access room $roomId")
56+
}
57+
return room
58+
}
3859
}

src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/controller/ChatRoomController.kt

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
package com.back.koreaTravelGuide.domain.userChat.chatroom.controller
22

33
import com.back.koreaTravelGuide.common.ApiResponse
4-
import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomDeleteRequest
54
import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomResponse
65
import com.back.koreaTravelGuide.domain.userChat.chatroom.dto.ChatRoomStartRequest
76
import com.back.koreaTravelGuide.domain.userChat.chatroom.service.ChatRoomService
87
import org.springframework.http.ResponseEntity
8+
import org.springframework.security.access.AccessDeniedException
9+
import org.springframework.security.core.annotation.AuthenticationPrincipal
910
import org.springframework.web.bind.annotation.DeleteMapping
1011
import org.springframework.web.bind.annotation.GetMapping
1112
import org.springframework.web.bind.annotation.PathVariable
@@ -22,26 +23,31 @@ class ChatRoomController(
2223
// 같은 페어는 방 재사용
2324
@PostMapping("/start")
2425
fun startChat(
26+
@AuthenticationPrincipal requesterId: Long?,
2527
@RequestBody req: ChatRoomStartRequest,
2628
): ResponseEntity<ApiResponse<ChatRoomResponse>> {
27-
val room = roomService.exceptOneToOneRoom(req.guideId, req.userId)
29+
val authenticatedId = requesterId ?: throw AccessDeniedException("인증이 필요합니다.")
30+
val room = roomService.checkOneToOneRoom(req.guideId, req.userId, authenticatedId)
2831
return ResponseEntity.ok(ApiResponse(msg = "채팅방 시작", data = ChatRoomResponse.from(room)))
2932
}
3033

3134
@DeleteMapping("/{roomId}")
3235
fun deleteRoom(
3336
@PathVariable roomId: Long,
34-
@RequestBody req: ChatRoomDeleteRequest,
37+
@AuthenticationPrincipal requesterId: Long?,
3538
): ResponseEntity<ApiResponse<Unit>> {
36-
roomService.deleteByOwner(roomId, req.userId)
39+
val authenticatedId = requesterId ?: throw AccessDeniedException("인증이 필요합니다.")
40+
roomService.deleteByOwner(roomId, authenticatedId)
3741
return ResponseEntity.ok(ApiResponse("채팅방 삭제 완료"))
3842
}
3943

4044
@GetMapping("/{roomId}")
4145
fun get(
4246
@PathVariable roomId: Long,
47+
@AuthenticationPrincipal requesterId: Long?,
4348
): ResponseEntity<ApiResponse<ChatRoomResponse>> {
44-
val room = roomService.get(roomId)
49+
val authenticatedId = requesterId ?: throw AccessDeniedException("인증이 필요합니다.")
50+
val room = roomService.get(roomId, authenticatedId)
4551
return ResponseEntity.ok(ApiResponse(msg = "채팅방 조회", data = ChatRoomResponse.from(room)))
4652
}
4753
}

src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/dto/ChatRoomDeleteRequest.kt

Lines changed: 0 additions & 5 deletions
This file was deleted.

src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/chatroom/service/ChatRoomService.kt

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,24 @@ package com.back.koreaTravelGuide.domain.userChat.chatroom.service
33
import com.back.koreaTravelGuide.domain.userChat.chatmessage.repository.ChatMessageRepository
44
import com.back.koreaTravelGuide.domain.userChat.chatroom.entity.ChatRoom
55
import com.back.koreaTravelGuide.domain.userChat.chatroom.repository.ChatRoomRepository
6+
import org.springframework.security.access.AccessDeniedException
67
import org.springframework.stereotype.Service
78
import org.springframework.transaction.annotation.Transactional
89
import java.time.Instant
10+
import java.util.NoSuchElementException
911

1012
@Service
1113
class ChatRoomService(
1214
private val roomRepository: ChatRoomRepository,
1315
private val messageRepository: ChatMessageRepository,
1416
) {
1517
@Transactional
16-
fun exceptOneToOneRoom(
18+
fun checkOneToOneRoom(
1719
guideId: Long,
1820
userId: Long,
21+
requesterId: Long,
1922
): ChatRoom {
23+
checkParticipant(guideId, userId, requesterId)
2024
// 1) 기존 방 재사용
2125
roomRepository.findOneToOneRoom(guideId, userId)?.let { return it }
2226

@@ -28,21 +32,46 @@ class ChatRoomService(
2832
}
2933

3034
@Transactional(readOnly = true)
31-
fun get(roomId: Long): ChatRoom =
32-
roomRepository.findById(roomId)
33-
.orElseThrow { NoSuchElementException("room not found: $roomId") }
35+
fun get(
36+
roomId: Long,
37+
requesterId: Long,
38+
): ChatRoom {
39+
val room =
40+
roomRepository.findById(roomId)
41+
.orElseThrow { NoSuchElementException("room not found: $roomId") }
42+
checkMember(room, requesterId)
43+
return room
44+
}
3445

3546
@Transactional
3647
fun deleteByOwner(
3748
roomId: Long,
3849
requesterId: Long,
3950
) {
40-
val room = get(roomId)
51+
val room = get(roomId, requesterId)
4152
if (room.userId != requesterId) {
42-
// 예외처리 임시
43-
throw IllegalArgumentException("채팅방 생성자만 삭제할 수 있습니다.")
53+
throw AccessDeniedException("채팅방 생성자만 삭제할 수 있습니다.")
4454
}
4555
messageRepository.deleteByRoomId(roomId)
4656
roomRepository.deleteById(roomId)
4757
}
58+
59+
private fun checkParticipant(
60+
guideId: Long,
61+
userId: Long,
62+
requesterId: Long,
63+
) {
64+
if (guideId != requesterId && userId != requesterId) {
65+
throw AccessDeniedException("채팅방은 참여자만 생성할 수 있습니다.")
66+
}
67+
}
68+
69+
private fun checkMember(
70+
room: ChatRoom,
71+
requesterId: Long,
72+
) {
73+
if (room.guideId != requesterId && room.userId != requesterId) {
74+
throw AccessDeniedException("채팅방에 접근할 수 없습니다.")
75+
}
76+
}
4877
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.back.koreaTravelGuide.domain.userChat.config
2+
3+
import com.back.koreaTravelGuide.common.security.JwtTokenProvider
4+
import org.springframework.messaging.Message
5+
import org.springframework.messaging.MessageChannel
6+
import org.springframework.messaging.simp.stomp.StompCommand
7+
import org.springframework.messaging.simp.stomp.StompHeaderAccessor
8+
import org.springframework.messaging.support.ChannelInterceptor
9+
import org.springframework.messaging.support.MessageHeaderAccessor
10+
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException
11+
import org.springframework.stereotype.Component
12+
13+
@Component
14+
class UserChatStompAuthChannelInterceptor(
15+
private val jwtTokenProvider: JwtTokenProvider,
16+
) : ChannelInterceptor {
17+
override fun preSend(
18+
message: Message<*>,
19+
channel: MessageChannel,
20+
): Message<*>? {
21+
val accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor::class.java) ?: return message
22+
23+
if (accessor.command == StompCommand.CONNECT) {
24+
val rawHeader =
25+
accessor.getFirstNativeHeader("Authorization")
26+
?: throw AuthenticationCredentialsNotFoundException("Authorization header is missing")
27+
val token = rawHeader.removePrefix("Bearer ").trim()
28+
if (!jwtTokenProvider.validateToken(token)) {
29+
throw AuthenticationCredentialsNotFoundException("Invalid JWT token")
30+
}
31+
accessor.user = jwtTokenProvider.getAuthentication(token)
32+
} else if (accessor.user == null) {
33+
throw AuthenticationCredentialsNotFoundException("Unauthenticated STOMP request")
34+
}
35+
36+
return message
37+
}
38+
}

src/main/kotlin/com/back/koreaTravelGuide/domain/userChat/config/UserChatWebSocketConfig.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.back.koreaTravelGuide.domain.userChat.config
22

33
import org.springframework.context.annotation.Configuration
4+
import org.springframework.messaging.simp.config.ChannelRegistration
45
import org.springframework.messaging.simp.config.MessageBrokerRegistry
56
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker
67
import org.springframework.web.socket.config.annotation.StompEndpointRegistry
@@ -10,7 +11,9 @@ import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerCo
1011

1112
@Configuration
1213
@EnableWebSocketMessageBroker
13-
class UserChatWebSocketConfig : WebSocketMessageBrokerConfigurer {
14+
class UserChatWebSocketConfig(
15+
private val userChatStompAuthChannelInterceptor: UserChatStompAuthChannelInterceptor,
16+
) : WebSocketMessageBrokerConfigurer {
1417
override fun registerStompEndpoints(registry: StompEndpointRegistry) {
1518
registry.addEndpoint("/ws/userchat")
1619
.setAllowedOriginPatterns("*")
@@ -21,4 +24,8 @@ class UserChatWebSocketConfig : WebSocketMessageBrokerConfigurer {
2124
registry.enableSimpleBroker("/topic")
2225
registry.setApplicationDestinationPrefixes("/pub")
2326
}
27+
28+
override fun configureClientInboundChannel(registration: ChannelRegistration) {
29+
registration.interceptors(userChatStompAuthChannelInterceptor)
30+
}
2431
}

0 commit comments

Comments
 (0)