diff --git a/backend/build.gradle b/backend/build.gradle index b35f0d6..4dad8d6 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -47,6 +47,10 @@ dependencies { // h2 database(for test) testImplementation 'com.h2database:h2:2.2.220' + // signalling + implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'com.fasterxml.jackson.core:jackson-databind' + // test testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/backend/src/main/java/com/focussu/backend/auth/filter/JwtAuthenticationFilter.java b/backend/src/main/java/com/focussu/backend/auth/filter/JwtAuthenticationFilter.java index 1ee74da..03ba0f7 100644 --- a/backend/src/main/java/com/focussu/backend/auth/filter/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/focussu/backend/auth/filter/JwtAuthenticationFilter.java @@ -29,6 +29,15 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenUtil jwtTokenUtil; private final TokenService tokenService; + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // 1) WebSocket 핸드쉐이크는 GET + Upgrade:websocket + + // 2) 이 외에도 필요하다면 특정 경로를 추가로 제외할 수 있습니다. + return "GET".equalsIgnoreCase(request.getMethod()) && + "websocket".equalsIgnoreCase(request.getHeader("Upgrade")) && + request.getRequestURI().equals("/ws/signaling"); + } @Override protected void doFilterInternal(HttpServletRequest req, diff --git a/backend/src/main/java/com/focussu/backend/auth/util/JwtTokenUtil.java b/backend/src/main/java/com/focussu/backend/auth/util/JwtTokenUtil.java index 472cb36..4f15e10 100644 --- a/backend/src/main/java/com/focussu/backend/auth/util/JwtTokenUtil.java +++ b/backend/src/main/java/com/focussu/backend/auth/util/JwtTokenUtil.java @@ -82,4 +82,15 @@ public Boolean validateToken(String token, UserDetails userDetails) { final String username = getUsernameFromToken(token); return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); } + + public boolean validateToken(String token) { + try { + // 서명 검증 및 Claims 파싱 + getAllClaimsFromToken(token); + // 만료 여부 검사 + return !isTokenExpired(token); + } catch (AuthException e) { + return false; + } + } } diff --git a/backend/src/main/java/com/focussu/backend/config/SecurityConfig.java b/backend/src/main/java/com/focussu/backend/config/SecurityConfig.java index 9de6428..4323e3c 100644 --- a/backend/src/main/java/com/focussu/backend/config/SecurityConfig.java +++ b/backend/src/main/java/com/focussu/backend/config/SecurityConfig.java @@ -103,6 +103,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(WhiteList.DOCS.getPatterns()).permitAll() .requestMatchers(WhiteList.AUTH.getPatterns()).permitAll() .requestMatchers(WhiteList.CHECKER.getPatterns()).permitAll() + .requestMatchers("/ws/signaling").permitAll() .anyRequest().authenticated() ) .addFilterBefore(authExceptionFilter, UsernamePasswordAuthenticationFilter.class) // 예외 처리 diff --git a/backend/src/main/java/com/focussu/backend/signalling/JwtHandshakeInterceptor.java b/backend/src/main/java/com/focussu/backend/signalling/JwtHandshakeInterceptor.java new file mode 100644 index 0000000..660d607 --- /dev/null +++ b/backend/src/main/java/com/focussu/backend/signalling/JwtHandshakeInterceptor.java @@ -0,0 +1,70 @@ +package com.focussu.backend.signalling; + +import com.focussu.backend.auth.util.JwtTokenUtil; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.List; +import java.util.Map; + +@Slf4j +public class JwtHandshakeInterceptor implements HandshakeInterceptor { + private final JwtTokenUtil jwtTokenUtil; + + public JwtHandshakeInterceptor(JwtTokenUtil jwtTokenUtil) { + this.jwtTokenUtil = jwtTokenUtil; + } + + @Override + public boolean beforeHandshake(ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler handler, + Map attrs) throws Exception { + // 1) 헤더에서 Authorization 꺼내기 + List authHeaders = request.getHeaders().get(HttpHeaders.AUTHORIZATION); + String token = null; + if (authHeaders != null && !authHeaders.isEmpty() + && authHeaders.get(0).startsWith("Bearer ")) { + token = authHeaders.get(0).substring(7); + } + + // 2) 헤더에 토큰이 없으면 query param 으로 fallback + if (token == null) { + token = UriComponentsBuilder.fromUri(request.getURI()) + .build() + .getQueryParams() + .getFirst("token"); + } + + // 3) 로깅 + log.info("[HandshakeInterceptor] 요청 URI: {}", request.getURI()); + log.info("[HandshakeInterceptor] 토큰: {}", token); + + // 4) 검증 + if (token == null || !jwtTokenUtil.validateToken(token)) { + response.setStatusCode(HttpStatus.UNAUTHORIZED); + return false; + } + + attrs.put("userId", jwtTokenUtil.getUsernameFromToken(token)); + return true; + } + + + @Override + public void afterHandshake(ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Exception exception) { + // 아무 처리 필요 없으면 빈 바디로 둡니다. + // // 또는 디버깅용: + System.out.println("WebSocket Handshake 완료: " + request.getRemoteAddress()); + } + +} diff --git a/backend/src/main/java/com/focussu/backend/signalling/SignalingHandler.java b/backend/src/main/java/com/focussu/backend/signalling/SignalingHandler.java new file mode 100644 index 0000000..233d7a5 --- /dev/null +++ b/backend/src/main/java/com/focussu/backend/signalling/SignalingHandler.java @@ -0,0 +1,123 @@ +package com.focussu.backend.signalling; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.AllArgsConstructor; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +public class SignalingHandler extends TextWebSocketHandler { + // 사용자ID → WebSocketSession 맵 + private final Map sessions = new ConcurrentHashMap<>(); + // 방ID → 사용자ID 세트 + private final Map> rooms = new ConcurrentHashMap<>(); + private final ObjectMapper mapper = new ObjectMapper(); + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + String userId = (String) session.getAttributes().get("userId"); + sessions.put(userId, session); + log.info("[Signaling] CONNECTED: {}", userId); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage msg) throws IOException { + SignalingMessage in = mapper.readValue(msg.getPayload(), SignalingMessage.class); + String from = (String) session.getAttributes().get("userId"); + String roomId = in.getRoomId(); + log.info("[Signaling] [{}] {} ▶ {}", roomId, from, in.getType()); + + switch (in.getType()) { + case "join": + // (1) 방 가입 + rooms.computeIfAbsent(roomId, r -> ConcurrentHashMap.newKeySet()).add(from); + // (2) 기존 멤버에게 new-peer 브로드캐스트 + ObjectNode joinPayload = mapper.createObjectNode().put("from", from); + SignalingMessage newPeerMsg = new SignalingMessage("new-peer", roomId, null, joinPayload); + broadcast(roomId, newPeerMsg, from); + break; + + case "leave": + // (1) 방 탈퇴 + Set members = rooms.getOrDefault(roomId, Collections.emptySet()); + members.remove(from); + // (2) peer-left 방송 + ObjectNode leavePayload = mapper.createObjectNode().put("from", from); + SignalingMessage leftMsg = new SignalingMessage("peer-left", roomId, null, leavePayload); + broadcast(roomId, leftMsg, from); + break; + + case "offer": + case "answer": + case "candidate": + case "ping": + // 1:1 대상 있으면 sendTo, 없으면 룸 브로드캐스트 + if (in.getTo() != null) { + sendTo(roomId, in.getTo(), from, in); + } else { + broadcast(roomId, in, from); + } + break; + + default: + log.warn("[Signaling] Unknown message type: {}", in.getType()); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + String userId = (String) session.getAttributes().get("userId"); + sessions.remove(userId); + rooms.values().forEach(m -> m.remove(userId)); + log.info("[Signaling] DISCONNECTED: {}", userId); + } + + /** + * 룸 내 전체 브로드캐스트 (sender 제외) + */ + private void broadcast(String roomId, SignalingMessage msg, String sender) throws IOException { + if (msg.getPayload() == null) { + msg.setPayload(mapper.createObjectNode()); + } + ((ObjectNode) msg.getPayload()).put("from", sender); + + for (String to : rooms.getOrDefault(roomId, Collections.emptySet())) { + if (!to.equals(sender)) { + WebSocketSession ws = sessions.get(to); + if (ws != null && ws.isOpen()) { + ws.sendMessage(new TextMessage(mapper.writeValueAsString(msg))); + } + } + } + log.info("[Signaling] BROADCAST to room {}: {}", roomId, msg.getType()); + } + + /** + * 룸 내 특정 유저에게 전송 + */ + private void sendTo(String roomId, String to, String from, SignalingMessage msg) throws IOException { + if (msg.getPayload() == null) { + msg.setPayload(mapper.createObjectNode()); + } + ((ObjectNode) msg.getPayload()).put("from", from); + + WebSocketSession ws = sessions.get(to); + if (ws != null && ws.isOpen() && rooms.getOrDefault(roomId, Collections.emptySet()).contains(to)) { + ws.sendMessage(new TextMessage(mapper.writeValueAsString(msg))); + log.info("[Signaling] SEND to {} in room {}: {}", to, roomId, msg.getType()); + } else { + log.warn("[Signaling] Cannot send to {} (not in room or closed)", to); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/focussu/backend/signalling/SignalingMessage.java b/backend/src/main/java/com/focussu/backend/signalling/SignalingMessage.java new file mode 100644 index 0000000..9afcb99 --- /dev/null +++ b/backend/src/main/java/com/focussu/backend/signalling/SignalingMessage.java @@ -0,0 +1,21 @@ +package com.focussu.backend.signalling; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SignalingMessage { + private String type; // "join"|"leave"|"offer"|"answer"|"candidate" + private String roomId; // ex: "room-1234" + private String to; // 1:1용 수신자 userId (필요 시) + private JsonNode payload; + + public SignalingMessage(String type, String roomId, JsonNode payload) { + this(type, roomId, null, payload); + } + // getters/setters... +} diff --git a/backend/src/main/java/com/focussu/backend/signalling/WebSecurityIgnoreConfig.java b/backend/src/main/java/com/focussu/backend/signalling/WebSecurityIgnoreConfig.java new file mode 100644 index 0000000..4eb38dc --- /dev/null +++ b/backend/src/main/java/com/focussu/backend/signalling/WebSecurityIgnoreConfig.java @@ -0,0 +1,14 @@ +package com.focussu.backend.signalling; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; + +@Configuration +public class WebSecurityIgnoreConfig { + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> web.ignoring() + .requestMatchers("/ws/signaling"); + } +} diff --git a/backend/src/main/java/com/focussu/backend/signalling/WebSocketConfig.java b/backend/src/main/java/com/focussu/backend/signalling/WebSocketConfig.java new file mode 100644 index 0000000..f6d1803 --- /dev/null +++ b/backend/src/main/java/com/focussu/backend/signalling/WebSocketConfig.java @@ -0,0 +1,32 @@ +package com.focussu.backend.signalling; + +import com.focussu.backend.auth.util.JwtTokenUtil; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +@Configuration +@EnableWebSocket +public class WebSocketConfig implements WebSocketConfigurer { + private final JwtTokenUtil jwtTokenUtil; // 기존에 쓰시던 유틸 + + public WebSocketConfig(JwtTokenUtil jwtTokenUtil) { + this.jwtTokenUtil = jwtTokenUtil; + } + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry + .addHandler(signalingHandler(), "/ws/signaling") + .setAllowedOrigins("*") + .addInterceptors(new JwtHandshakeInterceptor(jwtTokenUtil)); + } + + @Bean + public WebSocketHandler signalingHandler() { + return new SignalingHandler(); + } +} diff --git a/backend/src/main/resources/http/auth.http b/backend/src/main/resources/http/auth.http index 6c8e081..c2867c2 100644 --- a/backend/src/main/resources/http/auth.http +++ b/backend/src/main/resources/http/auth.http @@ -8,7 +8,7 @@ POST http://localhost:8080/auth/login Content-Type: application/json { - "email": "test@gmail.com", + "email": "test3@gmail.com", "password": "testpassword" } diff --git a/backend/src/main/resources/http/member.http b/backend/src/main/resources/http/member.http index cb8d846..a5ae1a3 100644 --- a/backend/src/main/resources/http/member.http +++ b/backend/src/main/resources/http/member.http @@ -3,7 +3,7 @@ POST http://localhost:8080/api/members Content-Type: application/json { - "name": "test", - "email": "test@gmail.com", + "name": "test3", + "email": "test3@gmail.com", "password": "testpassword" }