MSA Shopping Mall - 9 (trouble Shooting)

김원기·2025년 3월 27일

MSA Shopping Mall

목록 보기
10/13

MicroService의 경우

트러블슈팅을 먼저 진행하고 가야 할 것 같다.

그렇지만 따로 문제가 생겼던 코드는 커밋을 해두지 않았기에 실행했던 스크린샷과 같은 자료가 없고,
그냥 이러한 문제를 겪었었다 정도를 적는 시간이 될 것 같다.

트러블 슈팅

내가 겪었던 문제는 필터에서의 익명 인증 객체가 설정되는 문제였다.

나는 일단 MSA 구조 자체에서 Gateway에 보안적인 부분을 모두 할당? 한다는 점으로 이해하고 작업했었으며, 그로 인해 CustomHeader를 생성했고 해당 헤더들을 각 서비스에서 검증만하는 방식으로 구현했다.

이러한 방식을 통해
API Gateway :

  • JWT 토큰 검증 및 인증 객체 생성 후 Gateway내에 저장
  • CustomHeader 생성 및 DownStream 진행

각 서비스 :

  • DownStream 방식으로 전해진 헤더 검증
  • 리소스 접근

의 방식으로 진행된다고 생각했고, DownStream 방식으로 전해진 헤더 검증 을 SecurityConfig와 더불어 Filter를 통해 구현한다면 리소스 접근까지 해결할 수 있으리라 생각했다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, SignatureAuthFilter signatureAuthFilter) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/v1/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(signatureAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }
}

처음에 저렇게 Filter를 생성했는데 바로 여기서 익명 인증 객체의 문제가 발생했다.

@Component
@RequiredArgsConstructor
@Slf4j
public class SignatureAuthFilter extends OncePerRequestFilter {

    private final SignatureUtil signatureUtil;
    private final List<String> excludedPaths = List.of("/v1/auth/login", "/v1/users/signup", "/v1/users/email");

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        System.out.println("DISPATCHER TYPE : " + request.getDispatcherType());

        String path = request.getRequestURI();

        // 화이트리스트 URL 예외 처리 (필터 통과)
        if (excludedPaths.contains(path)) {
            filterChain.doFilter(request, response);
            return;  //  반드시 return 추가
        }

        String userId = request.getHeader("X-User-Id");
        String timestamp = request.getHeader("X-Timestamp");
        String signature = request.getHeader("X-Signature");

        // 헤더 값 검증 실패 시 즉시 리턴
        if (userId == null || timestamp == null || signature == null) {
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Missing authentication headers");
            return;
        }

        log.debug("Received authentication headers: userId={}, timestamp={}, signature={}", userId, timestamp, signature);

        // 타임스탬프 검증
        if (!signatureUtil.isTimestampValid(timestamp)) {
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Timestamp expired");
            return;
        }

        // 서명 검증
        if (!signatureUtil.validateSignature(userId, timestamp, signature)) {
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Invalid signature");
            return;
        }

        // 인증 정보 설정 (ROLE_USER 부여)
        List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_USER"));

        UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(userId, null, authorities);

        SecurityContextHolder.getContext().setAuthentication(authentication);

        log.debug("Authentication: {}", SecurityContextHolder.getContext().getAuthentication());

        // 검증 완료 후 필터 체인 실행
        filterChain.doFilter(request, response);
    }
}

위의 코드는 구현한 필터인데 구체적으로 문제가 발생했던 지점은 다음과 같은 부분이다.

// 인증 정보 설정 (ROLE_USER 부여)
List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_USER"));

UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(userId, null, authorities);

SecurityContextHolder.getContext().setAuthentication(authentication);

조금 보기 힘들 수 있으니 익명 인증이 활성화 되는 흐름을 먼저 정리해보겠다.
(다음 정리하는 내용은 SecurityConfig에서 설정해놓은 필터의 순서로 볼 수 있다.)

  1. 요청이 들어옴
  1. Filter Chain 실행
  1. Custom Filter 실행
    3-1. SecurityContextHolder.getContext().setAuthentication(auth) 를 제대로 설정하지 않으면 인증되지 않은 상태가 유지됨
  1. SecurityContext의 Authentication 값 확인 SecurityContextHolder.getContext().setAuthentication() 값이 null이거나 isAuthenticated() == false일 경우 AnonymousAuthenticationToken 이 자동으로 설정됨 (자동으로 익명 인증 객체가 생성된다는 뜻)
  1. 이후 인증이 필요한 경로에 접근할 경우 지속해서 AnonymousAuthenticationToken가 인증 객체로 사용된다.
  1. SecurityConfig에서 보면 CustomFilter 후에 UsernamePasswordAuthenticationFilter 를 실행하도록 하였는데 UsernamePasswordAuthenticationFilter 는 JWT 토큰과 관련된 인증 필터이므로 SignatureAuthFilter 이후 아무 역할을 하지 않는다.
  1. SecurityContext가 유지되지 않는다면, 다음 요청 시에도 익명 인증 객체가 설정됨
    SecurityContextHolder.getContext().setAuthentication(auth)을 제대로 설정하지 않으면
    매 요청마다 SecurityContext가 AnonymousAuthenticationToken을 생성하게 됨
    결국 모든 요청이 익명 사용자로 처리됨

이렇게 정리해도 어려운건 매한가지 인것 같다.

다만 4번 부터 문제일 것이라 생각되었고, 조금 크리티컬한 문제는 CustomFilter 실행 후 아무런 역할을 하지 않는 필터가 작동하기 때문에 AnonymousAuthenticationFilter 이전에 인증 객체를 설정하는 커스텀 필터를 작동하면 문제가 해결될 것이라 생각하고 필터의 순서를 바꿔도 보았다.

아쉽게도 이전에 실행하는 필터를 변경한다해도 문제가 없어지지는 않았다.

해결법

그래서 나는 필터 방식을 버리고 Interceptor 방식을 택했다.

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig implements WebMvcConfigurer {

    private final GatewayAuthenticationInterceptor gatewayAuthenticationInterceptor;

    @Bean
    public PasswordEncoder getPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(sessionManagement ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                        .requestMatchers("/v1/users/email","/v1/users/signup").permitAll()
                        .requestMatchers("/v1/users/**").permitAll()  // 모든 /v1/users/** 경로 허용
                        .anyRequest().permitAll()  // 다른 모든 요청도 허용
                );
        return http.build();
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(gatewayAuthenticationInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns(
                    "/error",
                    "/v1/users/signup",
                    "/v1/users/email",
                    "/v1/auth/login"
                );
    }
}

@Component
public class GatewayAuthenticationInterceptor implements HandlerInterceptor {
    
    @Autowired
    private GatewaySignatureVerifier signatureVerifier;
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String userId = request.getHeader("X-User-Id");
        String timestamp = request.getHeader("X-Timestamp");
        String signature = request.getHeader("X-Signature");
        
        if (userId == null || timestamp == null || signature == null) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }
        
        boolean isValid = signatureVerifier.isValidSignature(
            userId,
            Long.parseLong(timestamp),
            signature
        );
        
        if (!isValid) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return false;
        }
        
        return true;
    }
} 

인터셉터 방식을 적용하기 위해 일단 Spring Security의 모든 경로에 대해 접근을 허용하도록 변경했다.

그 후 모든 경로에 인터셉터를 추가하였고, 따라서 모든 요청에 헤더를 검증할 수 있도록 구현되었다.

profile
혼자 공부하는 블로그라 부족함이 많아요 https://www.notion.so/18067a27ac7e4f4790dde645fb3bf3d3?pvs=4

0개의 댓글