diff --git a/src/main/java/com/back/domain/chat/room/controller/RoomChatWebSocketController.java b/src/main/java/com/back/domain/chat/room/controller/RoomChatWebSocketController.java index 4501e5e3..2c6f830b 100644 --- a/src/main/java/com/back/domain/chat/room/controller/RoomChatWebSocketController.java +++ b/src/main/java/com/back/domain/chat/room/controller/RoomChatWebSocketController.java @@ -7,6 +7,7 @@ import com.back.global.exception.CustomException; import com.back.global.security.user.CustomUserDetails; import com.back.domain.chat.room.service.RoomChatService; +import com.back.global.websocket.util.WebSocketAuthHelper; import com.back.global.websocket.util.WebSocketErrorHelper; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -29,6 +30,7 @@ public class RoomChatWebSocketController { private final RoomChatService roomChatService; private final SimpMessagingTemplate messagingTemplate; + private final WebSocketAuthHelper authHelper; private final WebSocketErrorHelper errorHelper; /** @@ -43,7 +45,8 @@ public void handleRoomChat(@DestinationVariable Long roomId, try { // WebSocket에서 인증된 사용자 정보 추출 - CustomUserDetails userDetails = extractUserDetails(principal); + CustomUserDetails userDetails = authHelper.extractUserDetails(principal); + if (userDetails == null) { errorHelper.sendUnauthorizedError(headerAccessor.getSessionId()); return; @@ -101,7 +104,8 @@ public void clearRoomChat(@DestinationVariable Long roomId, log.info("WebSocket 채팅 일괄 삭제 요청 - roomId: {}", roomId); // 사용자 인증 확인 - CustomUserDetails userDetails = extractUserDetails(principal); + CustomUserDetails userDetails = authHelper.extractUserDetails(principal); + if (userDetails == null) { errorHelper.sendUnauthorizedError(headerAccessor.getSessionId()); return; @@ -148,15 +152,4 @@ public void clearRoomChat(@DestinationVariable Long roomId, } } - // WebSocket Principal에서 CustomUserDetails 추출 - private CustomUserDetails extractUserDetails(Principal principal) { - if (principal instanceof Authentication auth) { - Object principalObj = auth.getPrincipal(); - if (principalObj instanceof CustomUserDetails userDetails) { - return userDetails; - } - } - return null; - } - } \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java b/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java index 27c1d44d..3e770c04 100644 --- a/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java +++ b/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java @@ -28,8 +28,8 @@ public class WebSocketSessionManager { private static final String SESSION_USER_KEY = "ws:session:{}"; private static final String ROOM_USERS_KEY = "ws:room:{}:users"; - // TTL 설정 (10분) - private static final int SESSION_TTL_MINUTES = 10; + // TTL 설정 + private static final int SESSION_TTL_MINUTES = 6; // 사용자 세션 추가 (연결 시 호출) public void addSession(Long userId, String sessionId) { diff --git a/src/main/java/com/back/global/websocket/util/WebSocketAuthHelper.java b/src/main/java/com/back/global/websocket/util/WebSocketAuthHelper.java new file mode 100644 index 00000000..a609dc9d --- /dev/null +++ b/src/main/java/com/back/global/websocket/util/WebSocketAuthHelper.java @@ -0,0 +1,22 @@ +package com.back.global.websocket.util; + +import com.back.global.security.user.CustomUserDetails; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import java.security.Principal; + +@Component +public class WebSocketAuthHelper { + + // WebSocket에서 인증된 사용자 정보 추출 + public static CustomUserDetails extractUserDetails(Principal principal) { + if (principal instanceof Authentication auth) { + Object principalObj = auth.getPrincipal(); + if (principalObj instanceof CustomUserDetails userDetails) { + return userDetails; + } + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCApiController.java b/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCApiController.java new file mode 100644 index 00000000..ed085425 --- /dev/null +++ b/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCApiController.java @@ -0,0 +1,61 @@ +package com.back.global.websocket.webrtc.controller; + +import com.back.global.common.dto.RsData; +import com.back.global.websocket.webrtc.dto.ice.IceServerConfig; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/webrtc") +@RequiredArgsConstructor +@Tag(name = "WebRTC API", description = "WebRTC 시그널링 및 ICE 서버 관련 REST API") +public class WebRTCApiController { + + // ICE 서버 설정 조회 + @GetMapping("/ice-servers") + @Operation( + summary = "ICE 서버 설정 조회", + description = "WebRTC 연결에 필요한 STUN/TURN 서버 정보를 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + public ResponseEntity> getIceServers( + @Parameter(description = "사용자 ID (선택)") @RequestParam(required = false) Long userId, + @Parameter(description = "방 ID (선택)") @RequestParam(required = false) Long roomId) { + + log.info("ICE 서버 설정 요청 - userId: {}, roomId: {}", userId, roomId); + + // 기본 Google STUN 서버 사용 + IceServerConfig config = IceServerConfig.withDefaultStunServers(); + + log.info("ICE 서버 설정 제공 완료 - STUN 서버 {}개", config.iceServers().size()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("ICE 서버 설정 조회 성공", config)); + } + + // WebRTC 서비스 상태 확인 + @GetMapping("/health") + @Operation( + summary = "WebRTC 서비스 상태 확인", + description = "WebRTC 시그널링 서버의 상태를 확인합니다." + ) + @ApiResponse(responseCode = "200", description = "정상 작동 중") + public ResponseEntity> healthCheck() { + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("WebRTC 서비스 정상 작동 중")); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingController.java b/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingController.java new file mode 100644 index 00000000..55f82a22 --- /dev/null +++ b/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCSignalingController.java @@ -0,0 +1,211 @@ +package com.back.global.websocket.webrtc.controller; + +import com.back.global.security.user.CustomUserDetails; +import com.back.global.websocket.webrtc.dto.media.WebRTCMediaToggleRequest; +import com.back.global.websocket.webrtc.dto.media.WebRTCMediaStateResponse; +import com.back.global.websocket.webrtc.dto.signal.*; +import com.back.global.websocket.webrtc.service.WebRTCSignalValidator; +import com.back.global.websocket.util.WebSocketErrorHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.validation.annotation.Validated; + +import java.security.Principal; + +@Controller +@RequiredArgsConstructor +@Slf4j +public class WebRTCSignalingController { + + private final SimpMessagingTemplate messagingTemplate; + private final WebSocketErrorHelper errorHelper; + private final WebRTCSignalValidator validator; + + // WebRTC Offer 메시지 처리 + @MessageMapping("/webrtc/offer") + public void handleOffer(@Validated @Payload WebRTCOfferRequest request, + SimpMessageHeaderAccessor headerAccessor, + Principal principal) { + try { + // WebSocket에서 인증된 사용자 정보 추출 + CustomUserDetails userDetails = extractUserDetails(principal); + if (userDetails == null) { + errorHelper.sendUnauthorizedError(headerAccessor.getSessionId()); + return; + } + + Long fromUserId = userDetails.getUserId(); + + // 시그널 검증 + validator.validateSignal(request.roomId(), fromUserId, request.targetUserId()); + + log.info("WebRTC Offer received - Room: {}, From: {}, To: {}, MediaType: {}", + request.roomId(), fromUserId, request.targetUserId(), request.mediaType()); + + // Offer 메시지 생성 + WebRTCSignalResponse response = WebRTCSignalResponse.offerOrAnswer( + WebRTCSignalType.OFFER, + fromUserId, + request.targetUserId(), + request.roomId(), + request.sdp(), + request.mediaType() + ); + + // 방 전체에 브로드캐스트 (P2P Mesh 연결) + messagingTemplate.convertAndSend( + "/topic/room/" + request.roomId() + "/webrtc", + response + ); + + } catch (Exception e) { + log.error("WebRTC Offer 처리 중 오류 발생 - roomId: {}", request.roomId(), e); + errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "Offer 전송 중 오류가 발생했습니다"); + } + } + + // WebRTC Answer 메시지 처리 + @MessageMapping("/webrtc/answer") + public void handleAnswer(@Validated @Payload WebRTCAnswerRequest request, + SimpMessageHeaderAccessor headerAccessor, + Principal principal) { + try { + // WebSocket에서 인증된 사용자 정보 추출 + CustomUserDetails userDetails = extractUserDetails(principal); + if (userDetails == null) { + errorHelper.sendUnauthorizedError(headerAccessor.getSessionId()); + return; + } + + Long fromUserId = userDetails.getUserId(); + + // 시그널 검증 + validator.validateSignal(request.roomId(), fromUserId, request.targetUserId()); + + log.info("WebRTC Answer received - Room: {}, From: {}, To: {}, MediaType: {}", + request.roomId(), fromUserId, request.targetUserId(), request.mediaType()); + + // Answer 메시지 생성 + WebRTCSignalResponse response = WebRTCSignalResponse.offerOrAnswer( + WebRTCSignalType.ANSWER, + fromUserId, + request.targetUserId(), + request.roomId(), + request.sdp(), + request.mediaType() + ); + + // 방 전체에 브로드캐스트 (P2P Mesh 연결) + messagingTemplate.convertAndSend( + "/topic/room/" + request.roomId() + "/webrtc", + response + ); + + } catch (Exception e) { + log.error("WebRTC Answer 처리 중 오류 발생 - roomId: {}", request.roomId(), e); + errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "Answer 전송 중 오류가 발생했습니다"); + } + } + + // ICE Candidate 메시지 처리 + @MessageMapping("/webrtc/ice-candidate") + public void handleIceCandidate(@Validated @Payload WebRTCIceCandidateRequest request, + SimpMessageHeaderAccessor headerAccessor, + Principal principal) { + try { + // WebSocket에서 인증된 사용자 정보 추출 + CustomUserDetails userDetails = extractUserDetails(principal); + if (userDetails == null) { + errorHelper.sendUnauthorizedError(headerAccessor.getSessionId()); + return; + } + + Long fromUserId = userDetails.getUserId(); + + // 시그널 검증 + validator.validateSignal(request.roomId(), fromUserId, request.targetUserId()); + + log.info("ICE Candidate received - Room: {}, From: {}, To: {}", + request.roomId(), fromUserId, request.targetUserId()); + + // ICE Candidate 메시지 생성 + WebRTCSignalResponse response = WebRTCSignalResponse.iceCandidate( + fromUserId, + request.targetUserId(), + request.roomId(), + request.candidate(), + request.sdpMid(), + request.sdpMLineIndex() + ); + + // 방 전체에 브로드캐스트 (P2P Mesh 연결) + messagingTemplate.convertAndSend( + "/topic/room/" + request.roomId() + "/webrtc", + response + ); + + } catch (Exception e) { + log.error("ICE Candidate 처리 중 오류 발생 - roomId: {}", request.roomId(), e); + errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "ICE Candidate 전송 중 오류가 발생했습니다"); + } + } + + // 미디어 상태 토글 처리 + @MessageMapping("/webrtc/media/toggle") + public void handleMediaToggle(@Validated @Payload WebRTCMediaToggleRequest request, + SimpMessageHeaderAccessor headerAccessor, + Principal principal) { + try { + // 인증된 사용자 정보 추출 + CustomUserDetails userDetails = extractUserDetails(principal); + if (userDetails == null) { + errorHelper.sendUnauthorizedError(headerAccessor.getSessionId()); + return; + } + + Long userId = userDetails.getUserId(); + String nickname = userDetails.getUsername(); + + // 미디어 상태 변경 검증 + validator.validateMediaStateChange(request.roomId(), userId); + + log.info("미디어 상태 변경 - Room: {}, User: {}, MediaType: {}, Enabled: {}", + request.roomId(), userId, request.mediaType(), request.enabled()); + + // 미디어 상태 응답 생성 + WebRTCMediaStateResponse response = WebRTCMediaStateResponse.of( + userId, + nickname, + request.mediaType(), + request.enabled() + ); + + // 방 전체에 브로드캐스트 + messagingTemplate.convertAndSend( + "/topic/room/" + request.roomId() + "/media-status", + response + ); + + } catch (Exception e) { + log.error("미디어 상태 변경 중 오류 발생 - roomId: {}", request.roomId(), e); + errorHelper.sendGenericErrorToUser(headerAccessor.getSessionId(), e, "미디어 상태 변경 중 오류가 발생했습니다"); + } + } + + // Principal에서 CustomUserDetails 추출 헬퍼 메서드 + private CustomUserDetails extractUserDetails(Principal principal) { + if (principal instanceof Authentication auth) { + Object principalObj = auth.getPrincipal(); + if (principalObj instanceof CustomUserDetails userDetails) { + return userDetails; + } + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/webrtc/dto/ice/IceServer.java b/src/main/java/com/back/global/websocket/webrtc/dto/ice/IceServer.java new file mode 100644 index 00000000..633aa927 --- /dev/null +++ b/src/main/java/com/back/global/websocket/webrtc/dto/ice/IceServer.java @@ -0,0 +1,20 @@ +package com.back.global.websocket.webrtc.dto.ice; + +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record IceServer( + String urls, + String username, + String credential +) { + // STUN 서버 (인증 불필요) + public static IceServer stun(String url) { + return new IceServer(url, null, null); + } + + // TURN 서버 (인증 필요) + public static IceServer turn(String url, String username, String credential) { + return new IceServer(url, username, credential); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/webrtc/dto/ice/IceServerConfig.java b/src/main/java/com/back/global/websocket/webrtc/dto/ice/IceServerConfig.java new file mode 100644 index 00000000..460c30be --- /dev/null +++ b/src/main/java/com/back/global/websocket/webrtc/dto/ice/IceServerConfig.java @@ -0,0 +1,17 @@ +package com.back.global.websocket.webrtc.dto.ice; + +import java.util.List; + +public record IceServerConfig( + List iceServers +) { + public static IceServerConfig withDefaultStunServers() { + return new IceServerConfig( + List.of( + IceServer.stun("stun:stun.l.google.com:19302"), + IceServer.stun("stun:stun1.l.google.com:19302"), + IceServer.stun("stun:stun2.l.google.com:19302") + ) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/webrtc/dto/media/WebRTCMediaStateResponse.java b/src/main/java/com/back/global/websocket/webrtc/dto/media/WebRTCMediaStateResponse.java new file mode 100644 index 00000000..b945059c --- /dev/null +++ b/src/main/java/com/back/global/websocket/webrtc/dto/media/WebRTCMediaStateResponse.java @@ -0,0 +1,28 @@ +package com.back.global.websocket.webrtc.dto.media; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.LocalDateTime; + +public record WebRTCMediaStateResponse( + Long userId, + String nickname, + WebRTCMediaType mediaType, + Boolean enabled, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime timestamp +) { + public static WebRTCMediaStateResponse of( + Long userId, + String nickname, + WebRTCMediaType mediaType, + Boolean enabled + ) { + return new WebRTCMediaStateResponse( + userId, + nickname, + mediaType, + enabled, + LocalDateTime.now() + ); + } +} diff --git a/src/main/java/com/back/global/websocket/webrtc/dto/media/WebRTCMediaToggleRequest.java b/src/main/java/com/back/global/websocket/webrtc/dto/media/WebRTCMediaToggleRequest.java new file mode 100644 index 00000000..d564cc60 --- /dev/null +++ b/src/main/java/com/back/global/websocket/webrtc/dto/media/WebRTCMediaToggleRequest.java @@ -0,0 +1,10 @@ +package com.back.global.websocket.webrtc.dto.media; + +import jakarta.validation.constraints.NotNull; + +public record WebRTCMediaToggleRequest( + @NotNull Long roomId, + @NotNull WebRTCMediaType mediaType, + @NotNull Boolean enabled +) { +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/webrtc/dto/media/WebRTCMediaType.java b/src/main/java/com/back/global/websocket/webrtc/dto/media/WebRTCMediaType.java new file mode 100644 index 00000000..340f77f9 --- /dev/null +++ b/src/main/java/com/back/global/websocket/webrtc/dto/media/WebRTCMediaType.java @@ -0,0 +1,7 @@ +package com.back.global.websocket.webrtc.dto.media; + +public enum WebRTCMediaType { + AUDIO, + VIDEO, + SCREEN +} diff --git a/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCAnswerRequest.java b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCAnswerRequest.java new file mode 100644 index 00000000..ca3d7cfe --- /dev/null +++ b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCAnswerRequest.java @@ -0,0 +1,12 @@ +package com.back.global.websocket.webrtc.dto.signal; + +import com.back.global.websocket.webrtc.dto.media.WebRTCMediaType; +import jakarta.validation.constraints.NotNull; + +public record WebRTCAnswerRequest( + @NotNull Long roomId, + @NotNull Long targetUserId, + @NotNull String sdp, + @NotNull WebRTCMediaType mediaType +) { +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCIceCandidateRequest.java b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCIceCandidateRequest.java new file mode 100644 index 00000000..2649ea46 --- /dev/null +++ b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCIceCandidateRequest.java @@ -0,0 +1,12 @@ +package com.back.global.websocket.webrtc.dto.signal; + +import jakarta.validation.constraints.NotNull; + +public record WebRTCIceCandidateRequest( + @NotNull Long roomId, + @NotNull Long targetUserId, + @NotNull String candidate, + @NotNull String sdpMid, + @NotNull Integer sdpMLineIndex +) { +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCOfferRequest.java b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCOfferRequest.java new file mode 100644 index 00000000..93fe4108 --- /dev/null +++ b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCOfferRequest.java @@ -0,0 +1,12 @@ +package com.back.global.websocket.webrtc.dto.signal; + +import com.back.global.websocket.webrtc.dto.media.WebRTCMediaType; +import jakarta.validation.constraints.NotNull; + +public record WebRTCOfferRequest( + @NotNull Long roomId, + @NotNull Long targetUserId, + @NotNull String sdp, + @NotNull WebRTCMediaType mediaType +) { +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCSignalResponse.java b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCSignalResponse.java new file mode 100644 index 00000000..59f8d2b4 --- /dev/null +++ b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCSignalResponse.java @@ -0,0 +1,65 @@ +package com.back.global.websocket.webrtc.dto.signal; + +import com.back.global.websocket.webrtc.dto.media.WebRTCMediaType; +import com.fasterxml.jackson.annotation.JsonFormat; +import java.time.LocalDateTime; + +public record WebRTCSignalResponse( + WebRTCSignalType type, + Long fromUserId, + Long targetUserId, + Long roomId, + String sdp, + WebRTCMediaType mediaType, + String candidate, + String sdpMid, + Integer sdpMLineIndex, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime timestamp +) { + // Offer, Answer용 생성자 + public static WebRTCSignalResponse offerOrAnswer( + WebRTCSignalType type, + Long fromUserId, + Long targetUserId, + Long roomId, + String sdp, + WebRTCMediaType mediaType + ) { + return new WebRTCSignalResponse( + type, + fromUserId, + targetUserId, + roomId, + sdp, + mediaType, + null, + null, + null, + LocalDateTime.now() + ); + } + + // ICE Candidate용 생성자 + public static WebRTCSignalResponse iceCandidate( + Long fromUserId, + Long targetUserId, + Long roomId, + String candidate, + String sdpMid, + Integer sdpMLineIndex + ) { + return new WebRTCSignalResponse( + WebRTCSignalType.ICE_CANDIDATE, + fromUserId, + targetUserId, + roomId, + null, + null, + candidate, + sdpMid, + sdpMLineIndex, + LocalDateTime.now() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCSignalType.java b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCSignalType.java new file mode 100644 index 00000000..c95e33b3 --- /dev/null +++ b/src/main/java/com/back/global/websocket/webrtc/dto/signal/WebRTCSignalType.java @@ -0,0 +1,7 @@ +package com.back.global.websocket.webrtc.dto.signal; + +public enum WebRTCSignalType { + OFFER, + ANSWER, + ICE_CANDIDATE +} diff --git a/src/main/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidator.java b/src/main/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidator.java new file mode 100644 index 00000000..6a8dfd96 --- /dev/null +++ b/src/main/java/com/back/global/websocket/webrtc/service/WebRTCSignalValidator.java @@ -0,0 +1,62 @@ +package com.back.global.websocket.webrtc.service; + +import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.repository.RoomMemberRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +/** + * WebRTC 시그널링 메시지 검증 + * - 같은 방에 있는지 확인 + * - 자기 자신에게 보내는지 확인 + * - 온라인 상태인지 확인 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WebRTCSignalValidator { + + private final RoomMemberRepository roomMemberRepository; + + // WebRTC 시그널 검증 + public void validateSignal(Long roomId, Long fromUserId, Long targetUserId) { + // 1. 자기 자신에게 보내는지 확인 + if (fromUserId.equals(targetUserId)) { + log.warn("자기 자신에게 시그널 전송 시도 - userId: {}", fromUserId); + throw new CustomException(ErrorCode.BAD_REQUEST); + } + + // 2. 발신자가 방에 속해있는지 확인 + Optional fromMember = roomMemberRepository.findByRoomIdAndUserId(roomId, fromUserId); + if (fromMember.isEmpty() || !fromMember.get().isOnline()) { + log.warn("방에 속하지 않은 사용자의 시그널 전송 시도 - roomId: {}, userId: {}", roomId, fromUserId); + throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); + } + + // 3. 수신자가 같은 방에 속해있는지 확인 + Optional targetMember = roomMemberRepository.findByRoomIdAndUserId(roomId, targetUserId); + if (targetMember.isEmpty() || !targetMember.get().isOnline()) { + log.warn("수신자가 방에 없거나 오프라인 상태 - roomId: {}, targetUserId: {}", roomId, targetUserId); + throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); + } + + log.debug("WebRTC 시그널 검증 통과 - roomId: {}, from: {}, to: {}", roomId, fromUserId, targetUserId); + } + + // 미디어 상태 변경 검증 + public void validateMediaStateChange(Long roomId, Long userId) { + Optional member = roomMemberRepository.findByRoomIdAndUserId(roomId, userId); + + if (member.isEmpty() || !member.get().isOnline()) { + log.warn("방에 속하지 않은 사용자의 미디어 상태 변경 시도 - roomId: {}, userId: {}", roomId, userId); + throw new CustomException(ErrorCode.NOT_ROOM_MEMBER); + } + + log.debug("미디어 상태 변경 검증 통과 - roomId: {}, userId: {}", roomId, userId); + } +} \ No newline at end of file diff --git a/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java b/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java index 10f7aad0..297a2e2e 100644 --- a/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java +++ b/src/test/java/com/back/global/websocket/service/WebSocketSessionManagerTest.java @@ -59,7 +59,7 @@ void t1() { ); // then - verify(valueOperations, times(2)).set(anyString(), any(), eq(Duration.ofMinutes(10))); + verify(valueOperations, times(2)).set(anyString(), any(), eq(Duration.ofMinutes(6))); } @Test @@ -81,7 +81,7 @@ void t2() { // then verify(redisTemplate, atLeastOnce()).delete(anyString()); // 기존 세션 삭제 - verify(valueOperations, times(2)).set(anyString(), any(), eq(Duration.ofMinutes(10))); // 새 세션 등록 + verify(valueOperations, times(2)).set(anyString(), any(), eq(Duration.ofMinutes(6))); // 새 세션 등록 } @Test @@ -191,7 +191,7 @@ void t9() { ); // then - verify(valueOperations).set(eq("ws:user:123"), any(WebSocketSessionInfo.class), eq(Duration.ofMinutes(10))); + verify(valueOperations).set(eq("ws:user:123"), any(WebSocketSessionInfo.class), eq(Duration.ofMinutes(6))); } @Test @@ -229,8 +229,8 @@ void t11() { // then verify(setOperations).add("ws:room:456:users", TEST_USER_ID); - verify(redisTemplate).expire("ws:room:456:users", Duration.ofMinutes(10)); - verify(valueOperations).set(eq("ws:user:123"), any(WebSocketSessionInfo.class), eq(Duration.ofMinutes(10))); + verify(redisTemplate).expire("ws:room:456:users", Duration.ofMinutes(6)); + verify(valueOperations).set(eq("ws:user:123"), any(WebSocketSessionInfo.class), eq(Duration.ofMinutes(6))); } @Test @@ -280,7 +280,7 @@ void t13() { // then verify(setOperations).remove("ws:room:456:users", TEST_USER_ID); - verify(valueOperations).set(eq("ws:user:123"), any(WebSocketSessionInfo.class), eq(Duration.ofMinutes(10))); + verify(valueOperations).set(eq("ws:user:123"), any(WebSocketSessionInfo.class), eq(Duration.ofMinutes(6))); } @Test