Files
invyone/backend-spring/src/main/java/com/erp/security/SecurityConfig.java
T
johngreen 22d073f563
Build & Deploy to K8s / build-and-deploy (push) Successful in 1m0s
SecurityConfig: Spring Security 6 호환 필터 등록 순서 수정
기존 코드는 addFilterBefore(AiApi, JwtAuthenticationFilter.class) 가
jwt 등록 전에 호출되어 'JwtAuthenticationFilter does not have a
registered order' 예외 발생. Spring Security 6 부터 anchor 는 _이미
등록된_ 필터여야 함.

수정: jwt 를 UsernamePasswordAuthenticationFilter 기준으로 가장 먼저
등록 → JwtAuthenticationFilter.class 가 known map 에 추가됨 →
이후 SubdomainResolver/AiApiKey/TenantConsistency/ForcePw 가 jwt 를
anchor 로 정상 참조.

실행 순서는 동일: SubdomainResolver → AiApiKey → Jwt → TenantConsistency
→ ForcePw → UsernamePasswordAuthenticationFilter

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:35:08 +09:00

113 lines
6.0 KiB
Java

package com.erp.security;
import com.erp.ai.security.AiApiKeyAuthFilter;
import com.erp.ai.service.AiAgentApiKeyService;
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;
/**
* 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 뒤 — 비번 강제 변경 대기자는 허용 경로만 통과.
.addFilterAfter(new ForcePasswordChangeGuardFilter(jwtTokenProvider),
TenantConsistencyGuardFilter.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;
}
}