Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions chat_service/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,5 @@ out/

### VS Code ###
.vscode/

**/security/
11 changes: 9 additions & 2 deletions chat_service/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,30 @@ 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
runtimeOnly 'org.postgresql:postgresql'
// 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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Message<?>> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<String> allowedOrigins;
private boolean csrfProtection = true;
}
Loading
Loading