diff --git a/build.gradle b/build.gradle index 32b1bba..f1c83d3 100644 --- a/build.gradle +++ b/build.gradle @@ -33,11 +33,16 @@ dependencies { implementation('org.pgsg:common:0.3.2-SNAPSHOT') { exclude group: 'org.springframework.boot', module: 'spring-boot-starter-data-jpa' exclude group: 'com.querydsl', module: 'querydsl-jpa' + exclude group: 'org.springdoc', module: 'springdoc-openapi-starter-webmvc-ui' + exclude group: 'org.springframework.cloud', module: 'spring-cloud-starter-openfeign' + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-web' } implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webmvc' + implementation 'org.springframework.cloud:spring-cloud-starter-gateway-server-webflux' + implementation 'org.springframework.cloud:spring-cloud-starter-loadbalancer' implementation 'org.springframework.cloud:spring-cloud-starter-config' + implementation 'com.github.ben-manes.caffeine:caffeine' implementation 'io.micrometer:micrometer-tracing-bridge-brave' @@ -51,6 +56,7 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.cloud:spring-cloud-contract-wiremock' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' diff --git a/docker-compose.yaml b/docker-compose.yaml index 65c75b6..e6b6afb 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,8 +11,6 @@ services: - "8090:8090" env_file: - .env.runtime - environment: - - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=http://eureka-server:8761/eureka/ networks: - pgsg-network diff --git a/src/main/java/org/pgsg/gateway/GatewayApplication.java b/src/main/java/org/pgsg/gateway/GatewayApplication.java index 93920d4..bf583f0 100644 --- a/src/main/java/org/pgsg/gateway/GatewayApplication.java +++ b/src/main/java/org/pgsg/gateway/GatewayApplication.java @@ -5,13 +5,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.ImportAutoConfiguration; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.context.annotation.Import; @SpringBootApplication @ImportAutoConfiguration(exclude = AppCtx.class) @Import(GatewayAppCtx.class) -@EnableFeignClients public class GatewayApplication { public static void main(String[] args) { diff --git a/src/main/java/org/pgsg/gateway/auth/AuthProvider.java b/src/main/java/org/pgsg/gateway/auth/AuthProvider.java index 71589cb..ff389fe 100644 --- a/src/main/java/org/pgsg/gateway/auth/AuthProvider.java +++ b/src/main/java/org/pgsg/gateway/auth/AuthProvider.java @@ -1,6 +1,8 @@ package org.pgsg.gateway.auth; +import reactor.core.publisher.Mono; + public interface AuthProvider { - boolean verifyToken(String accessToken); + Mono verifyToken(String accessToken); } diff --git a/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java b/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java index 4634c1e..b410353 100644 --- a/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java +++ b/src/main/java/org/pgsg/gateway/auth/AuthProviderImpl.java @@ -1,59 +1,44 @@ package org.pgsg.gateway.auth; -import lombok.RequiredArgsConstructor; -import org.pgsg.common.response.CommonResponse; -import org.pgsg.gateway.feign.AuthClient; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.extern.slf4j.Slf4j; +import org.pgsg.gateway.client.AuthClient; import org.springframework.stereotype.Component; +import reactor.core.publisher.Mono; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +@Slf4j @Component -@RequiredArgsConstructor public class AuthProviderImpl implements AuthProvider { - private static final long CACHE_TTL = 30 * 1000; // 캐시 유지 시간: 30초 - private static final int MAX_CACHE_SIZE = 10000; - - // 간단한 로컬 캐시 (토큰별 검증 결과 저장) - private final Map cache = new ConcurrentHashMap<>(); + private final Cache tokenCache; private final AuthClient authClient; - @Override - public boolean verifyToken(String accessToken) { - CacheEntry entry = cache.get(accessToken); - - // 캐시가 유효하면 바로 반환 - if (entry != null && !entry.isExpired()) { - return entry.result; - } - - // 캐시가 없거나 만료되었으면 Feign 호출 - CommonResponse response = authClient.verifyToken(new AuthDto.TokenVerifyRequest(accessToken)); - - // 결과 추출 (success 가 true 이고 isVerifiedToken 이 true 인 경우에만 성공) - boolean result = response != null && response.success() && response.data() != null && response.data().isVerifiedToken(); - if (cache.size() >= MAX_CACHE_SIZE) { - cleanupCache(); - if (cache.size() >= MAX_CACHE_SIZE) { - cache.clear(); - } - } - cache.put(accessToken, new CacheEntry(result, System.currentTimeMillis() + CACHE_TTL)); - - cleanupCache(); - - return result; + public AuthProviderImpl(AuthClient authClient) { + this.authClient = authClient; + this.tokenCache = Caffeine.newBuilder() + .expireAfterWrite(30, TimeUnit.SECONDS) + .maximumSize(10000) + .build(); } - // 만료된 캐시를 가끔 정리 (메모리 누수 방지) - private void cleanupCache() { - cache.entrySet().removeIf(e -> e.getValue().isExpired()); - } + @Override + public Mono verifyToken(String accessToken) { + Boolean cachedResult = tokenCache.getIfPresent(accessToken); - private record CacheEntry(boolean result, long expiryTime) { - boolean isExpired() { - return System.currentTimeMillis() > expiryTime; + if (cachedResult != null) { + return Mono.just(cachedResult); } + + return authClient.verifyToken(new AuthDto.TokenVerifyRequest(accessToken)) + .map(response -> response != null + && response.success() + && response.data() != null + && response.data().isVerifiedToken()) + .doOnNext(result -> tokenCache.put(accessToken, result)) + .onErrorReturn(false); } } + diff --git a/src/main/java/org/pgsg/gateway/client/AuthClient.java b/src/main/java/org/pgsg/gateway/client/AuthClient.java new file mode 100644 index 0000000..127ed7f --- /dev/null +++ b/src/main/java/org/pgsg/gateway/client/AuthClient.java @@ -0,0 +1,28 @@ +package org.pgsg.gateway.client; + +import org.pgsg.common.response.CommonResponse; +import org.pgsg.gateway.auth.AuthDto; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +//@FeignClient(name = "user-service", fallbackFactory = AuthClientFallbackFactory.class) +@Component +public class AuthClient { + + private final WebClient webClient; + + public AuthClient(WebClient.Builder builder) { + this.webClient = builder.baseUrl("lb://user-service").build(); + } + + public Mono> verifyToken(AuthDto.TokenVerifyRequest request) { + return webClient.post() + .uri("/internal/v1/auth/verify") + .bodyValue(request) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .onErrorReturn(new CommonResponse<>(false, "인증 서비스 장애", new AuthDto.TokenVerifyData(false), null)); + } +} diff --git a/src/main/java/org/pgsg/gateway/config/GatewayAppCtx.java b/src/main/java/org/pgsg/gateway/config/GatewayAppCtx.java index c23205b..7fb12ad 100644 --- a/src/main/java/org/pgsg/gateway/config/GatewayAppCtx.java +++ b/src/main/java/org/pgsg/gateway/config/GatewayAppCtx.java @@ -1,75 +1,23 @@ package org.pgsg.gateway.config; -import com.fasterxml.jackson.databind.ObjectMapper; import org.pgsg.common.exception.ErrorConfigProperties; -import org.pgsg.common.exception.GlobalExceptionAdvice; -import org.pgsg.common.exception.GlobalExceptionAdviceImpl; -import org.pgsg.common.filter.MdcLoggingFilter; -import org.pgsg.common.response.CommonResponseAdvice; -import org.pgsg.config.feign.FeignConfig; import org.pgsg.config.json.JsonConfig; -import org.pgsg.config.security.*; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.cloud.client.loadbalancer.LoadBalanced; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; -import org.springframework.context.annotation.Lazy; -import org.springframework.core.Ordered; -import org.springframework.web.servlet.HandlerExceptionResolver; +import org.springframework.web.reactive.function.client.WebClient; @Configuration @Import({ - FeignConfig.class, JsonConfig.class, ErrorConfigProperties.class }) public class GatewayAppCtx { @Bean - public LoginFilter loginFilter(@Lazy @Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) { - return new LoginFilter(resolver); - } - - @Bean - public CustomAuthenticationEntryPoint customAuthenticationEntryPoint( - ObjectMapper objectMapper, ErrorConfigProperties errorConfigProperties) { - return new CustomAuthenticationEntryPoint(objectMapper, errorConfigProperties); - } - - @Bean - public CustomAccessDeniedHandler accessDeniedHandler( - ObjectMapper objectMapper, ErrorConfigProperties errorConfigProperties) { - return new CustomAccessDeniedHandler(objectMapper, errorConfigProperties); - } - - @Bean - @ConditionalOnMissingBean(SecurityConfig.class) - public SecurityConfig securityConfig( - LoginFilter loginFilter, - CustomAuthenticationEntryPoint customAuthenticationEntryPoint, - CustomAccessDeniedHandler accessDeniedHandler) { - return new SecurityConfigImpl(loginFilter, customAuthenticationEntryPoint, accessDeniedHandler); - } - - @Bean - @ConditionalOnMissingBean(GlobalExceptionAdvice.class) - public GlobalExceptionAdvice globalExceptionAdvice(ErrorConfigProperties errorConfigProperties) { - return new GlobalExceptionAdviceImpl(errorConfigProperties); - } - - @Bean - public CommonResponseAdvice commonResponseAdvice() { - return new CommonResponseAdvice(); - } - - @Bean - public FilterRegistrationBean mdcLoggingFilter() { - FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(new MdcLoggingFilter()); - registrationBean.addUrlPatterns("/*"); - registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE); - return registrationBean; + @LoadBalanced + public WebClient.Builder webClientBuilder() { + return WebClient.builder(); } } \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java b/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java index baf3453..98bc74d 100644 --- a/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java +++ b/src/main/java/org/pgsg/gateway/config/GatewaySecurityConfig.java @@ -1,27 +1,23 @@ package org.pgsg.gateway.config; +import lombok.RequiredArgsConstructor; import org.pgsg.config.security.SecurityConfig; 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.web.SecurityFilterChain; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.SecurityWebFilterChain; @Configuration -@EnableWebSecurity +@EnableWebFluxSecurity +@RequiredArgsConstructor public class GatewaySecurityConfig implements SecurityConfig { @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .sessionManagement(session - -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() - ); - return http.build(); + public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) { + return http + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .authorizeExchange(auth -> auth.anyExchange().permitAll()) + .build(); } -} +} \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/feign/AuthClient.java b/src/main/java/org/pgsg/gateway/feign/AuthClient.java deleted file mode 100644 index 614c113..0000000 --- a/src/main/java/org/pgsg/gateway/feign/AuthClient.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.pgsg.gateway.feign; - -import org.pgsg.common.response.CommonResponse; -import org.pgsg.gateway.auth.AuthDto; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; - -@FeignClient(name = "user-service", fallbackFactory = AuthClientFallbackFactory.class) -public interface AuthClient { - - @PostMapping(value = "/internal/v1/auth/verify") - CommonResponse verifyToken(@RequestBody AuthDto.TokenVerifyRequest request); -} diff --git a/src/main/java/org/pgsg/gateway/feign/AuthClientFallbackFactory.java b/src/main/java/org/pgsg/gateway/feign/AuthClientFallbackFactory.java deleted file mode 100644 index dc5c0a0..0000000 --- a/src/main/java/org/pgsg/gateway/feign/AuthClientFallbackFactory.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.pgsg.gateway.feign; - -import lombok.extern.slf4j.Slf4j; -import org.pgsg.common.response.CommonResponse; -import org.pgsg.gateway.auth.AuthDto; -import org.springframework.cloud.openfeign.FallbackFactory; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -public class AuthClientFallbackFactory implements FallbackFactory { - - @Override - public AuthClient create(Throwable cause) { - log.error("[AuthClientFallback] 인증 서비스 호출 실패: {}", cause.getMessage()); - return request -> new CommonResponse<>( - false, - "인증 서비스 장애 (Fallback)", - new AuthDto.TokenVerifyData(false), - null - ); - } -} diff --git a/src/main/java/org/pgsg/gateway/filter/HttpRequestHeaderWrapper.java b/src/main/java/org/pgsg/gateway/filter/HttpRequestHeaderWrapper.java deleted file mode 100644 index 8768c0e..0000000 --- a/src/main/java/org/pgsg/gateway/filter/HttpRequestHeaderWrapper.java +++ /dev/null @@ -1,69 +0,0 @@ -package org.pgsg.gateway.filter; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletRequestWrapper; - -import java.util.*; -import java.util.stream.Collectors; - -public class HttpRequestHeaderWrapper extends HttpServletRequestWrapper { - - private static final String FORBIDDEN_HEADER_PREFIX = "x-user-"; - - private final Map customHeaders = new HashMap<>(); - - public HttpRequestHeaderWrapper(HttpServletRequest request) { - super(request); - } - - public void putHeader(String name, String value) { - customHeaders.put(name.toLowerCase(), value); - } - - // x-user- 로 시작하는 헤더 일괄 제거 (스푸핑 방지) - public void removeHeaders(String prefix) { - Collections.list(super.getHeaderNames()).stream() - .filter(name -> name.toLowerCase().startsWith(prefix.toLowerCase())) - .forEach(name -> customHeaders.remove(name.toLowerCase())); - } - - @Override - public String getHeader(String name) { - String lowerName = name.toLowerCase(); - if (customHeaders.containsKey(lowerName)) { - return customHeaders.get(lowerName); - } - if (lowerName.startsWith(FORBIDDEN_HEADER_PREFIX)) { - return null; - } - return super.getHeader(name); - } - - @Override - public Enumeration getHeaders(String name) { - String lowerName = name.toLowerCase(); - String value = customHeaders.get(lowerName); - - if (customHeaders.containsKey(lowerName)) { - // 리스트의 길이가 1인 경우에도 호환 - return Collections.enumeration(Collections.singletonList(value)); - } - if (lowerName.startsWith(FORBIDDEN_HEADER_PREFIX)) { - return Collections.emptyEnumeration(); - } - return super.getHeaders(name); - } - - @Override - public Enumeration getHeaderNames() { - Set names = Collections.list(super.getHeaderNames()).stream() - .map(String::toLowerCase) - .filter(headerName -> - !customHeaders.containsKey(headerName) && // 직접 추가한 요청 헤더가 아니면서 - !headerName.startsWith(FORBIDDEN_HEADER_PREFIX) //금지된 접두사로 시작하는 헤더가 아님 - ) - .collect(Collectors.toCollection(LinkedHashSet::new)); - names.addAll(customHeaders.keySet()); - return Collections.enumeration(names); - } -} \ No newline at end of file diff --git a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java index ab8fc30..b16a4d1 100644 --- a/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java +++ b/src/main/java/org/pgsg/gateway/filter/JwtGatewayFilter.java @@ -1,29 +1,31 @@ package org.pgsg.gateway.filter; +import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.Claims; import io.jsonwebtoken.JwtException; import io.micrometer.tracing.Tracer; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; -import org.pgsg.config.security.CustomAuthenticationEntryPoint; +import org.pgsg.common.response.CommonResponse; import org.pgsg.config.security.jwt.JwtUtils; import org.pgsg.config.security.token.TokenProvider; import org.pgsg.config.security.token.TokenType; import org.pgsg.gateway.auth.AuthProvider; -import org.slf4j.MDC; -import org.springframework.context.annotation.Lazy; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.stereotype.Component; import org.springframework.util.AntPathMatcher; -import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; -import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.List; @@ -33,8 +35,7 @@ @Slf4j @Component -@Order(Ordered.HIGHEST_PRECEDENCE + 1) -public class JwtGatewayFilter extends OncePerRequestFilter { +public class JwtGatewayFilter implements GlobalFilter, Ordered { private static final String HEADER_TRACE_ID = "X-Trace-Id"; private static final AntPathMatcher pathMatcher = new AntPathMatcher(); @@ -54,136 +55,145 @@ public class JwtGatewayFilter extends OncePerRequestFilter { private final Tracer tracer; private final TokenProvider jwtTokenProvider; private final AuthProvider authProvider; - private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final ObjectMapper objectMapper; - public JwtGatewayFilter( - Tracer tracer, - TokenProvider jwtTokenProvider, - AuthProvider authProvider, - @Lazy CustomAuthenticationEntryPoint customAuthenticationEntryPoint) { + public JwtGatewayFilter(Tracer tracer, TokenProvider jwtTokenProvider, AuthProvider authProvider, ObjectMapper objectMapper) { this.tracer = tracer; this.jwtTokenProvider = jwtTokenProvider; this.authProvider = authProvider; - this.customAuthenticationEntryPoint = customAuthenticationEntryPoint; + this.objectMapper = objectMapper; } @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - - HttpRequestHeaderWrapper mutableRequest = new HttpRequestHeaderWrapper(request); - - // 1. 추적 ID 동기화 및 보안 헤더 초기화 - String traceId = initializeHeaders(mutableRequest, tracer); - - log.info("[JwtGatewayFilter] 요청 수신: {} {}", request.getMethod(), request.getRequestURI()); - String accessToken = JwtUtils.resolveToken(request.getHeader(HttpHeaders.AUTHORIZATION)); - String path = request.getRequestURI(); - - // 2. 화이트리스트 경로인 경우: 즉시 통과 (패턴 매칭 지원) + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + String path = request.getURI().getPath(); + String traceId = resolveTraceId(); + + // 헤더 초기화: x-user-* 제거 + traceId 주입 + ServerHttpRequest sanitized = request.mutate() + .headers(headers -> { + headers.keySet().removeIf(key -> key.toLowerCase().startsWith("x-user-")); + headers.set(HEADER_TRACE_ID, traceId); + }) + .build(); + + log.info("[JwtGatewayFilter] 요청 수신: {} {}", request.getMethod(), path); + + // 화이트리스트 통과 if (isWhitelisted(path)) { - filterChain.doFilter(mutableRequest, response); - return; + return chain.filter(exchange.mutate().request(sanitized).build()); } - // 3. 토큰이 없는 경우: 즉시 차단 (화이트리스트 제외) + // 토큰 누락 + String accessToken = JwtUtils.resolveToken( + request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION)); + if (accessToken == null) { log.warn("[JwtGatewayFilter] Access 토큰 누락 - 차단 (TraceID: {})", traceId); - customAuthenticationEntryPoint.commence(request, response, - new InsufficientAuthenticationException("Access 토큰이 필요합니다.")); - return; - } - - // 4. 통합 인증 프로세스 수행 (로컬 검증 -> 원격 검증 -> 헤더 주입) - if (!authenticate(mutableRequest, response, accessToken, traceId)) { - return; // 검증 실패 시 응답 종료 + return onAuthError(exchange, "Access 토큰이 필요합니다.", traceId); } - filterChain.doFilter(mutableRequest, response); + return authenticate(exchange, sanitized, chain, accessToken, traceId); } - private boolean isWhitelisted(String path) { - return WHITELIST.stream() - .anyMatch(pattern -> pathMatcher.match(pattern, path)); + private Mono authenticate(ServerWebExchange exchange, ServerHttpRequest sanitized, + GatewayFilterChain chain, String accessToken, String traceId) { + // [Step 1] 로컬 검증 + return Mono.fromCallable(() -> jwtTokenProvider.validateToken(accessToken)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(valid -> { + if (!valid) { + log.info("[JwtGatewayFilter] 유효하지 않은 토큰 - 차단 (TraceID: {})", traceId); + return Mono.error(new InsufficientAuthenticationException("유효하지 않거나 만료된 토큰입니다.")); + } + // [Step 2] 원격 검증 (WebClient 비동기 호출) + return authProvider.verifyToken(accessToken); + }) + .flatMap(verified -> { + if (!verified) { + log.warn("[JwtGatewayFilter] 블랙리스트 토큰 감지 - 차단 (TraceID: {})", traceId); + return Mono.error(new InsufficientAuthenticationException("이미 로그아웃되었거나 사용할 수 없는 토큰입니다.")); + } + // [Step 3] Claims 파싱 + return Mono.fromCallable(() -> jwtTokenProvider.parseClaims(accessToken)) + .subscribeOn(Schedulers.boundedElastic()); + }) + .flatMap(claims -> { + String tokenType = claims.get(JwtUtils.CLAIM_TOKEN_TYPE, String.class); + if (!TokenType.ACCESS.matches(tokenType)) { + log.warn("[JwtGatewayFilter] 허용되지 않은 토큰 타입 ({}) - 차단 (TraceID: {})", tokenType, traceId); + return Mono.error(new InsufficientAuthenticationException("Access 토큰이 필요합니다.")); + } + // [Step 4] 사용자 헤더 주입 + ServerHttpRequest mutated = injectUserHeaders(sanitized, claims); + log.info("[JwtGatewayFilter] 인증 성공 (TraceID: {})", traceId); + return chain.filter(exchange.mutate().request(mutated).build()); + }) + .onErrorResume(InsufficientAuthenticationException.class, + e -> onAuthError(exchange, e.getMessage(), traceId)) + .onErrorResume(JwtException.class, e -> { + log.error("[JwtGatewayFilter] JWT 예외: {} (TraceID: {})", e.getMessage(), traceId); + return onAuthError(exchange, "토큰 인증 중 오류가 발생했습니다.", traceId); + }) + .onErrorResume(IllegalArgumentException.class, e -> { + log.error("[JwtGatewayFilter] 잘못된 인자: {} (TraceID: {})", e.getMessage(), traceId); + return onAuthError(exchange, "토큰 인증 중 오류가 발생했습니다.", traceId); + }); } - /** - * 통합 인증 로직 (최적화된 순서) - * 1. 로컬 검증 (Signature, Expiration) - 비용 낮음 - * 2. 원격 검증 (Blacklist 체크) - 비용 높음 - * 3. Claims 파싱 및 헤더 주입 - */ - private boolean authenticate(HttpRequestHeaderWrapper request, HttpServletResponse response, String accessToken, String traceId) throws IOException, ServletException { - try { - // [Step 1] 로컬 검증 (가장 먼저 수행하여 잘못된 토큰의 원격 호출 방지) - if (!jwtTokenProvider.validateToken(accessToken)) { - log.info("[JwtGatewayFilter] 유효하지 않은 토큰 - 차단 (TraceID: {})", traceId); - customAuthenticationEntryPoint.commence(request, response, - new InsufficientAuthenticationException("유효하지 않거나 만료된 토큰입니다.")); - return false; - } - - // [Step 2] 원격 검증 (로컬 검증 통과 시에만 실시간 블랙리스트 확인) - if (!authProvider.verifyToken(accessToken)) { - log.warn("[JwtGatewayFilter] 블랙리스트 토큰 감지 - 차단 (TraceID: {})", traceId); - customAuthenticationEntryPoint.commence(request, response, - new InsufficientAuthenticationException("이미 로그아웃되었거나 사용할 수 없는 토큰입니다.")); - return false; - } - - // [Step 3] Claims 추출 및 토큰 타입 확인 - Claims claims = jwtTokenProvider.parseClaims(accessToken); - String tokenType = claims.get(JwtUtils.CLAIM_TOKEN_TYPE, String.class); - - if (!TokenType.ACCESS.matches(tokenType)) { - log.warn("[JwtGatewayFilter] 허용되지 않은 토큰 타입 ({}) - 차단 (TraceID: {})", tokenType, traceId); - customAuthenticationEntryPoint.commence(request, response, - new InsufficientAuthenticationException("Access 토큰이 필요합니다.")); - return false; - } - - // [Step 4] 검증 완료 - 사용자 헤더 주입 - injectUserHeaders(request, claims); - log.info("[JwtGatewayFilter] 인증 성공 - 사용자 헤더 주입 (TraceID: {})", traceId); - return true; - - } catch (JwtException | IllegalArgumentException e) { - log.error("[JwtGatewayFilter] 인증 처리 중 예외 발생: {} (TraceID: {})", e.getMessage(), traceId); - customAuthenticationEntryPoint.commence(request, response, - new InsufficientAuthenticationException("토큰 인증 중 오류가 발생했습니다.")); - return false; - } - } + private Mono onAuthError(ServerWebExchange exchange, String message, String traceId) { + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); - private String initializeHeaders(HttpRequestHeaderWrapper mutableRequest, Tracer tracer) { - String traceId = (tracer.currentSpan() != null) - ? Objects.requireNonNull(tracer.currentSpan()).context().traceId() - : MDC.get("traceId"); + CommonResponse errorResponse = new CommonResponse<>( + false, + message, + null, + traceId + ); - if (traceId == null) { - traceId = UUID.randomUUID().toString().substring(0, 8); + try { + byte[] body = objectMapper.writeValueAsBytes(errorResponse); + return response.writeWith(Mono.just(response.bufferFactory().wrap(body))); + } catch (Exception e) { + log.error("[JwtGatewayFilter] JSON 직렬화 오류 (TraceID: {})", traceId, e); + return Mono.error(e); } + } - MDC.put("traceId", traceId); - mutableRequest.removeHeaders("x-user-"); - mutableRequest.putHeader(HEADER_TRACE_ID, traceId); - - return traceId; + private ServerHttpRequest injectUserHeaders(ServerHttpRequest request, Claims claims) { + Boolean enabled = claims.get(JwtUtils.CLAIM_ENABLED, Boolean.class); + return request.mutate() + .header(JwtUtils.HEADER_USER_ID, claims.getSubject()) + .header(JwtUtils.HEADER_USERNAME, claims.get(JwtUtils.CLAIM_USERNAME, String.class)) + .header(JwtUtils.HEADER_ROLES, claims.get(JwtUtils.CLAIM_USER_ROLE, String.class)) + .header(JwtUtils.HEADER_USER_NAME, encodeValue(claims.get(JwtUtils.CLAIM_NAME, String.class))) + .header(JwtUtils.HEADER_USER_NICKNAME, encodeValue(claims.get(JwtUtils.CLAIM_NICKNAME, String.class))) + .header(JwtUtils.HEADER_ENABLED, enabled != null ? enabled.toString() : "false") + .build(); } - private void injectUserHeaders(HttpRequestHeaderWrapper request, Claims claims) { - request.putHeader(JwtUtils.HEADER_USER_ID, claims.getSubject()); - request.putHeader(JwtUtils.HEADER_USERNAME, claims.get(JwtUtils.CLAIM_USERNAME, String.class)); - request.putHeader(JwtUtils.HEADER_ROLES, claims.get(JwtUtils.CLAIM_USER_ROLE, String.class)); - request.putHeader(JwtUtils.HEADER_USER_NAME, encodeValue(claims.get(JwtUtils.CLAIM_NAME, String.class))); - request.putHeader(JwtUtils.HEADER_USER_NICKNAME, encodeValue(claims.get(JwtUtils.CLAIM_NICKNAME, String.class))); + private boolean isWhitelisted(String path) { + return WHITELIST.stream().anyMatch(pattern -> pathMatcher.match(pattern, path)); + } - Boolean enabled = claims.get(JwtUtils.CLAIM_ENABLED, Boolean.class); - request.putHeader(JwtUtils.HEADER_ENABLED, enabled != null ? enabled.toString() : "false"); + private String resolveTraceId() { + if (tracer.currentSpan() != null) { + return Objects.requireNonNull(tracer.currentSpan()).context().traceId(); + } + return UUID.randomUUID().toString().substring(0, 8); } private String encodeValue(String value) { return Optional.ofNullable(value) - .map(val -> URLEncoder.encode(val, StandardCharsets.UTF_8)) + .map(v -> URLEncoder.encode(v, StandardCharsets.UTF_8)) .orElse(null); } + + @Override + public int getOrder() { + return SecurityWebFiltersOrder.AUTHORIZATION.getOrder() + 1; + } } diff --git a/src/test/java/org/pgsg/gateway/JwtGatewayIntegrationTest.java b/src/test/java/org/pgsg/gateway/JwtGatewayIntegrationTest.java index b563bd0..12a6495 100644 --- a/src/test/java/org/pgsg/gateway/JwtGatewayIntegrationTest.java +++ b/src/test/java/org/pgsg/gateway/JwtGatewayIntegrationTest.java @@ -1,159 +1,164 @@ package org.pgsg.gateway; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.mockito.Mockito; import org.pgsg.common.response.CommonResponse; import org.pgsg.config.security.token.TokenProvider; -import org.pgsg.gateway.auth.AuthDto; -import org.pgsg.gateway.feign.AuthClient; +import org.pgsg.gateway.auth.AuthProvider; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; +import org.springframework.cloud.gateway.route.RouteLocator; +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; +import org.springframework.context.annotation.Bean; import org.springframework.http.HttpHeaders; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RestController; - +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; import org.pgsg.config.security.jwt.JwtUtils; +import reactor.core.publisher.Mono; import java.util.Map; -import static org.mockito.ArgumentMatchers.any; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.mockito.Mockito.*; import org.springframework.test.context.bean.override.mockito.MockitoBean; -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { + "downstream.service.url=http://localhost:${wiremock.server.port}" +}) +@AutoConfigureWebTestClient +@AutoConfigureWireMock(port = 0) class JwtGatewayIntegrationTest { @Autowired - private MockMvc mockMvc; + private WebTestClient webTestClient; + + @Autowired + private ObjectMapper objectMapper; @MockitoBean private TokenProvider tokenProvider; @MockitoBean - private AuthClient authClient; + private AuthProvider authProvider; - /** - * 테스트용 컨트롤러: 게이트웨이 필터를 거쳐 주입된 헤더를 확인하는 용도 - */ @TestConfiguration - @RestController - static class TestDownstreamController { - @GetMapping("/test/headers") - public Map getHeaders( - @RequestHeader(value = "x-user-id", required = false) String userId, - @RequestHeader(value = "x-user-roles", required = false) String roles - ) { - return Map.of( - "userId", userId != null ? userId : "null", - "roles", roles != null ? roles : "null" - ); - } - - @GetMapping("/api/v1/auth/login") - public String whitelist() { - return "ok"; + static class TestRouteConfig { + @Bean + public RouteLocator testRoutes(RouteLocatorBuilder builder, @Value("${downstream.service.url}") String downstreamUrl) { + return builder.routes() + .route("test_route", r -> r.path("/test/**") + .filters(f -> f.prefixPath("/internal")) + .uri(downstreamUrl)) + .route("auth_route", r -> r.path("/api/v1/auth/**") + .uri(downstreamUrl)) + .build(); } } @Test @DisplayName("유효한 토큰 요청 시 사용자 헤더가 정상 주입되어야 한다") - void success_token_injection() throws Exception { - // given - String token = "valid-token"; + void success_token_injection() throws JsonProcessingException { + String token = "valid-token-final"; String userId = "00000000-0000-0000-0000-000000000001"; String role = "ROLE_USER"; - Mockito.when(tokenProvider.validateToken(token)).thenReturn(true); - + when(tokenProvider.validateToken(token)).thenReturn(true); Claims claims = Jwts.claims() .subject(userId) .add(JwtUtils.CLAIM_USER_ROLE, role) - .add(JwtUtils.CLAIM_TOKEN_TYPE, "access") // TokenType.ACCESS.getValue() 값인 "access" 사용 + .add(JwtUtils.CLAIM_TOKEN_TYPE, "access") .add(JwtUtils.CLAIM_USERNAME, "tester") - .add(JwtUtils.CLAIM_NAME, "TesterName") - .add(JwtUtils.CLAIM_NICKNAME, "TestNick") - .add(JwtUtils.CLAIM_ENABLED, true) .build(); - Mockito.when(tokenProvider.parseClaims(token)).thenReturn(claims); - - Mockito.when(authClient.verifyToken(any())) - .thenReturn(new CommonResponse<>(true, "success", new AuthDto.TokenVerifyData(true), null)); - - // when & then - mockMvc.perform(get("/test/headers") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.userId").value(userId)) - .andExpect(jsonPath("$.data.roles").value(role)); + when(tokenProvider.parseClaims(token)).thenReturn(claims); + when(authProvider.verifyToken(token)).thenReturn(Mono.just(true)); + + // CommonResponse를 사용하여 JSON 바디 생성 + String responseBody = objectMapper.writeValueAsString( + new CommonResponse<>(true, "OK", Map.of("status", "passed"), "test-trace-id") + ); + + stubFor(get(urlEqualTo("/internal/test/headers")) + .withHeader(JwtUtils.HEADER_USER_ID, equalTo(userId)) + .withHeader(JwtUtils.HEADER_ROLES, equalTo(role)) + .willReturn(aResponse() + .withStatus(200) + .withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .withBody(responseBody))); + + webTestClient.get().uri("/test/headers") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .exchange() + .expectStatus().isOk() + .expectBody() + .jsonPath("$.success").isEqualTo(true) + .jsonPath("$.data.status").isEqualTo("passed"); } @Test @DisplayName("검증한 토큰이 블랙리스트에 포함되어 있다면 401 에러를 반환해야 한다") - void fail_blacklisted_token() throws Exception { - // given - String token = "blacklisted-token"; - Mockito.when(tokenProvider.validateToken(token)).thenReturn(true); - - // 원격 검증에서 실패(블랙리스트) 반환 - Mockito.when(authClient.verifyToken(any())) - .thenReturn(new CommonResponse<>(true, "fail", new AuthDto.TokenVerifyData(false), null)); - - // when & then - mockMvc.perform(get("/test/headers") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) - .andExpect(status().isUnauthorized()); + void fail_blacklisted_token() { + String token = "blacklisted-token-final"; + when(tokenProvider.validateToken(token)).thenReturn(true); + when(authProvider.verifyToken(token)).thenReturn(Mono.just(false)); + + webTestClient.get().uri("/test/headers") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .exchange() + .expectStatus().isUnauthorized() + .expectBody() + .jsonPath("$.success").isEqualTo(false) + .jsonPath("$.message").isEqualTo("이미 로그아웃되었거나 사용할 수 없는 토큰입니다."); } @Test @DisplayName("화이트리스트에 포함된 경로는 유효한 토큰 없이도 통과되어야 한다") - void success_whitelist() throws Exception { - mockMvc.perform(get("/api/v1/auth/login")) - .andExpect(status().isOk()); + void success_whitelist() { + stubFor(post(urlEqualTo("/api/v1/auth/login")) + .willReturn(aResponse().withStatus(200).withBody("ok"))); + + webTestClient.post().uri("/api/v1/auth/login") + .exchange() + .expectStatus().isOk(); } @Test @DisplayName("화이트리스트에 포함되지 않은 경로는 유효한 토큰이 없으면 차단되어야 한다") - void fail_nonWhitelist_noToken() throws Exception { - mockMvc.perform(get("/test/headers")) // 비화이트리스트 경로 - .andExpect(status().isUnauthorized()); + void fail_nonWhitelist_noToken() { + webTestClient.get().uri("/test/headers") + .exchange() + .expectStatus().isUnauthorized() + .expectBody() + .jsonPath("$.success").isEqualTo(false) + .jsonPath("$.message").isEqualTo("Access 토큰이 필요합니다."); } @Test @DisplayName("외부에서 주입한 보안 헤더(x-user-)는 무시되어야 한다") - void success_spoofing_protection() throws Exception { - // given - String token = "valid-token"; + void success_spoofing_protection() { + String token = "spoofing-check-final"; String realUserId = "00000000-0000-0000-0000-000000000001"; - Mockito.when(tokenProvider.validateToken(token)).thenReturn(true); - Claims claims = Jwts.claims() - .subject(realUserId) - .add(JwtUtils.CLAIM_USER_ROLE, "ROLE_USER") - .add(JwtUtils.CLAIM_TOKEN_TYPE, "access") // "access" 사용 - .add(JwtUtils.CLAIM_USERNAME, "tester") - .add(JwtUtils.CLAIM_NAME, "TesterName") - .add(JwtUtils.CLAIM_NICKNAME, "TestNick") - .add(JwtUtils.CLAIM_ENABLED, true) - .build(); - Mockito.when(tokenProvider.parseClaims(token)).thenReturn(claims); - Mockito.when(authClient.verifyToken(any())).thenReturn(new CommonResponse<>(true, "success", new AuthDto.TokenVerifyData(true), null)); - - // when & then - mockMvc.perform(get("/test/headers") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) - .header("x-user-id", "99999")) // 스푸핑 시도 - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.userId").value(realUserId)); // 게이트웨이가 주입한 값이어야 함 + when(tokenProvider.validateToken(token)).thenReturn(true); + Claims claims = Jwts.claims().subject(realUserId).add(JwtUtils.CLAIM_USER_ROLE, "ROLE_USER").add(JwtUtils.CLAIM_TOKEN_TYPE, "access").build(); + when(tokenProvider.parseClaims(token)).thenReturn(claims); + when(authProvider.verifyToken(token)).thenReturn(Mono.just(true)); + + stubFor(get(urlEqualTo("/internal/test/headers")) + .withHeader(JwtUtils.HEADER_USER_ID, equalTo(realUserId)) + .willReturn(aResponse().withStatus(200).withBody("ok"))); + + webTestClient.get().uri("/test/headers") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .header(JwtUtils.HEADER_USER_ID, "99999") // 스푸핑 시도 + .exchange() + .expectStatus().isOk(); } }