diff --git a/build.gradle b/build.gradle index 41ed7c856..2de93e064 100644 --- a/build.gradle +++ b/build.gradle @@ -66,6 +66,7 @@ dependencies { // Etc implementation 'org.hibernate.validator:hibernate-validator' implementation 'com.amazonaws:aws-java-sdk-s3:1.12.782' + implementation 'org.springframework.boot:spring-boot-starter-websocket' } tasks.named('test', Test) { diff --git a/src/main/java/com/example/solidconnection/chat/config/StompEventListener.java b/src/main/java/com/example/solidconnection/chat/config/StompEventListener.java new file mode 100644 index 000000000..1064eef3a --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/config/StompEventListener.java @@ -0,0 +1,27 @@ +package com.example.solidconnection.chat.config; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionConnectEvent; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +@Component +public class StompEventListener { + + private final Set sessions = ConcurrentHashMap.newKeySet(); + + @EventListener + public void connectHandle(SessionConnectEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + sessions.add(accessor.getSessionId()); + } + + @EventListener + public void disconnectHandle(SessionDisconnectEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + sessions.remove(accessor.getSessionId()); + } +} diff --git a/src/main/java/com/example/solidconnection/chat/config/StompHandler.java b/src/main/java/com/example/solidconnection/chat/config/StompHandler.java new file mode 100644 index 000000000..660f01f28 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/config/StompHandler.java @@ -0,0 +1,64 @@ +package com.example.solidconnection.chat.config; + +import static com.example.solidconnection.common.exception.ErrorCode.AUTHENTICATION_FAILED; + +import com.example.solidconnection.auth.token.JwtTokenProvider; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +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.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class StompHandler implements ChannelInterceptor { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + public Message preSend(Message message, MessageChannel channel) { + final StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); + + if (StompCommand.CONNECT.equals(accessor.getCommand())) { + Claims claims = validateAndExtractClaims(accessor, AUTHENTICATION_FAILED); + } + + if (StompCommand.SUBSCRIBE.equals(accessor.getCommand())) { + Claims claims = validateAndExtractClaims(accessor, AUTHENTICATION_FAILED); + + String email = claims.getSubject(); + String destination = accessor.getDestination(); + + String roomId = extractRoomId(destination); + + // todo: roomId 기반 실제 구독 권한 검사 로직 추가 + } + + return message; + } + + private Claims validateAndExtractClaims(StompHeaderAccessor accessor, ErrorCode errorCode) { + String bearerToken = accessor.getFirstNativeHeader("Authorization"); + if (bearerToken == null || !bearerToken.startsWith("Bearer ")) { + throw new CustomException(errorCode); + } + String token = bearerToken.substring(7); + return jwtTokenProvider.parseClaims(token); + } + + private String extractRoomId(String destination) { + if (destination == null) { + throw new CustomException(ErrorCode.INVALID_ROOM_ID); + } + String[] parts = destination.split("/"); + if (parts.length < 3 || !parts[1].equals("topic")) { + throw new CustomException(ErrorCode.INVALID_ROOM_ID); + } + return parts[2]; + } +} diff --git a/src/main/java/com/example/solidconnection/chat/config/StompProperties.java b/src/main/java/com/example/solidconnection/chat/config/StompProperties.java new file mode 100644 index 000000000..ce9663c72 --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/config/StompProperties.java @@ -0,0 +1,23 @@ +package com.example.solidconnection.chat.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "websocket") +public record StompProperties(ThreadPool threadPool, HeartbeatProperties heartbeat) { + + public record ThreadPool(InboundProperties inbound, OutboundProperties outbound) { + + } + + public record InboundProperties(int corePoolSize, int maxPoolSize, int queueCapacity) { + + } + + public record OutboundProperties(int corePoolSize, int maxPoolSize) { + + } + + public record HeartbeatProperties(long serverInterval, long clientInterval) { + + } +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/chat/config/StompWebSocketConfig.java b/src/main/java/com/example/solidconnection/chat/config/StompWebSocketConfig.java new file mode 100644 index 000000000..86b6eef5d --- /dev/null +++ b/src/main/java/com/example/solidconnection/chat/config/StompWebSocketConfig.java @@ -0,0 +1,55 @@ +package com.example.solidconnection.chat.config; + +import com.example.solidconnection.chat.config.StompProperties.HeartbeatProperties; +import com.example.solidconnection.chat.config.StompProperties.InboundProperties; +import com.example.solidconnection.chat.config.StompProperties.OutboundProperties; +import com.example.solidconnection.security.config.CorsProperties; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +@RequiredArgsConstructor +public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer { + + private final StompHandler stompHandler; + private final StompProperties stompProperties; + private final CorsProperties corsProperties; + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + List strings = corsProperties.allowedOrigins(); + String[] allowedOrigins = strings.toArray(String[]::new); + registry.addEndpoint("/connect").setAllowedOrigins(allowedOrigins).withSockJS(); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + InboundProperties inboundProperties = stompProperties.threadPool().inbound(); + registration.interceptors(stompHandler).taskExecutor().corePoolSize(inboundProperties.corePoolSize()).maxPoolSize(inboundProperties.maxPoolSize()).queueCapacity(inboundProperties.queueCapacity()); + } + + @Override + public void configureClientOutboundChannel(ChannelRegistration registration) { + OutboundProperties outboundProperties = stompProperties.threadPool().outbound(); + registration.taskExecutor().corePoolSize(outboundProperties.corePoolSize()).maxPoolSize(outboundProperties.maxPoolSize()); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("wss-heartbeat-"); + scheduler.initialize(); + HeartbeatProperties heartbeatProperties = stompProperties.heartbeat(); + registry.setApplicationDestinationPrefixes("/publish"); + registry.enableSimpleBroker("/topic").setHeartbeatValue(new long[]{heartbeatProperties.serverInterval(), heartbeatProperties.clientInterval()}).setTaskScheduler(scheduler); + } +} diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 824c531f6..4659e61bf 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -112,6 +112,10 @@ public enum ErrorCode { UNAUTHORIZED_MENTORING(HttpStatus.FORBIDDEN.value(), "멘토링 권한이 없습니다."), MENTORING_ALREADY_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "이미 승인 또는 거절된 멘토링입니다."), + // socket + UNAUTHORIZED_SUBSCRIBE(HttpStatus.FORBIDDEN.value(), "구독 권한이 없습니다."), + INVALID_ROOM_ID(HttpStatus.BAD_REQUEST.value(), "경로의 roomId가 잘못되었습니다."), + // report ALREADY_REPORTED_BY_CURRENT_USER(HttpStatus.BAD_REQUEST.value(), "이미 신고한 상태입니다."), diff --git a/src/main/resources/secret b/src/main/resources/secret index be52e6ce9..e592f6d36 160000 --- a/src/main/resources/secret +++ b/src/main/resources/secret @@ -1 +1 @@ -Subproject commit be52e6ce9ca3d2c6eb51442108328b00a539510b +Subproject commit e592f6d36f57185c8d92a7838c0e3039603b2c57 diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 83fe6e8cf..7abc6949f 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -42,6 +42,18 @@ view: count: scheduling: delay: 3000 +websocket: + thread-pool: + inbound: + core-pool-size: 8 + max-pool-size: 16 + queue-capacity: 1000 + outbound: + core-pool-size: 8 + max-pool-size: 16 + heartbeat: + server-interval: 15000 + client-interval: 15000 oauth: apple: token-url: "https://appleid.apple.com/auth/token"