spring security와 filter
서블릿과 필터는 Java EE(enterprise edition)을 스펙을 기반으로 합니다. 기존의 filter를 등록하는 표준 방식(ex. web.xml 설정)에서는 서블릿 컨테이너에 의해 직접 관리됩니다. 따라서 서블릿 컨테이너가 직접 관리하는 필터는 스프링 애플리케이션 컨텍스트 내에서 정의하는 빈들을 인식하지 못합니다. 따라서DelegatingFilterProxy를 통해 스프링 빈으로 요청을 위임합니다. DelegatingFilterProxy는 FilterProxy(FilterChainProxy)구현체를 찾아 위임합니다.
// DelegatingFilterProxy.java
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// Lazily initialize the delegate if necessary.
Filter delegateToUse = this.delegate;
if (delegateToUse == null) {
synchronized (this.delegateMonitor) {
delegateToUse = this.delegate;
if (delegateToUse == null) {
WebApplicationContext wac = findWebApplicationContext();
if (wac == null) {
throw new IllegalStateException("No WebApplicationContext found: " +
"no ContextLoaderListener or DispatcherServlet registered?");
}
delegateToUse = initDelegate(wac);
}
this.delegate = delegateToUse;
}
}
// Let the delegate perform the actual doFilter operation.
invokeDelegate(delegateToUse, request, response, filterChain);
}
FilterChainProxy
스프링 시큐리티는 FilterChainProxy로 서블릿을 지원합니다. FilterChainProxy는 스프링 시큐리티가 제공하는 특별한 Filter로 SecurityFilterChain을 통해 여러 Filter인스턴스로 위임할 수 있습니다. FilterChainProxy는 빈이기 때문에 보통 DelegtingFilterProxy 로 감싸져있습니다. FilterChainProxy는 순서대로 FilterChain에 등록되어 있는 Filter를 실행시킵니다.
// FilterChainProxy.java
@Override
public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException {
if (this.currentPosition == this.size) {
this.originalChain.doFilter(request, response);
return;
}
this.currentPosition++;
Filter nextFilter = this.additionalFilters.get(this.currentPosition - 1);
if (logger.isTraceEnabled()) {
String name = nextFilter.getClass().getSimpleName();
logger.trace(LogMessage.format("Invoking %s (%d/%d)", name, this.currentPosition, this.size));
}
nextFilter.doFilter(request, response, this);
}
Spring Security의 FilterChain에 등록되는 필터는 다음과 같습니다 (security 6.1.1, boot 3.1.1)
- DisableEncodeUrlFilter
URL 인코딩을 비활성화하는 역할을 합니다 - WebAsyncManagerIntegrationFilter
ThreadLocal의 SecurityContext를 통합 관리하는 역할입니다 - SecurityContextHolderFilter
SecurityContextHolder는 주어진 SecurityContext를 현재 Thread에 연결시키는 역할을 합니다
SecurityContextRepository에서 HttpSession키로 SecurityContext를 로드합니다 - HeaderWriterFilter
HeaderWriterFilter는 현재 응답에 대해 브라우저 보호 헤더를 추가합니다 - CsrfFilter
csrf 공격에 대한 보호기능을 하는 필터입니다. 내부를 보면 crsf token을 생성하고 request에 등록합니다. - LogoutFilter
로그아웃 기능을 하는 필터입니다 클라이언트가 로그아웃을 하면 인증정보에 대한 부분을 삭제합니다. - RequestCacheAwareFilter
이전 request에 대한 부분을 캐시하는 역할을 하는 필터입니다. 예를 들어 리다이렉트할 요청 url 저장등 - SecurityContextHolderAwareRequestFilter
request를 SpringSecurity 용으로 감싸는 Wrapper 클래스를 생성하는 역할을 합니다 - AnonymousAuthenticationFilter
인증정보가 없는 요청에 대해서 익명사용자 토근을 생성하고 Authentication에 등록처리하는 필터입니다 - ExceptionTranslationFilter
필터 처리 중 발생하는 AuthenticationException, AccessDeniedException에 대해 핸들링합니다.
해당 과정을 살펴보면서 궁금한 것이 2가지가 생겼습니다.
첫번째 질문. spring boot에서는 내장 톰켓으로 구동되어 application context가 톰켓 실행 이전에 초기화가 됩니다. 그렇다면 “spring boot에서는 application context 정의 된 빈을 사용할 수 있지 않을까?”
결론 부터 이야기 하자면 가능합니다. spring boot는 내장 톰켓이 실행전 application context를 초기화 합니다. 따라서 spring boot는 filter를 bean으로 등록할 수 있습니다. 이는 filter가 application context에 접근할 수 있으며, application context에 정의된 빈을 사용할 수 있습니다.
다음 설정 파일이 있습니다
// TestSecurityConfiguration.java
@Configuration
public class TestSecurityConfiguration {
@Bean
public TestComponent testComponent(){
TestComponent testComponent = new TestComponent();
testComponent.config.put("test", "ok");
return testComponent;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity.addFilterBefore(new TestCheckFilter(), ExceptionTranslationFilter.class).build();
}
@Bean
public FilterRegistrationBean<RequestResponseLoggingFilter> loggingFilter(TestComponent testComponent){
FilterRegistrationBean<RequestResponseLoggingFilter> registrationBean
= new FilterRegistrationBean<>();
registrationBean.setFilter(new RequestResponseLoggingFilter(testComponent));
registrationBean.addUrlPatterns("/*");
// security filter 보다 앞에 위치시킨다
registrationBean.setOrder(SecurityProperties.DEFAULT_FILTER_ORDER - 1);
return registrationBean;
}
}
// RequestResponseLoggingFilter.java
// 주의! @Component를 붙이는 경우 Filter로 등록되므로
// @Component를 붙이던, 설정 파일에서 추가하는 동작 중 한가지만 해야한다
@Slf4j
@RequiredArgsConstructor
public class RequestResponseLoggingFilter implements Filter {
private final TestComponent testComponent;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
log.info(testComponent.config.values().toString());
log.info(
"Starting a transaction for req : {}",
req.getRequestURI());
chain.doFilter(request, response);
log.info(
"Committing a transaction for req : {}",
req.getRequestURI());
chain.doFilter(request, response);
}
}
디버깅을 했을 때, RequestResponseLoggingFilter는 spring security와 무관하며, DelegatingFilterProxy에게 위임 받은 FilterChainProxy에 등록되어 있지 않습니다. 또한 빈의 값을 정상적으로 받은 것을 확인할 수 있습니다
ApplicationFilterChain에는 정상적으로 등록되어있다.
두번째. spring security에서 발생하는 SecurityException을 제외한 다른 익셉션이 발생하면 어떻게 될까?
AuthenticationException, AccessDeniedException을 제외한 Exception은 ExceptionTranslationFilter에서 걸러지지 않습니다. 또한 Exception 이 ExceptionTranslationFilter 앞에서 생긴다면, ExceptionTranslationFilter는 정상 동작하지 않습니다.
// ExceptionTranslationFilter.java
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
chain.doFilter(request, response);
}
catch (IOException ex) {
throw ex;
}
catch (Exception ex) {
// Try to extract a SpringSecurityException from the stacktrace
Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
.getFirstThrowableOfType(AuthenticationException.class, causeChain);
if (securityException == null) {
securityException = (AccessDeniedException) this.throwableAnalyzer
.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
}
if (securityException == null) {
rethrow(ex);
}
if (response.isCommitted()) {
throw new ServletException("Unable to handle the Spring Security Exception "
+ "because the response is already committed.", ex);
}
// AuthenticationException과 AccessDeniedException에 대해서만 처리
handleSpringSecurityException(request, response, chain, securityException);
}
}
security에 등록된 필터들은 다음 filter를 호출하기 위해 filterchain의 dofilter를 호출합니다. 따라서 filter들이 연쇄적으로 이어져 있는 구조가 만들어 집니다. 확인을 위해 2가지 case를 준비했습니다. 첫번째는 ExceptionTranslationFilter 앞에 Exception이 생기는 경우, 두번째는 ExceptionTranslationFilter 뒤에 Exception이 생기는 경우 입니다.
// TestCheckFilter..
public class TestCheckFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException {
throw new IOException("TestCheckFilter");
}
}
// TestSecurityConfiguration.java, ExceptionTranslationFilter 앞에 두는 경우
@Configuration
public class TestSecurityConfiguration {
...
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity.addFilterBefore(new TestCheckFilter(), ExceptionTranslationFilter.class).build();
}
...
}
// TestSecurityConfiguration.java, ExceptionTranslationFilter 뒤에 두는 경우
@Configuration
public class TestSecurityConfiguration {
...
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity.addFilterAfter(new TestCheckFilter(), ExceptionTranslationFilter.class).build();
}
...
}
앞에 두는 경우
뒤에 두는 경우
요약 하자면
spring security는 빈을 인식하기 위해 DelegatingFilterProxy를 사용하며, FilterChainProxy를 통해 순서대로 호출 합니다
spring boot의 경우 내장 톰켓이 실행전에 application context가 초기화 되기 때문에 filter를 빈으로 등록할 수도, application context를 참조 할 수 있습니다.
spring security filter chain은 각각의 filter로 보안 구성을 하며, filter들이 연쇄적으로 이어져있는 구조가 됩니다. 따라서 하위 체인에서 exception 이 발생하면 상위로 전파되지만, 상위에서 exception이 발생하면 하위로 전파되지 않습니다.