spring

Spring Security 들여다보기 (1)

e4g3r 2025. 4. 2. 14:45
 

Spring / Spring Security filter chain 들여다보기

Spring 혹은 Spring Security를 사용하면 filter라는 키워드를 종종 듣게 됩니다.단순히 Http 요청이 Spring Application 영역으로 전달되기 전에 Servlet단에서 chain 형식으로 연결되어 있는 filter들을 하나씩실

e4g3r.tistory.com

 

지난 포스팅에서 Servlet Filter가 어떻게 처리되는지 확인해보면서 간단하게 Spring Security Filter Chain을 간단하게 살펴보았습니다.

이번에는 Spring Security Filter Chain 내부를 좀 더 자세히 보면서 정리하는 시간을 가져보았습니다.

 

제가 진행중인 프로젝트는 모든 URL을 permitAll 해주고 있습니다. 또한 JWT 토큰 기반 인증을 위해 JWT 인증 Filter를 사용합니다.

 

추가로 jwtAuthenticationFilter를 통해 HTTP 헤더에 있는 JWT 토큰을 파싱하여 인증 객체를 설정 해주고 있습니다.

 

SecurityFilterChain을 설정할 때 JwtAuthenticationFilter만 추가했지만 디버깅을 통해 SecurityFilterChain 내부를 확인해보면 Spring Security가 자동으로 설정한 Default Filter들이 추가되었음을 볼 수 있었습니다.

 

중요하다고 생각되는 Filter들을 살펴보기로 했습니다.

SecurityContext

우선 필터를 살펴보기전에 SecurityContext라는 개념을 다시 돌아보겠습니다.


Spring Security로 Jwt 인증을 처리하다보면 jwt 토큰을 통해 유저 정보를 조회하여 Authentication 객체를 만들어 SecurityContextHolder에 저장해주었던 로직을 작성했었습니다.

https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html#servlet-authentication-securitycontext

보안 컨텍스트 홀더는 스프링 보안이 인증된 사용자에 대한 세부 정보를 저장하는 곳입니다.
Spring Security는 SecurityContextHolder가 어떻게 채워지는지는 신경 쓰지 않습니다.
값이 포함되어 있으면 현재 인증된 사용자로 사용됩니다.

 

공식문서를 확인해보면 SecurityContext는 인증된 사람의 정보를 가지고 있는 Authentication을 포함하고 있는 객체이고

SecurityContextHolder에서 SecurityContext 객체를 포함하고 있는 형태였습니다.

그런데 실제 SecurityContextHolder 내부를 확인해보면 context라는 필드는 존재하지 않았습니다.


또한 SecurityContextHolder의 getContext의 메서드는 strategy 필드를 이용해서 처리하고 있습니다.

따라서 핵심적인 것은 strategy 필드인 SecurityContextHolderStrategy라고 볼 수 있을 것 같습니다.

SecurityContextHolderStrategy는 인터페이스고 주로 SecurityContext를 관리하는 메서드가 있습니다.

그럼 왜 이걸 인터페이스로 분리했고 구현한 클래스는 어떤 것들이 있는지 궁금할 수 있습니다.

 

그 정답은 다시 SecurityContextHolder로 돌아가서 초기화 메서드를 보면 알 수 있었습니다.

위 initializeStrategy 메서드는 SecurityContextHolder가 초기화 될 때 실행되는 메서드입니다.

strategyName에 따라 어떤 SecurityContextHolderStrategy 구현체를 사용할지 정해지게 됩니다.

 

일반적으로 strategtName의 기본 값은 System.propery에 정의 된 값을 사용하게 되는데 별도로 지정해주지 않으면 MODE_THREADLOCAL로 설정되는 것을 알 수 있습니다.

ThreadLocalSecurityContextHolderStrategy 내부를 보면 SecurityContext를 ThreadLocal로 보관하는 것을 볼 수 있습니다.
이 이유는 공식문서에서 알 수 있었습니다.

UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
        loginRequest.getUsername(), loginRequest.getPassword());
Authentication authentication = this.authenticationManager.authenticate(token);
// ...
SecurityContext context = SecurityContextHolder.createEmptyContext(); 
context.setAuthentication(authentication); 
SecurityContextHolder.setContext(context);

위의 코드는 잘 작동하지만 원치 않는 결과를 초래할 수 있습니다.
컴포넌트가 SecurityContextHolder를 통해 보안 컨텍스트에 정적으로 액세스하면, 
SecurityContextHolderStrategy를 지정하려는 여러 애플리케이션 컨텍스트가 있을 때 경쟁 조건을 생성할 수 있습니다. 
이는 SecurityContextHolder에는 애플리케이션 컨텍스트당 하나의 전략이 아니라 클래스 로더당 하나의 전략이 있기 때문입니다.

공식문서

 

한마디로 SecurityContextHolder는 일반적으로 모든 쓰레드에서 동시에 접근 가능한 객체이기 때문에 여러 객체가 동시에 접근하고

수정하면 인증 정보가 꼬일 수 있다는 의미입니다.

1. Thread-1: 유저 A의 인증을 처리하여 SecurityContextHolder에 저장

2. Thread-2: 유저 B의 인증을 처리하여 SecurityContextHolder에 저장

3. Thread-1: 현재 나의 인증 정보를 조회하는 경우 유저 B의 인증 정보 반환

 

위와 같은 문제가 발생하는 것을 방지하기 위해 ThreadLocalSecurityContextHolderStrategy같은 구현체를 통해 SecurityContext를 어떻게 보관할건지 정의하는 단계가 필요했던 것이였습니다.

 

ThreadLocalSecurityContextHolderStrategy 구현체는 일반적으로 자주 사용되는 방식으로
SecurityContext를 ThreadLocal로 보관하여 동시성 문제가 발생하지 않도록 하고 있던 것이었습니다.

 

이외에도 부모-자식 쓰레드간 SecurityContext가 공유되는 InheritableThreadLocalSecurityContextHolderStrategy,

공식문서의 예제처럼 모든 쓰레드가 동일한 SecurityContext를 바라보는 GlobalSecurityContextHolderStrategy가 있습니다.

 

이제 Spring Security Filter Chain에서 중요하다고 생각하는 Filter들을 살펴보겠습니다.

SecurityContextHolderFilter

 

먼저 SecurityContextHolderFilter입니다.

이 필터의 역할은 HTTP 요청을 처리하는 Thread가 사용할 SecurityContext의 공간을 관리하는 것이라고 볼 수 있습니다.

 

먼저 첫번째로 securityContextRepository로부터 SecurityContext를 가져와서 설정해주는 로직이 있습니다.

세션 기반 인증의 경우 인증 정보가 stateful하기 때문에 세션의 대한 SecurityContext는 어딘가에 저장이 될 것인데요.

SecurityContext를 보관하고 있는 저장소로부터 가져와 초기화 해주는 작업이라고 볼 수 있습니다.

 

하지만 JWT의 경우는 인증 정보가 statelessful하기 때문에 저장되고 있지는 않을 것입니다.

따라서 매번 요청마다 새로운 SecurityContext를 생성 할 것입니다. (저장소에 존재하지 않다는 의미)

 

그리고 finally를 통해 HTTP 요청을 마무리 하기 전(응답을 주기 전) Thread가 사용했던 SecurityContext 공간을 정리 합니다.

AuthorizationFilter

다음으로는 AuthorizationFilter입니다.

AuthorizationFilter는 이름 그대로 인가를 처리 해주는 것을 볼 수 있습니다.


앞에 Filter에서는 세션 혹은 JWT를 검증한 후 인증 정보인 SecurityContext를 설정해주는 처리를 하였다면

해당 필터에서는 SecurityContext에 저장된 인증 정보를 확인해서 권한이 있는지, 접근할 수 있는지 확인하는 역할이라고 볼 수 있습니다.

 

위 코드의 로직을 보면

 

1. 먼저 이미 해당 Filter를 수행한 경우 탈출

2. 비동기 작업이거나 에러 처리를 위한 요청이라면 탈출
3. authorizaionManager를 통해 인가 여부를 확인하고 인가되지 않았다면 예외 발생

 

핵심 로직은 3번이라고 볼 수 있습니다.

 

decision에 인가 여부가 담겨지게 되고 예외를 던져주기 때문에 인가 여부를 판단하는 authorizationManager를 확인해보겠습니다.


AuthorizationManager는 함수형 인터페이스로 verify 메서드와 check 메서드가 존재합니다.

디버깅을 통해 어떤 authorizaionManager가 구현체로 사용되는지 확인해보았습니다.


디버깅 결과 RequestMatcherDelegatingAuthorizationManager라는 구현체가 사용이 되었고 좀 더 자세히 살펴보았습니다.

 

check 메서드의 구현 부분을 보면 존재하는 AuthorizationManager를 순회하면서 요청이 URL Pattern과 일치 하는지 확인하고

적절한 authorizaionManager에게 인가 여부를 위임하는 것을 볼 수 있었습니다.

 

https://docs.spring.io/spring-security/reference/servlet/authorization/architecture.html#authz-delegate-authorization-manager

RequestMatcherDelegatingAuthorizationManager는 요청을 가장 적절한 위임 AuthorizationManager와
일치시킵니다.

 

공식 문서에서 또한 위임하는 역할이라고 명시가 되어 있습니다.

 

그런데 코드로만 보면 정확하게 잘 이해가 되지 않아 직접 설정을 수정하여 살펴보았습니다.

 

먼저 기존 securityFilterChain에서 모든 API URL을 permitAll을 해주던 것을 /123, /456, /789에는 ADMIN 권한이 필요하도록

수정해보았습니다.

 

디버깅 과정을 통해 확인해보면 4개의 URL에 대해 인가 설정이 되었기에 mappings 필드에도 4개의 AuthorizationManager
존재하고 있음을 볼 수 있었습니다.
또한 hasAnyRole처럼 권한 기반 인가 설정을 해주었기에 AuthorityAuthorizationManager로 처리되고 있음을 볼 수 있었습니다.

 

정리를 해보자면

 

1. SecurityFilterChain에서 URL 기반으로 인가 설정을 하면 {URL - AuthorizationManager} 짝 지어짐

2. 짝 지어진 {URL - AuthorizationManager}는 RequestMatcherDelegatingAuthorizationManager 내부에서 관리

3. HTTP 요청이 올 때 마다 RequestMatcherDelegatingAuthorizationManager는 URL을 확인하여 적절한 AuthorizationManager에게 인가를 위임 함

@PreAuthorize는 어떻게 처리 될 까?

앞에서는 SecurityFilterChain에서 URL 매핑을 통해 권한별 인가 설정을 해주었지만 URL이 많아질수록 복잡해지고 불편했기에

저는 평소에 SecurityFilterChain에는 permitAll을 해주고 컨트롤러에서 @PreAuthorize을 사용해 처리해주는 방식을 선호했습니다.

 

하지만 앞서 분석하는 과정을 돌이켜보면 Filter에서 직접적으로 PreAuthorize를 처리하는 부분은 없었기에 어떻게 처리되는 것인지

확인해보았습니다.

https://docs.spring.io/spring-security/reference/servlet/authorization/method-security.html


공식문서에서 PreAuthorize와 같은 Method Security가 처리되는 플로우를 보여주고 있는데요.

위 플로우를 제 프로젝트 기준으로 디버깅 과정을 통해 따라가보겠습니다.

 

먼저 플로우에서 가장 먼저 보이는 ExceptionTranslationFilter입니다. 해당 Filter는 try catch를 통해 HTTP 요청이 처리되는 도중

발생하는 예외를 잡는 것으로 보이는데요.

 

발생하는 예외 종류가 AuthenticationException, AccessDeniedException 관련된 것이라면 해당 Filter에서 처리되는 것 같습니다.

 

 

공식문서에서 제공하는 플로우를 보면 ExceptionTranslationFilter와 readCustomer라는 것과 연결되어 있는데요.

여기서 readCustomer는 @PreAuthorize와 같은 MethodSecurity가 적용된 메서드를 의미합니다.


따라서 저의 경우는 보통 컨트롤러에서 권한 설정을 해주기 때문에 위 코드에서는 registerCreator를 의미합니다.

공식문서에 따르면 MethodSecurity가 적용된 메서드는 AOP를 통해 호출된다고 합니다.

따라서 registerCreator도 AOP를 통해 처리될 것이기 때문에 디버깅 과정을 이어서 가보겠습니다.

 

AOP를 통해 registerCreator를 처리할 때 AuthorizationManagerBeforeMethodInterceptor를 실행하는 것으로 보입니다.

따라서 AuthorizaionManagerBeforeMethodInterceptor 쪽에 브레이크 포인트를 걸어 확인해보겠습니다.

 

dispatherServlet을 따라서 handler를 호출하는 과정을 쭉 진행하다보니 AuthorizaionManagerBeforeMethodInterceptor

attemptAuthorization에 진입할 수 있었습니다.

 

해당 메서드에서 authorizationManager를 통해 인가 여부를 결정하고 인가 여부를 이벤트로 발행하는 것을 볼 수 있습니다.

 

디버깅을 통해 확인해보면 현재 authorizationManager는 PreAuthorizeAuthorizationManager입니다.


PreAuthorizeAuthorizationManager의 check 메서드를 확인해보면 PreAuthorize의 표현식을 해석하여 필요한 권한을 알아내고

필요 권한과 현재 유저의 권한을 확인해서 인가 여부를 반환해주는 것을 볼 수 있었습니다.

 

공식문서 플로우를 보면 MethodSecurityExpresstionHandler를 통해 표현식을 해석하는 것으로 보입니다.

표현식은 @PreAuthorize에 사용된 ROLE_NormalUser인것을 볼 수 있습니다.

다시 인가 처리 과정을 수행하고 AuthorizaionManagerBeforeMethodInterceptor로 돌아오면 decision에
인가 여부가 담겨있게 됩니다.

 

만약 인가가 거부되었다면 if문 분기에 의해 this.handle 메서드가 호출되어 더이상 진행을하지 못하게 되고

인가가 승인되었다면 this.proceed를 통해 요청을 계속해서 처리하게 됩니다.

 

인가가 거부되었다면 최종적으로 AuthorizationDeniedException 예외가 발생하게 됩니다.

 

그리고 앞에서 언급되었던 ExceptionTranslationFilter에서 catch가 되어 401 혹은 403 응답을 반환하게 됩니다.