4a83bfc8e8
- TenantConsistencyGuard 뒤, ForcePasswordChangeGuard 앞에 등록 - /api/** 요청에 effective_user_ids = [user_id, ...active_original_ids] attribute 세팅 - SUPER_ADMIN (company_code='*') 은 short-circuit - DB 조회 실패 시 본 요청 차단 안 함 (가용성 우선, warn 로그)
118 lines
6.4 KiB
Java
118 lines
6.4 KiB
Java
package com.erp.security;
|
|
|
|
import com.erp.ai.security.AiApiKeyAuthFilter;
|
|
import com.erp.ai.service.AiAgentApiKeyService;
|
|
import com.erp.service.SubstituteService;
|
|
import com.erp.tenant.CompanyResolver;
|
|
import com.erp.tenant.SubdomainResolverFilter;
|
|
import com.erp.tenant.TenantDbSettings;
|
|
import com.erp.tenant.TenantRoutingDataSource;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.context.annotation.Bean;
|
|
import org.springframework.context.annotation.Configuration;
|
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
|
import org.springframework.security.web.SecurityFilterChain;
|
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
|
import org.springframework.web.cors.CorsConfiguration;
|
|
import org.springframework.web.cors.CorsConfigurationSource;
|
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
|
|
|
import java.util.Arrays;
|
|
import java.util.List;
|
|
|
|
@Configuration
|
|
@EnableWebSecurity
|
|
@RequiredArgsConstructor
|
|
@Slf4j
|
|
public class SecurityConfig {
|
|
|
|
private final JwtTokenProvider jwtTokenProvider;
|
|
private final CompanyResolver companyResolver;
|
|
private final TenantRoutingDataSource tenantRoutingDataSource;
|
|
private final TenantDbSettings tenantDbSettings;
|
|
private final AiAgentApiKeyService aiAgentApiKeyService;
|
|
private final SubstituteService substituteService;
|
|
|
|
/**
|
|
* CORS 화이트리스트. 콤마 구분 문자열로 주입 (예: "http://localhost:3000,https://v1.invyone.com").
|
|
* application.yml 또는 환경변수 CORS_ALLOWED_ORIGINS 로 설정.
|
|
*/
|
|
@Value("${cors.allowed-origins}")
|
|
private String corsAllowedOrigins;
|
|
|
|
@Bean
|
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
|
http
|
|
.csrf(AbstractHttpConfigurer::disable)
|
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
|
.authorizeHttpRequests(auth -> auth
|
|
// ⚠️ 95개 컨트롤러 중 자체 토큰 검증을 하는 건 AuthController 한 곳뿐.
|
|
// SecurityConfig 단계에서 강제 인증을 켜면 회귀 위험 너무 큼 (Phase 3 별도 트랙).
|
|
// 그동안은 기존 동작 유지하되, JwtAuthenticationFilter 가 valid 토큰일 때
|
|
// SecurityContext 를 채워주어 컨트롤러가 사용자 정보 attribute 를 받을 수 있게 함.
|
|
.requestMatchers("/api/**").permitAll()
|
|
.anyRequest().authenticated()
|
|
)
|
|
// ⚠️ Spring Security 6 부터 addFilterBefore/After 의 anchor 는 _이미 등록된_
|
|
// 필터여야 함. 따라서 JwtAuthenticationFilter 를 가장 먼저 (Spring 표준
|
|
// UsernamePasswordAuthenticationFilter 기준으로) 등록한 뒤, 나머지 커스텀
|
|
// 필터들이 JwtAuthenticationFilter 를 anchor 로 사용한다.
|
|
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
|
|
UsernamePasswordAuthenticationFilter.class)
|
|
// Phase 2 (2026-04-24): 서브도메인 → CompanyResolver → TenantRoutingDataSource 라우팅.
|
|
// JwtAuthenticationFilter 보다 앞에서 실행되어야 tenant 컨텍스트가 먼저 결정됨.
|
|
.addFilterBefore(
|
|
new SubdomainResolverFilter(companyResolver, tenantRoutingDataSource, tenantDbSettings),
|
|
JwtAuthenticationFilter.class)
|
|
// AiApi 키 인증 — jwt 앞에서 sk-pipe-* 형식 처리, 매칭되지 않으면 jwt 로 통과.
|
|
.addFilterBefore(new AiApiKeyAuthFilter(aiAgentApiKeyService),
|
|
JwtAuthenticationFilter.class)
|
|
// JwtAuthenticationFilter 뒤 — JWT.company_code 와 서브도메인의 company_code 대조.
|
|
.addFilterAfter(new TenantConsistencyGuardFilter(jwtTokenProvider),
|
|
JwtAuthenticationFilter.class)
|
|
// TenantConsistencyGuardFilter 뒤 — 대무자(代務者) 컨텍스트 effective_user_ids 주입.
|
|
.addFilterAfter(new SubstituteContextFilter(substituteService),
|
|
TenantConsistencyGuardFilter.class)
|
|
// SubstituteContextFilter 뒤 — 비번 강제 변경 대기자는 허용 경로만 통과.
|
|
.addFilterAfter(new ForcePasswordChangeGuardFilter(jwtTokenProvider),
|
|
SubstituteContextFilter.class);
|
|
|
|
return http.build();
|
|
}
|
|
|
|
@Bean
|
|
public PasswordEncoder passwordEncoder() {
|
|
return new BCryptPasswordEncoder();
|
|
}
|
|
|
|
@Bean
|
|
public CorsConfigurationSource corsConfigurationSource() {
|
|
CorsConfiguration config = new CorsConfiguration();
|
|
// 테넌트 서브도메인 지원을 위해 setAllowedOriginPatterns 사용
|
|
// (setAllowedOrigins 는 정확한 매칭만 허용해서 *.invyone.com 같은 패턴이 안 됨)
|
|
// 전체 와일드카드 '*' 는 금지 — 반드시 명시된 prefix 만 허용.
|
|
List<String> patterns = Arrays.stream(corsAllowedOrigins.split(","))
|
|
.map(String::trim)
|
|
.filter(s -> !s.isEmpty())
|
|
.toList();
|
|
log.info("[CORS] raw value from @Value = '{}'", corsAllowedOrigins);
|
|
log.info("[CORS] parsed patterns ({}): {}", patterns.size(), patterns);
|
|
config.setAllowedOriginPatterns(patterns);
|
|
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
|
|
config.setAllowedHeaders(List.of("*"));
|
|
config.setAllowCredentials(true);
|
|
|
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
|
source.registerCorsConfiguration("/**", config);
|
|
return source;
|
|
}
|
|
}
|