Files
invyone/backend-spring/src/main/java/com/erp/security/SecurityConfig.java
T
johngreen 4a83bfc8e8 feat(대무자): SubstituteContextFilter — JWT 후단계 effective_user_ids 주입
- TenantConsistencyGuard 뒤, ForcePasswordChangeGuard 앞에 등록
- /api/** 요청에 effective_user_ids = [user_id, ...active_original_ids] attribute 세팅
- SUPER_ADMIN (company_code='*') 은 short-circuit
- DB 조회 실패 시 본 요청 차단 안 함 (가용성 우선, warn 로그)
2026-05-12 08:06:21 +09:00

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;
}
}