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 index af414b81..8c519196 100644 --- a/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCApiController.java +++ b/src/main/java/com/back/global/websocket/webrtc/controller/WebRTCApiController.java @@ -9,11 +9,16 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; - import java.util.List; @Slf4j @@ -26,6 +31,15 @@ public class WebRTCApiController { private final WebRTCProperties webRTCProperties; + @Value("${webrtc.turn.shared-secret}") + private String turnSharedSecret; + + @Value("${webrtc.turn.server-ip}") + private String turnServerIp; + + @Value("${webrtc.turn.ttl-seconds}") + private long turnTtlSeconds; + @GetMapping("/ice-servers") @Operation(summary = "ICE 서버 설정 조회") public ResponseEntity> getIceServers( @@ -34,13 +48,35 @@ public ResponseEntity> getIceServers( log.info("ICE 서버 설정 요청 - userId: {}, roomId: {}", userId, roomId); - List iceServers = + List iceServers = new ArrayList<>( webRTCProperties.iceServers().stream() .map(s -> new IceServer(s.urls(), s.username(), s.credential())) - .toList(); + .toList() + ); - IceServerConfig config = new IceServerConfig(iceServers); + // 동적으로 시간제한이 있는 TURN 서버 인증 정보 생성 + try { + // 유효기간 타임스탬프를 생성 (현재 시간 + TTL) + long expiry = (System.currentTimeMillis() / 1000) + turnTtlSeconds; + String username = String.valueOf(expiry); + // HMAC-SHA1 알고리즘과 공유 비밀키를 사용하여 비밀번호(credential) 생성 + Mac sha1Hmac = Mac.getInstance("HmacSHA1"); + SecretKeySpec secretKey = new SecretKeySpec(turnSharedSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA1"); + sha1Hmac.init(secretKey); + byte[] hmacBytes = sha1Hmac.doFinal(username.getBytes(StandardCharsets.UTF_8)); + String credential = Base64.getEncoder().encodeToString(hmacBytes); + + // 생성된 TURN 서버 정보를 리스트에 추가 + String turnUrl = "turn:" + turnServerIp + ":3478"; + iceServers.add(IceServer.turn(turnUrl, username, credential)); + + } catch (Exception e) { + log.error("TURN 서버 동적 인증 정보 생성에 실패했습니다. STUN 서버만으로 응답합니다.", e); + // 인증 정보 생성에 실패하더라도, STUN 서버만으로라도 서비스가 동작하도록 예외를 던지지 않음 + } + + IceServerConfig config = new IceServerConfig(iceServers); log.info("ICE 서버 설정 제공 완료 - STUN/TURN 서버 {}개", config.iceServers().size()); return ResponseEntity diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index fece4404..aed83600 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -115,6 +115,10 @@ webrtc: - urls: stun:stun.l.google.com:19302 - urls: stun:stun1.l.google.com:19302 - urls: stun:stun2.l.google.com:19302 + turn: + shared-secret: "${WEBRTC_TURN_SHARED_SECRET}" + server-ip: "${WEBRTC_TURN_SERVER_IP}" + ttl-seconds: 3600 # 스터디룸 설정 studyroom: diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 29aca42b..dc21bf3d 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -89,12 +89,6 @@ jwt: frontend: base-url: http://localhost:3000 -webrtc: - ice-servers: - - urls: stun:stun.l.google.com:19302 - - urls: stun:stun1.l.google.com:19302 - - urls: stun:stun2.l.google.com:19302 - # AWS S3 cloud: aws: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b00f1c8a..1ac27ea8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -122,6 +122,10 @@ webrtc: - urls: stun:stun.l.google.com:19302 - urls: stun:stun1.l.google.com:19302 - urls: stun:stun2.l.google.com:19302 + turn: + shared-secret: "${WEBRTC_TURN_SHARED_SECRET}" + server-ip: "${WEBRTC_TURN_SERVER_IP}" + ttl-seconds: 3600 # 스터디룸 설정 studyroom: diff --git a/src/test/java/com/back/global/websocket/webrtc/controller/WebRTCApiControllerTest.java b/src/test/java/com/back/global/websocket/webrtc/controller/WebRTCApiControllerTest.java index 13c66e6e..6043eb7d 100644 --- a/src/test/java/com/back/global/websocket/webrtc/controller/WebRTCApiControllerTest.java +++ b/src/test/java/com/back/global/websocket/webrtc/controller/WebRTCApiControllerTest.java @@ -9,6 +9,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -20,6 +21,11 @@ @SpringBootTest @AutoConfigureMockMvc @WithMockUser +@TestPropertySource(properties = { + "webrtc.turn.shared-secret=test-secret-key-for-junit-12345", + "webrtc.turn.server-ip=127.0.0.1" + // ttl-seconds는 application.yml의 기본값 사용 +}) @DisplayName("WebRTC API 컨트롤러") class WebRTCApiControllerTest { @@ -34,7 +40,7 @@ class WebRTCApiControllerTest { class GetIceServersTest { @Test - @DisplayName("기본 조회") + @DisplayName("STUN 서버와 동적으로 생성된 TURN 서버 정보를 모두 포함하여 반환") void t1() throws Exception { // when & then MvcResult result = mockMvc.perform(get("/api/webrtc/ice-servers") @@ -47,13 +53,17 @@ void t1() throws Exception { .andExpect(jsonPath("$.data").exists()) .andExpect(jsonPath("$.data.iceServers").isArray()) .andExpect(jsonPath("$.data.iceServers").isNotEmpty()) + .andExpect(jsonPath("$.data.iceServers[?(@.urls contains 'turn:')].urls").exists()) + .andExpect(jsonPath("$.data.iceServers[?(@.urls contains 'turn:')].username").exists()) + .andExpect(jsonPath("$.data.iceServers[?(@.urls contains 'turn:')].credential").exists()) .andReturn(); - // 응답 본문 검증 String content = result.getResponse().getContentAsString(); assertThat(content).contains("stun:"); + assertThat(content).contains("turn:"); } + @Test @DisplayName("userId, roomId 파라미터") void t2() throws Exception {