diff --git a/chat_service/.gitignore b/chat_service/.gitignore index c2065bc..ca83e35 100644 --- a/chat_service/.gitignore +++ b/chat_service/.gitignore @@ -35,3 +35,5 @@ out/ ### VS Code ### .vscode/ + +**/security/ \ No newline at end of file diff --git a/chat_service/build.gradle b/chat_service/build.gradle index 607a5e7..a838f0f 100644 --- a/chat_service/build.gradle +++ b/chat_service/build.gradle @@ -22,14 +22,18 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' // WebSocket implementation 'org.springframework.boot:spring-boot-starter-websocket' + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + // OAuth2 Resource Server + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + // WebSocket Security + implementation 'org.springframework.security:spring-security-messaging' // JPA implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // Validation implementation 'org.springframework.boot:spring-boot-starter-validation' // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' - // Session Redis - implementation 'org.springframework.session:spring-session-data-redis' // H2 runtimeOnly 'com.h2database:h2' // PostgreSQL @@ -37,8 +41,11 @@ dependencies { // Lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + // Auth0 + implementation 'com.auth0:java-jwt:4.4.0' //Test testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/WebSecurityConfig.java b/chat_service/src/main/java/com/synapse/chat_service/config/WebSecurityConfig.java new file mode 100644 index 0000000..73416e3 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/config/WebSecurityConfig.java @@ -0,0 +1,90 @@ +package com.synapse.chat_service.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authorization.AuthorityAuthorizationManager; +import org.springframework.security.authorization.AuthorizationManagers; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; +import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; + +import com.synapse.chat_service.filter.CustomAuthenticationFilter; + +import static org.springframework.security.config.Customizer.withDefaults; + +import java.util.Arrays; + +@Configuration(proxyBeanMethods = false) +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class WebSecurityConfig { + private final CustomAuthenticationFilter customAuthenticationFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .formLogin(form -> form.disable()) + .httpBasic(basic -> basic.disable()) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .csrf(csrf -> csrf + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + ) + + .addFilterBefore(customAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + + .authorizeHttpRequests(auth -> auth + // WebSocket 핸드셰이크 경로 ("/ws/**") - 인증된 사용자만 허용 + .requestMatchers("/ws/**").authenticated() + // 채팅 관련 API ("/api/v1/messages/**", "/api/v1/ai-chat/**") - 인증된 사용자만 허용 + .requestMatchers("/api/v1/messages/**", "/api/v1/ai-chat/**").authenticated() + // CSRF 토큰 발급 API ("/api/v1/csrf-token") - 인증된 사용자만 허용 + .requestMatchers("/api/v1/csrf-token").authenticated() + .requestMatchers("/api/internal/**").access(AuthorizationManagers.allOf( + AuthorityAuthorizationManager.hasAuthority("SCOPE_api.internal"), + AuthorityAuthorizationManager.hasAuthority("SCOPE_chat:read") + )) + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults())) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()) + .accessDeniedHandler(new BearerTokenAccessDeniedHandler()) + ) + .build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(Arrays.asList("http://localhost:3000")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "DELETE")); + configuration.setAllowedHeaders(Arrays.asList( + "Content-Type", + "Authorization", + "X-Requested-With", + "Accept" + )); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + configuration.setExposedHeaders(Arrays.asList("Authorization")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + + return source; + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketConfig.java b/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketConfig.java index afb0dba..bee36ea 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketConfig.java +++ b/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketConfig.java @@ -1,35 +1,75 @@ package com.synapse.chat_service.config; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; 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; +import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; + +import com.synapse.chat_service.config.properties.WebSocketSecurityProperties; +import com.synapse.chat_service.exception.service.CustomStompErrorHandler; +import com.synapse.chat_service.interceptor.WebSocketChannelInterceptor; + +import lombok.RequiredArgsConstructor; @Configuration @EnableWebSocketMessageBroker +@RequiredArgsConstructor +@EnableConfigurationProperties(WebSocketSecurityProperties.class) public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + private final CustomStompErrorHandler customStompErrorHandler; + private final WebSocketChannelInterceptor webSocketChannelInterceptor; + private final WebSocketSecurityProperties webSocketSecurityProperties; @Override public void configureMessageBroker(MessageBrokerRegistry config) { // 클라이언트에서 메시지를 받을 때 사용할 prefix config.setApplicationDestinationPrefixes("/app"); - - // 클라이언트가 구독할 때 사용할 prefix - config.enableSimpleBroker("/topic", "/queue") + // 클라이언트가 구독할 때 사용할 prefix (AI 응답 수신용) + config.enableSimpleBroker("/assistant") .setTaskScheduler(heartbeatScheduler()) .setHeartbeatValue(new long[] {10000, 10000}); - - // AI 응답을 특정 사용자에게 보낼 때 사용할 prefix - config.setUserDestinationPrefix("/ai"); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.setErrorHandler(customStompErrorHandler); registry.addEndpoint("/ws") - .setAllowedOriginPatterns("*"); // CORS 설정: 모든 도메인 허용 (개발 환경) + .setAllowedOrigins( + webSocketSecurityProperties.getAllowedOrigins().get(0) + ); + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + // 클라이언트로부터 들어오는 메시지를 처리하는 스레드 풀 설정 + registration.taskExecutor() + .corePoolSize(4) + .maxPoolSize(8) + .queueCapacity(50); + registration.interceptors(webSocketChannelInterceptor); + } + + @Override + public void configureClientOutboundChannel(ChannelRegistration registration) { + // 클라이언트로 나가는 메시지를 처리하는 스레드 풀 설정 + registration.taskExecutor() + .corePoolSize(4) + .maxPoolSize(8) + .queueCapacity(50); + } + + @Override + public void configureWebSocketTransport(WebSocketTransportRegistration registration) { + registration + .setMessageSizeLimit(128 * 1024) // 128KB + .setSendTimeLimit(15 * 1000) // 15초 + .setSendBufferSizeLimit(512 * 1024); // 512KB } @Bean diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketSecurityConfig.java b/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketSecurityConfig.java new file mode 100644 index 0000000..2599c5b --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketSecurityConfig.java @@ -0,0 +1,48 @@ +package com.synapse.chat_service.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.Message; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.config.annotation.web.socket.EnableWebSocketSecurity; +import org.springframework.security.messaging.access.intercept.MessageMatcherDelegatingAuthorizationManager; +import org.springframework.context.annotation.Bean; + +import static org.springframework.messaging.simp.SimpMessageType.CONNECT; +import static org.springframework.messaging.simp.SimpMessageType.CONNECT_ACK; +import static org.springframework.messaging.simp.SimpMessageType.DISCONNECT; +import static org.springframework.messaging.simp.SimpMessageType.HEARTBEAT; +import static org.springframework.messaging.simp.SimpMessageType.UNSUBSCRIBE; +import static org.springframework.messaging.simp.SimpMessageType.DISCONNECT_ACK; + +@Configuration(proxyBeanMethods = false) +@EnableWebSocketSecurity +public class WebSocketSecurityConfig { + private static final String ASSISTANT_SUBSCRIBE_DEST = "/assistant/**"; + private static final String MESSAGE_DEST = "/app/v1/message/**"; + + @Bean + public AuthorizationManager> messageAuthorizationManager() { + MessageMatcherDelegatingAuthorizationManager.Builder builder = + new MessageMatcherDelegatingAuthorizationManager.Builder(); + + return builder + // 연결 요청은 인증된 사용자만 허용 + .simpTypeMatchers( + CONNECT, + CONNECT_ACK, + HEARTBEAT, + UNSUBSCRIBE + ).authenticated() + // 구독 요청 권한 설정 (AI 응답 수신용) + .simpSubscribeDestMatchers(ASSISTANT_SUBSCRIBE_DEST).authenticated() + // 메시지 전송 권한 설정 (AI 채팅 통합) + .simpMessageDestMatchers(MESSAGE_DEST).authenticated() + // 연결 해제는 모든 사용자 허용 + .simpTypeMatchers( + DISCONNECT, + DISCONNECT_ACK + ).permitAll() + .anyMessage().authenticated() + .build(); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/properties/WebSocketSecurityProperties.java b/chat_service/src/main/java/com/synapse/chat_service/config/properties/WebSocketSecurityProperties.java new file mode 100644 index 0000000..9bfa9b7 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/config/properties/WebSocketSecurityProperties.java @@ -0,0 +1,18 @@ +package com.synapse.chat_service.config.properties; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +@Component +@ConfigurationProperties(prefix = "websocket.security") +@Getter +@Setter +public class WebSocketSecurityProperties { + private List allowedOrigins; + private boolean csrfProtection = true; +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/service/CustomStompErrorHandler.java b/chat_service/src/main/java/com/synapse/chat_service/exception/service/CustomStompErrorHandler.java new file mode 100644 index 0000000..23cdb04 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/exception/service/CustomStompErrorHandler.java @@ -0,0 +1,134 @@ +package com.synapse.chat_service.exception.service; + +import java.nio.charset.StandardCharsets; + +import org.springframework.http.MediaType; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.StompSubProtocolErrorHandler; + +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.synapse.chat_service.exception.commonexception.BusinessException; +import com.synapse.chat_service.exception.domain.ExceptionType; +import com.synapse.chat_service.exception.dto.ExceptionResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * StompSubProtocolErrorHandler를 상속받아서 프로토콜 레벨 오류 처리 역할을 명확히 분리합니다. + * 클라이언트에게 안전하고 일관된 오류 피드백을 제공하며, 서버 측에는 상세한 로그를 남깁니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomStompErrorHandler extends StompSubProtocolErrorHandler { + + private final ObjectMapper objectMapper; + + /** + * @MessageMapping 메소드 실행 중 또는 인바운드 채널 인터셉터에서 발생한 예외를 처리합니다. + * + * @param clientMessage 클라이언트에서 보낸 원본 메시지 + * @param ex 발생한 예외 + * @return 클라이언트에게 전송할 ERROR 프레임 + */ + @Override + public Message handleClientMessageProcessingError(Message clientMessage, Throwable ex) { + // 예외 분석 및 로깅 + Throwable rootCause = getRootCause(ex); + + log.error("STOMP message processing error occurred. Client message: {}, Exception: {}", + clientMessage, ex.getMessage(), ex); + + // 클라이언트용 에러 페이로드 생성 + ExceptionResponse errorResponse = createErrorResponse(rootCause); + + // ERROR 프레임 생성 및 반환 + return createErrorFrame(errorResponse); + } + + /** + * 브로커 자체 오류 등 서버에서 클라이언트로 보내는 다른 에러 메시지를 처리합니다. + * handleClientMessageProcessingError와 동일한 포맷으로 에러 메시지를 표준화합니다. + * + * @param errorMessage 원본 에러 메시지 + * @param clientMessage 클라이언트 메시지 (nullable) + * @return 표준화된 ERROR 프레임 + */ + @Override + public Message handleErrorMessageToClient(Message errorMessage) { + // 표준화된 에러 응답 생성 + ExceptionResponse errorResponse = ExceptionResponse.of( + ExceptionType.INTERNAL_SERVER_ERROR, + "WebSocket 통신 중 오류가 발생했습니다." + ); + + return createErrorFrame(errorResponse); + } + + /** + * 예외의 근본 원인을 찾습니다. + * + * @param ex 분석할 예외 + * @return 근본 원인 예외 + */ + private Throwable getRootCause(Throwable ex) { + Throwable cause = ex; + while (cause.getCause() != null && cause.getCause() != cause) { + cause = cause.getCause(); + } + return cause; + } + + /** + * 예외를 분석하여 적절한 ExceptionResponse를 생성합니다. + * + * @param ex 분석할 예외 + * @return 클라이언트에게 전송할 에러 응답 + */ + private ExceptionResponse createErrorResponse(Throwable ex) { + if (ex instanceof BusinessException businessException) { + // 비즈니스 예외인 경우 해당 ExceptionType 사용 + return ExceptionResponse.from(businessException); + } else if (ex instanceof IllegalArgumentException) { + // IllegalArgumentException인 경우 잘못된 입력값으로 처리 + return ExceptionResponse.of(ExceptionType.INVALID_INPUT_VALUE, ex.getMessage()); + } else if (ex instanceof JWTVerificationException) { + // JWT 관련 예외는 인증 실패로 처리합니다. + return ExceptionResponse.of(ExceptionType.INVALID_TOKEN, "유효하지 않은 토큰입니다."); + } else { + // 그 외의 경우 내부 서버 오류로 처리 + return ExceptionResponse.of(ExceptionType.INTERNAL_SERVER_ERROR); + } + } + + /** + * ExceptionResponse를 사용하여 STOMP ERROR 프레임을 생성합니다. + * + * @param errorResponse 에러 응답 객체 + * @return STOMP ERROR 프레임 + */ + private Message createErrorFrame(ExceptionResponse errorResponse) { + byte[] payload; + try { + payload = objectMapper.writeValueAsBytes(errorResponse); + } catch (JsonProcessingException e) { + // 직렬화 실패 시 비상용 메시지 + log.error("Failed to serialize error response: {}", e.getMessage(), e); + payload = "{\"code\":\"E999\",\"message\":\"Error response serialization failed.\"}".getBytes(StandardCharsets.UTF_8); + } + + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR); + accessor.setLeaveMutable(true); + accessor.setMessage(errorResponse.getMessage()); // 헤더 메시지 설정 + accessor.setContentType(MediaType.APPLICATION_JSON); // 콘텐츠 타입 명시 + + return MessageBuilder.createMessage(payload, accessor.getMessageHeaders()); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/filter/CustomAuthenticationFilter.java b/chat_service/src/main/java/com/synapse/chat_service/filter/CustomAuthenticationFilter.java new file mode 100644 index 0000000..bdeeffe --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/filter/CustomAuthenticationFilter.java @@ -0,0 +1,54 @@ +package com.synapse.chat_service.filter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAuthenticationFilter extends OncePerRequestFilter { + private static final String HEADER_MEMBER_ID = "X-Authenticated-Member-Id"; + private static final String HEADER_MEMBER_ROLE = "X-Authenticated-Member-Role"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String memberId = request.getHeader(HEADER_MEMBER_ID); + String memberRole = request.getHeader(HEADER_MEMBER_ROLE); + + // 현재는 게이트웨이로부터 받은 데이터에 헤더가 존재할 경우 신뢰함 + if (memberId != null && !memberId.isBlank()) { + List authorities = new ArrayList<>(); + if (memberRole != null && !memberRole.isBlank()) { + authorities = Arrays.stream(memberRole.split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + } + + Long principal = Long.parseLong(memberId); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(principal, null, authorities); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/interceptor/WebSocketChannelInterceptor.java b/chat_service/src/main/java/com/synapse/chat_service/interceptor/WebSocketChannelInterceptor.java new file mode 100644 index 0000000..807a6a5 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/interceptor/WebSocketChannelInterceptor.java @@ -0,0 +1,142 @@ +package com.synapse.chat_service.interceptor; + +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; + +import com.synapse.chat_service.provider.JwtTokenProvider; +import com.synapse.chat_service.session.WebSocketSessionFacade; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * STOMP 프로토콜 레벨에서 메시지를 가로채는 인터셉터 + * 세션 생명주기 관리 및 활동 추적 등 인증/상태와 관련된 부가적인 처리를 수행합니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WebSocketChannelInterceptor implements ChannelInterceptor { + private final JwtTokenProvider jwtTokenProvider; + private final WebSocketSessionFacade webSocketSessionFacade; + + /** + * STOMP 메시지 전송 전 처리 + * CONNECT, DISCONNECT, MESSAGE(SEND) 프레임을 가로채어 세션 관리 및 활동 추적을 수행합니다. + */ + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + + if (accessor == null || accessor.getCommand() == null) { + return message; + } + + switch (accessor.getCommand()) { + case CONNECT -> handleConnect(accessor); + case DISCONNECT -> handleDisconnect(accessor); + case SEND -> handleMessage(accessor); + default -> {} + } + + return message; + } + + /** + * CONNECT 프레임 처리 + * 사용자 인증 정보를 확인하고 WebSocket 세션을 생성합니다. + */ + private void handleConnect(StompHeaderAccessor accessor) { + String jwtToken = accessor.getFirstNativeHeader("Authorization"); + + Authentication authentication = jwtTokenProvider.verifyAndDecode(jwtToken); + // STOMP 세션에 인증 정보 저장 + accessor.setUser(authentication); + + String sessionId = accessor.getSessionId(); + String userId = authentication.getName(); + + if (userId == null) { + log.warn("CONNECT 프레임에서 인증되지 않은 사용자 감지: sessionId={}", sessionId); + return; + } + + String clientInfo = extractClientInfo(accessor); + + log.info("CONNECT 프레임 처리: sessionId={}, userId={}", sessionId, userId); + + // WebSocket 세션 생성 및 AI 채팅 정보 동기화 + webSocketSessionFacade.handleUserConnection(sessionId, userId, clientInfo); + + // 세션 속성에 사용자 ID 저장 (DISCONNECT 시 활용) + accessor.getSessionAttributes().put("userId", userId); + } + + /** + * DISCONNECT 프레임 처리 + * WebSocket 세션을 정리합니다. + */ + private void handleDisconnect(StompHeaderAccessor accessor) { + String sessionId = accessor.getSessionId(); + String userId = (String) accessor.getSessionAttributes().get("userId"); + + log.info("DISCONNECT 프레임 처리: sessionId={}, userId={}", sessionId, userId); + + // WebSocket 세션 정리 + webSocketSessionFacade.handleUserDisconnection(sessionId); + } + + /** + * MESSAGE(SEND) 프레임 처리 + * 사용자의 메시지 활동을 추적하고 활동 시간을 업데이트합니다. + */ + private void handleMessage(StompHeaderAccessor accessor) { + String sessionId = accessor.getSessionId(); + String userId = (String) accessor.getSessionAttributes().get("userId"); + + if (userId == null) { + log.warn("MESSAGE 프레임에서 사용자 ID를 찾을 수 없음: sessionId={}", sessionId); + return; + } + + log.debug("MESSAGE 프레임 처리: sessionId={}, userId={}", sessionId, userId); + + // 메시지 활동 추적 (마지막 활동 시간 및 메시지 수 업데이트) + webSocketSessionFacade.handleMessageActivity(userId); + } + + private String extractClientInfo(StompHeaderAccessor accessor) { + try { + StringBuilder clientInfo = new StringBuilder(); + + String clientType = accessor.getFirstNativeHeader("client-type"); + if (clientType != null) { + clientInfo.append("Type: ").append(clientType); + } + + String clientVersion = accessor.getFirstNativeHeader("client-version"); + if (clientVersion != null) { + if (clientInfo.length() > 0) clientInfo.append(", "); + clientInfo.append("Version: ").append(clientVersion); + } + + String deviceInfo = accessor.getFirstNativeHeader("device-info"); + if (deviceInfo != null) { + if (clientInfo.length() > 0) clientInfo.append(", "); + clientInfo.append("Device: ").append(deviceInfo); + } + + // 기본값: 클라이언트 정보가 없는 경우 + return clientInfo.length() > 0 ? clientInfo.toString() : "Web Client"; + + } catch (Exception e) { + log.warn("Failed to extract client info from STOMP headers: {}", e.getMessage()); + return "Unknown Client"; + } + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/provider/JwtTokenProvider.java b/chat_service/src/main/java/com/synapse/chat_service/provider/JwtTokenProvider.java new file mode 100644 index 0000000..47433e5 --- /dev/null +++ b/chat_service/src/main/java/com/synapse/chat_service/provider/JwtTokenProvider.java @@ -0,0 +1,51 @@ +package com.synapse.chat_service.provider; + +import java.util.Arrays; +import java.util.Collection; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTVerifier; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.Claim; +import com.auth0.jwt.interfaces.DecodedJWT; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class JwtTokenProvider { + private final Algorithm algorithm; + private final JWTVerifier verifier; + + public JwtTokenProvider(@Value("${secret.key}") String secretKey) { + this.algorithm = Algorithm.HMAC256(secretKey); + this.verifier = JWT.require(this.algorithm).build(); + } + + public final Authentication verifyAndDecode(String token) throws JWTVerificationException { + DecodedJWT decodedJWT = verifier.verify(token); + String userId = decodedJWT.getSubject(); + + Claim authClaim = decodedJWT.getClaim("role"); + String[] roles = authClaim.asString().split(","); + + Collection authorities = Arrays.stream(roles) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + UserDetails principal = new User(userId, "", authorities); + + return new UsernamePasswordAuthenticationToken(principal, "", authorities); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/WebSocketSessionFacade.java b/chat_service/src/main/java/com/synapse/chat_service/session/WebSocketSessionFacade.java index 895445a..dcd4b33 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/session/WebSocketSessionFacade.java +++ b/chat_service/src/main/java/com/synapse/chat_service/session/WebSocketSessionFacade.java @@ -22,11 +22,11 @@ public class WebSocketSessionFacade { * 1. 새로운 세션 생성 (다중 기기 동시 접속 지원) * 2. AI 채팅 정보 조회 (MessageService에서 DB와 Redis 동기화 처리) */ - public SessionInfo handleUserConnection(String sessionId, String userId, String username, String clientInfo) { + public SessionInfo handleUserConnection(String sessionId, String userId, String clientInfo) { log.info("AI 채팅 사용자 연결 처리 시작: sessionId={}, userId={}", sessionId, userId); // 1. 새로운 세션 생성 (다중 세션 지원) - SessionInfo sessionInfo = SessionInfo.create(sessionId, userId, username, clientInfo); + SessionInfo sessionInfo = SessionInfo.create(sessionId, userId, clientInfo); sessionManager.createSession(sessionInfo); // 2. AI 채팅 정보 조회 (MessageService에서 DB와 Redis 동기화가 이미 처리됨) diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionInfo.java b/chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionInfo.java index c36da95..f6546a0 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionInfo.java +++ b/chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionInfo.java @@ -17,7 +17,6 @@ public record SessionInfo( String sessionId, String userId, - String username, LocalDateTime connectedAt, LocalDateTime lastActivityAt, SessionStatus status, @@ -27,12 +26,11 @@ public record SessionInfo( /** * 새로운 AI 채팅 세션 생성을 위한 팩토리 메서드 */ - public static SessionInfo create(String sessionId, String userId, String username, String clientInfo) { + public static SessionInfo create(String sessionId, String userId, String clientInfo) { LocalDateTime now = LocalDateTime.now(); return new SessionInfo( sessionId, userId, - username, now, now, SessionStatus.CONNECTED, @@ -47,7 +45,6 @@ public SessionInfo updateLastActivity() { return new SessionInfo( sessionId, userId, - username, connectedAt, LocalDateTime.now(), status, @@ -62,7 +59,6 @@ public SessionInfo changeStatus(SessionStatus newStatus) { return new SessionInfo( sessionId, userId, - username, connectedAt, LocalDateTime.now(), newStatus, diff --git a/chat_service/src/main/resources/application-local.yml b/chat_service/src/main/resources/application-local.yml index 81b336e..c4694e7 100644 --- a/chat_service/src/main/resources/application-local.yml +++ b/chat_service/src/main/resources/application-local.yml @@ -28,16 +28,20 @@ spring: max-active: ${local-db.redis.max-active} max-idle: ${local-db.redis.max-idle} min-idle: ${local-db.redis.min-idle} - - session: - store-type: redis - redis: - namespace: spring:session session: expiration-hours: 24 max-sessions-per-user: 5 +websocket: + security: + allowed-origins: + - "http://localhost:3000" + - "http://localhost:3001" + - "http://127.0.0.1:3000" + - "http://127.0.0.1:3001" + csrf-protection: false + logging: level: org: diff --git a/chat_service/src/main/resources/security/application-jwt.yml b/chat_service/src/main/resources/security/application-jwt.yml new file mode 100644 index 0000000..a24c1fc --- /dev/null +++ b/chat_service/src/main/resources/security/application-jwt.yml @@ -0,0 +1,2 @@ +secret: + key: a8a16408bc2e11b6b74797dbd0837948b1267d5de209df9aaab670be16343b3d5faaf94c17c7e957aca3d5f691dca32ba4dddcb053bc44fd2de2bfc593e19cc4 diff --git a/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java b/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java deleted file mode 100644 index 3699b27..0000000 --- a/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java +++ /dev/null @@ -1,210 +0,0 @@ -package com.synapse.chat_service.controller; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.synapse.chat_service.domain.entity.Conversation; -import com.synapse.chat_service.domain.entity.Message; -import com.synapse.chat_service.domain.entity.enums.SenderType; -import com.synapse.chat_service.domain.repository.ConversationRepository; -import com.synapse.chat_service.domain.repository.MessageRepository; -import com.synapse.chat_service.dto.request.MessageRequest; -import com.synapse.chat_service.testutil.TestObjectFactory; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.transaction.annotation.Transactional; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@SpringBootTest -@AutoConfigureMockMvc -@Transactional -@DisplayName("MessageController 통합 테스트") -class MessageControllerTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private ObjectMapper objectMapper; - - @Autowired - private ConversationRepository conversationRepository; - - @Autowired - private MessageRepository messageRepository; - - private Conversation testConversation; - private Message testMessage; - - @BeforeEach - void setUp() { - // 테스트용 대화 생성 - testConversation = TestObjectFactory.createConversation(1L); - testConversation = conversationRepository.save(testConversation); - - // 테스트용 메시지 생성 - testMessage = TestObjectFactory.createUserMessage(testConversation, "테스트 메시지"); - testMessage = messageRepository.save(testMessage); - } - - @Nested - @DisplayName("POST /api/v1/messages - 메시지 생성") - class CreateMessage { - - @Test - @DisplayName("성공: 유효한 메시지 생성 요청") - void createMessage_Success() throws Exception { - // given - MessageRequest.Create request = new MessageRequest.Create( - testConversation.getUserId(), - SenderType.USER, - "새로운 메시지" - ); - - // when & then - mockMvc.perform(post("/api/v1/messages") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").exists()) - .andExpect(jsonPath("$.conversationId").value(testConversation.getId().toString())) - .andExpect(jsonPath("$.senderType").value("USER")) - .andExpect(jsonPath("$.content").value("새로운 메시지")) - .andExpect(jsonPath("$.createdDate").exists()) - .andExpect(jsonPath("$.updatedDate").exists()); - } - - @Test - @DisplayName("실패: 사용자 ID가 null인 경우") - void createMessage_Fail_NullUserId() throws Exception { - // given - MessageRequest.Create request = new MessageRequest.Create( - null, - SenderType.USER, - "메시지 내용" - ); - - // when & then - mockMvc.perform(post("/api/v1/messages") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("실패: 발신자 타입이 null인 경우") - void createMessage_Fail_NullSenderType() throws Exception { - // given - MessageRequest.Create request = new MessageRequest.Create( - testConversation.getUserId(), - null, - "메시지 내용" - ); - - // when & then - mockMvc.perform(post("/api/v1/messages") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("실패: 메시지 내용이 비어있는 경우") - void createMessage_Fail_BlankContent() throws Exception { - // given - MessageRequest.Create request = new MessageRequest.Create( - testConversation.getUserId(), - SenderType.USER, - "" - ); - - // when & then - mockMvc.perform(post("/api/v1/messages") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("성공: 새로운 사용자 ID로 대화 생성") - void createMessage_Success_NewUser() throws Exception { - // given - Long newUserId = 999L; - MessageRequest.Create request = new MessageRequest.Create( - newUserId, - SenderType.USER, - "새 사용자의 첫 메시지" - ); - - // when & then - mockMvc.perform(post("/api/v1/messages") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.content").value("새 사용자의 첫 메시지")); - } - } - - @Nested - @DisplayName("GET /api/v1/messages/{messageId} - 메시지 단건 조회") - class GetMessage { - - @Test - @DisplayName("성공: 존재하는 메시지 조회") - void getMessage_Success() throws Exception { - // when & then - mockMvc.perform(get("/api/v1/messages/{messageId}", testMessage.getId())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(testMessage.getId())) - .andExpect(jsonPath("$.conversationId").value(testConversation.getId().toString())) - .andExpect(jsonPath("$.senderType").value("USER")) - .andExpect(jsonPath("$.content").value("테스트 메시지")) - .andExpect(jsonPath("$.createdDate").exists()) - .andExpect(jsonPath("$.updatedDate").exists()); - } - - @Test - @DisplayName("실패: 존재하지 않는 메시지 ID") - void getMessage_Fail_NotFound() throws Exception { - // given - Long nonExistentMessageId = 99999L; - - // when & then - mockMvc.perform(get("/api/v1/messages/{messageId}", nonExistentMessageId)) - .andExpect(status().isNotFound()); - } - } - - - - @Nested - @DisplayName("DELETE /api/v1/messages/{messageId} - 메시지 삭제") - class DeleteMessage { - - @Test - @DisplayName("성공: 존재하는 메시지 삭제") - void deleteMessage_Success() throws Exception { - // when & then - mockMvc.perform(delete("/api/v1/messages/{messageId}", testMessage.getId())) - .andExpect(status().isNoContent()); - } - - @Test - @DisplayName("실패: 존재하지 않는 메시지 ID") - void deleteMessage_Fail_NotFound() throws Exception { - // given - Long nonExistentMessageId = 99999L; - - // when & then - mockMvc.perform(delete("/api/v1/messages/{messageId}", nonExistentMessageId)) - .andExpect(status().isNotFound()); - } - } -} diff --git a/chat_service/src/test/resources/application-test.yml b/chat_service/src/test/resources/application-test.yml index 5d8e519..f7a5358 100644 --- a/chat_service/src/test/resources/application-test.yml +++ b/chat_service/src/test/resources/application-test.yml @@ -1,4 +1,22 @@ spring: + security: + oauth2: + client: + registration: + payment-service: + provider: payment + client-id: test-service-client + client-secret: test-secret + authorization-grant-type: client_credentials + scope: api.internal, account:read + provider: + payment: + token-uri: test-uri + resourceserver: + jwt: + issuer-uri: http://test-issuer + jwk-set-uri: http://test-issuer/jwks + h2: console: enabled: true @@ -23,10 +41,22 @@ spring: open-in-view: false show-sql: true +secret: + key: a8a16408bc2e11b6b74797dbd0837948b1267d5de209df9aaab670be16343b3d5faaf94c17c7e957aca3d5f691dca32ba4dddcb053bc44fd2de2bfc593e19cc4 + session: expiration-hours: 24 max-sessions-per-user: 5 +websocket: + security: + allowed-origins: + - "http://localhost:3000" + - "http://localhost:3001" + - "http://127.0.0.1:3000" + - "http://127.0.0.1:3001" + csrf-protection: false + logging: level: org: