💡 Spring Secuirty 6.3.3의 Spring Security/Servlet Applications 문서를 기준으로 작성하였습니다
Filter 리뷰
Spring Security는 Servlet Filter를 기반으로 Servlet을 지원하므로, Filter에 대한 일반적인 역할을 먼저 보는 것이 유용합니다. 다음 사진은 하나의 HTTP 요청에 대한 핸들러들의 일반적인 레이어링을 나타냅니다.
클라이언트가 어플리케이션에 요청을 보내면, Spring Container는 요청 URI에 기반하여 FilterChain을 생성합니다. FilterChain은 Filter 인스턴스와 HttpServletRequest를 처리하는 Servlet을 포함합니다.
스프링 MVC 어플리케이션에서, Servlet은 DispatcherServlet의 인스턴스입니다. 일반적으로 Servlet은 하나의 HttpServletRequest와 HttpServletResponse를 다룹니다다.
그러나, 둘 이상의 필터는
- 다운스트림 필터 인스턴스나 서블렛이 실행되는 것을 막습니다. 이를 수행한 필터는 일반적으로 HttpServletResponse를 작성합니다.
- 다운스트림 필터 인스턴스나 서블렛이 사용할 HttpServletRequest나 HttpServletResponse를 수정할 수 있습니다.
한 필터는 이후에 올 필터 인스턴스와 서블렛에 대해서만 영향을 끼치기 때문에, 각각의 필터가 실행되는 순서는 매우 중요합니다.
DelegatingFilterProxy
스프링은 DelegatingFilterProxy라는 필터 implementation을 제공합니다. DelegatingFilterProxy는 서블렛 컨테이너의 생명주기와 스프링의 ApplicationContext를 연결합니다. 서블렛 컨테이너는 고유한 기준으로 필터 인스턴스의 등록을 허가해주는데, 그것이 스프링에서 정의된 빈으로 인지되지는 않습니다. 표준 Servlet 컨테이너 메커니즘을 통해 DelegatingFilterProxy를 등록할 수도 있지만, 이 모든 작업을 필터를 구현하는 Spring Bean에 위임할 수 있습니다.
다음 그림은 어떻게 DelegatingFilterProxy가 Filter 인스턴스와 FilterChain에 들어가는지를 나타냅니다.
DelegatingFilterProxy는 ApplicationContextBeanFilter0를 찾아서 이를 실행합니다. 다음은 DelegationFilterProxy의 의사 코드입니다.
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain chain) {
// Spring Bean으로 등록된 필터를 lazy get 합니다.
Filter delegate = getFilterBean(someBeanName);
// 해당 Spring bean에게 작업을 위임합니다.
delegate.doFilter(request, response);
}
DelegatingFilterProxy의 또다른 중요한 장점은 Filter 빈 인스턴스를 지연 탐색한다는 것입니다. 컨테이너는 컨테이너의 시작 이전에 필터 인스턴스를 등록해야 합니다. 그러나, 스프링은 주로 ContextLoaderListener를 사용하여 스프링 빈을 로딩하는데, 이는 필터 인스턴스가 저장되어야 하는 시점까지 끝나지 않습니다.
FilterChainProxy
스프링 시큐리티의 서블릿 지원은 FilterChainProxy에 포함되는 개념입니다. FilterChainProxy는 스프링 시큐리티가 지원하는 특별한 필터로, 여러 필터 인스턴스들이 SecurityFilterChain에 위임할 수 있도록 합니다. FilterChainProxy가 빈이므로, 일반적으로 DelegatingFilterProxy로 감싸져있습니다.
다음 사진은 FilterChainProxy의 역할을 보여줍니다.
SecurityFilterChain
SecurityFilterChain은 FilterChainProxy가 현재 요청에 대해 어떤 Spring Security 필터 인스턴스를 사용할지를 결정하기 위해 사용합니다.
다음은 SecurityFilterChain의 역할을 보여주는 그림입니다.
일반적으로 SecurityFilterChain의 Security Filter들은 빈이지만, DelegatingFilterProxy가 아닌 FilterChainProxy를 통해 등록됩니다. FilterChainProxy는 서블릿 컨테이너나 DelegatingFilterProxy를 이용해서 직접 등록하는 것보다 더 많은 이점을 제공합니다. 첫번째로, FilterChainProxy는 모든 스프링 시큐리티의 서블렛 지원에게 starting point를 제공한다. 이러한 이유로, 스프링 시큐리티의 서블렛 지원에 대한 트러블 슈팅을 할 때는 FilterChainProxy에 debug point를 추가하는 것 부터 시작하는 것이 좋습니다.
두 번째로, FilterChainProxy가 스프링 시큐리티 사용의 중심이기 때문에, 옵션이 아닌 것으로 간주되는 과정을 수행할 수 있습니다. 예를 들어, FilterChainProxy는 SecurityContext를 정리하여 메모리 누수를 방지하고, 스프링 시큐리티의 HttpFirewall을 적용하여 특정 공격들로부터 어플리케이션을 보호합니다.
또한, FilterChainProxy는 SecurityFilterChain이 언제 수행될지를 결정하는 것에 있어 더 많은 선택지를 제공합니다. 서블릿 컨테이너에서, 필터 인스턴스들은 URL에 기반해서만 실행되게 됩니다. 하지만, FilterChainProxy는 RequestMatcher 인터페이스를 이용하여 HttpServletRequest에 있는 어떤 것이든지 이에 따라 실행 여부를 결정할 수 있습니다.
다음 이미지는 여러 개의 SecurityFilterChain 인스턴스들을 나타냅니다.
위의 여러 개의 SecurityFilterChain 구조도에서, FilterChainProxy는 어떤 SecurityFilterChain이 사용될지를 결정합니다. 매치되는 첫번째 SecurityFilterChain만이 수행되게 됩니다. 만약 /api/messages/라는 URL이 요청되면, FilterChainProxy는 /api/** 패턴을 갖는 SecurityFilterChain0부터 매칭을 하게 되고, 따라서 SecurityFilterChain_n과도 매칭됨에도 불구하고 SecurityFilterChain0만이 수행됩니다.
SecurityFilterChain0이 단지 3개의 필터 인스턴스로 구성된 것을 볼 수 있습니다. 반면, SecurityFilterChain_n 는 4개의 필터 인스턴스로 구성되어 있습니다. 각각의 SecurityFilterChain은 고유하고 독립적으로 구성될 수 있다는 것이 중요합니다. 실제로 어플리케이션이 스프링 시큐리티가 특정 요청을 무시하기를 원한다면 SecurityFilterChain은 0개의 필터 인스턴스를 가질 수도 있습니다.
Security Filters
시큐리티 필터는 SecurityFilterChain API를 통해 FilterChainProxy에 추가될 수 있습니다. 이 필터들은 인증, 인가, 취약점 공격 방지 등 각기 다른 다양한 목적으로 사용될 수 있습니다. 필터들은 제때에 실행되는 것을 보장하기 위해 정해진 순서대로 실행됩니다. 예를 들어, 인증을 수행하는 필터는 인가를 수행하는 필터 이전에 실행되어야 합니다. 스프링 시큐리티의 필터들의 순서들은 일반적으로 꼭 알아야 할 필요는 없습니다. 그러나, 이 순서를 아는 것이 이득인 경우가 있으므로, 알고싶다면 FilterorderRegistration 코드를 확인하면 됩니다.
다음 시큐리티 구성을 보면서 예시를 들어 보겠습니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(Customizer.withDefaults())
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults())
.formLogin(Customizer.withDefaults());
return http.build();
}
}
위의 구성은 다음과 같은 필터 순서를 갖게 됩니다.
- 첫째로, CSRF 공격을 방지하기 위해 CsrfFilter가 실행됩니다.
- 둘째로, 요청을 인증하기 위해 인증 필터들이 실행됩니다.
- 셋째로, 요청을 인가하기 위해 AuthorizationFilter가 실행됩니다.
📒 Note
위의 예시에 나타나지 않는 필터가 있을 수 있습니다. 특정 요청에 대해 실행되는 필터의 리스트를 보고싶다면, 다음을 이용하여 출력해볼 수 있습니다.
Printing the Security Filters
특정 리퀘스트에 대해 실행되는 시큐리티 필터의 리스트를 보는 것이 유용할 때가 있습니다. 예를 들어, 추가한 필터가 시큐리티 필터 리스트에 있는지 확인할 수도 있습니다.
필터들의 리스트는 어플리케이션이 시작될 때 INFO 레벨에서 출력되어 콘솔에서 다음과 같은 내용을 볼 수 있습니다.
2023-06-14T08:55:22.321-03:00 INFO 76975 --- [ main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [
org.springframework.security.web.session.DisableEncodeUrlFilter@404db674,
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@50f097b5,
org.springframework.security.web.context.SecurityContextHolderFilter@6fc6deb7,
org.springframework.security.web.header.HeaderWriterFilter@6f76c2cc,
org.springframework.security.web.csrf.CsrfFilter@c29fe36,
org.springframework.security.web.authentication.logout.LogoutFilter@ef60710,
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@7c2dfa2,
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@4397a639,
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@7add838c,
org.springframework.security.web.authentication.www.BasicAuthenticationFilter@5cc9d3d0,
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@7da39774,
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@32b0876c,
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@3662bdff,
org.springframework.security.web.access.ExceptionTranslationFilter@77681ce4,
org.springframework.security.web.access.intercept.AuthorizationFilter@169268a7]
Adding a Custom Filter to the Filter Chain
대부분의 경우, 당신의 어플리케이션에 보안성을 추가하기 위해 기본 시큐리티 필터로 충분할 것입니다. 그러나, 시큐리티 필터 체인에 커스텀 필터를 추가해야 할 경우가 있기도 합니다.
예를 들어, 테넌트 아이디 헤더를 얻어 현재 접속한 유저가 해당 테넌트에 접근할 수 있는지를 확인하는 필터를 추가한다고 생각해 봅시다. 방금의 설명은 우리가 어디에 필터를 추가해야 하는지에 대한 힌트를 제공합니다. 우리가 현재 접속한 유저를 알아야 할 필요가 있기 때문에, 인증 필터 이후에 이 필터를 추가해야 합니다.
먼저, 필터를 생성해 보겠습니다.
import java.io.IOException;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
// 요청 헤더로부터 tenant id를 얻는다.
String tenantId = request.getHeader("X-Tenant-Id"); (1)
// 현재 유저가 테넌트 아이디에 대해 접근할 수 있는지를 확인한다.
boolean hasAccess = isUserAllowed(tenantId); (2)
// 유저가 접근할 수 있을 때, 체인의 나머지 필터를 실행한다.
if (hasAccess) {
filterChain.doFilter(request, response); (3)
return;
}
// 유저가 접근할 수 없다면, AccessDeniedException을 throw한다.
throw new AccessDeniedException("Access denied"); (4)
}
}
💡 TIP
필터를 추가하는 대신, 각 요청당 한번만 실행되고 HttpServlet과 HttpServletResponse 인수를 갖는 doFilterInternal을 제공하는 필터들의 base class인 OncePerRequestFilter를 extend하여 사용할 수 있습니다.
이제, 생성한 필터를 시큐리티 필터 체인에 추가하겠습니다.
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ...생략
// HttpSecurity#addFilterBefore를 사용해서 AuthorizationFilter 이전에
// TenantFilter를 추가한다.
.addFilterBefore(new TenantFilter(), AuthorizationFilter.class);
return http.build();
}
생성한 필터를 AuthorizationFilter 이전에 추가함으로써 인증 필터 이후에 TenantFilter가 실행되도록 할 수 있습니다. HttpSecurity#addFilterAfter를 사용해서 특정 필터 이후에 필터를 추가하거나, HttpSecurity#addFilterAt을 사용해서 필터를 특정 필터체인의 특정 필터 위치에 추가할 수도 있습니다.
그리고 이렇게 TenantFilter는 필터체인 내에서 수행되어, 현재 접속한 유저가 테넌트 아이디에 접근할 수 있는지를 체크할 것입니다.
@Component 어노테이션을 사용하거나 configuration에 빈으로 선언하는 방법으로 당신의 필터를 스프링 빈으로 선언할 때, 스프링부트가 자동으로 embedded container로 등록할 수 있으므로 조심해야 합니다. 이는 필터를 한번은 컨테이너에 의해, 한번은 Spring Security에 의해 서로 다른 순서로 두번 실행하는 원인이 될 수 있습니다.
@Bean
public FilterRegistrationBean<TenantFilter> tenantFilterRegistration(TenantFilter filter) {
FilterRegistrationBean<TenantFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false);
return registration;
}
Handling Security Exception
ExceptionTranslationFilter는 AccessDeniedException과 AuthenticationException을 HTTP 응답으로 변환합니다.
ExceptionTranslationFilter는 Security Filter의 한 종류로 FilterChainProxy에 추가됩니다.
다음 이미지는 ExceptionTranslationFilter와 다른 요소들의 연관관계를 보여줍니다.
- 먼저, ExceptionTranslationFilter는 어플리케이션의 나머지를 실행하기 위해 FilterChain.doFilter(request, response)를 실행합니다.
- 만약 유저가 인증이 안되어있거나 AuthenticationException이 일어나면, 인증을 시작합니다.
- SecurityContextHolder를 지웁니다.
- 인증이 성공하면 원래의 요청을 다시 처리하기 위해 HttpServlet을 저장합니다.
- 클라이언트의 자격 증명을 요청하는 데 AuthenticationEntryPoint를 사용합니다. 예를 들어, 로그인 페이지로 리다이렉트 하거나 WWW-Authenticate 헤더를 전송합니다.
- 만약 AccessDeniedException가 발생하면 액세스가 거부됩니다. 액세스 거부를 처리하기 위해 AccessDeniedHandler가 실행됩니다.
📖 NOTE
만약 어플리케이션이 AccessDeniedException나 AuthenticationException을 발생시키지 않으면 ExceptionTranslationFilter는 아무것도 하지 않습니다.
ExceptionTranslationFilter의 의사 코드는 다음과 같습니다.
try {
filterChain.doFilter(request, response);
} catch (AccessDeniedException | AuthenticationException ex) {
if (!authenticated || ex instanceof AuthenticationException) {
startAuthentication();
} else {
accessDenied();
}
}
- 상단의 Filter 리뷰에서 설명한 것처럼, FilterChain.doFilter(request, response)를 실행하는 것은 어플리케이션의 나머지 부분을 실행한다는 것이다. 이는 만약 어플리케이션의 다른 부분(FilterSecurityInterceptor나 method security)가 AuthenticationException이나 AccessDeniedException을 발생시키면, catch되어 여기에서 처리됩니다.
- 만약 이용자가 인증되지 않거나 AuthenticationException이 발생하면, 인증을 시작합니다.
- 그 외의 경우, 액세스가 거부됩니다.
Saving Requests Between Authentication
Handling Security Exceptions에서 설명한 것 처럼, 요청이 인증이 없거나 인증을 필요로 하는 자원에 대한 요청이라면, 인증이 성공했을 때 재요청을 위해 인증된 자원에 대한 요청을 저장해야 합니다. Spring Security에서는 RequestCache를 이용해 HttpServletRequest를 저장함으로써 이를 수행합니다.
RequestCache
HttpServletRequest는 RequestCache에 저장된다. 유저가 인증에 성공하면, RequestCache는 원본 요청을 다시 처리하기 위해 사용됩니다. RequestCacheAwareFilter는 유저 인증 이후 RequestCache를 이용하여 저장된 HttpServletRequest를 가져옵니다. 반면, ExceptionTranslationFilter는 AuthenticationException을 인지하면 유저를 로그인 엔드포인트로 리다이렉트 하기 전에 RequestCache를 사용해서 HttpServlet을 저장합니다.
기본적으로, HttpSessionRequestCache가 사용됩니다. 아래의 코드는 Continue 변수가 존재하는 경우 저장된 리퀘스트가 있는지 HttpSession을 확인하기 위해 사용되는 RequestCache를 커스텀하는 방법을 보여줍니다.
@Bean
DefaultSecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
requestCache.setMatchingRequestParameterName("continue");
http
// ...
.requestCache((cache) -> cache
.requestCache(requestCache)
);
return http.build();
}
Prevent the Request From Being Saved
유저의 인증되지 않은 요청을 세션에 저장하지 않게 하고 싶은 경우가 있을 수 있습니다. 해당 스토리지를 사용자의 브라우저에서 오프로드하거나 데이터베이스에 저장할 수도 있습니다. 이용자들이 로그인 이전이라면 그들이 원하는 페이지 대신 항상 홈페이지로 리다이렉트하기 위해 이 기능 자체를 삭제할 수도 있습니다.
이를 위해서는 NullRequestCache의 구현체를 사용할 수 있습니다.
@Bean
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
RequestCache nullRequestCache = new NullRequestCache();
http
// ...
.requestCache((cache) -> cache
.requestCache(nullRequestCache)
);
return http.build();
}
RequestCacheAwareFilter
RequestCacheAwareFilter는 RequestCache를 이용해서 원본 요청을 재수행합니다.
Logging
Spring Security는 보안과 연관된 모든 이벤트들에 대해 DEBUG와 TRACE 레벨에서 포괄적인 로깅을 제공합니다. Spring Security가 보안을 위해 응답 본문에 요청이 거부된 이유에 대한 세부 정보를 추가하지 않기 때문에, 이 로깅은 어플리케이션을 디버깅할 때 유용하게 사용할 수 있습니다. 401이나 403 에러가 발생했을 때, 무슨 일이 일어났는지 로그 메시지를 보게 될 확률이 매우 높습니다.
유저가 CSRF 토큰 없이 CSRF 보호가 적용된 자원에 대해 POST 요청을 보내려고 한다고 가정해 봅시다. 로그 없이는 유저는 요청이 왜 거부되었는지에 대한 아무런 설명 없이 403 에러를 보게 될 것입니다. 그러나, 만약 당신이 Spring Security의 Logging을 설정해 두었다면 다음과 같은 에러를 볼 수 있을 것입니다.
2023-06-14T09:44:25.797-03:00 DEBUG 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy : Securing POST /hello
2023-06-14T09:44:25.797-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy : Invoking DisableEncodeUrlFilter (1/15)
2023-06-14T09:44:25.798-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/15)
2023-06-14T09:44:25.800-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderFilter (3/15)
2023-06-14T09:44:25.801-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy : Invoking HeaderWriterFilter (4/15)
2023-06-14T09:44:25.802-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.security.web.FilterChainProxy : Invoking CsrfFilter (5/15)
2023-06-14T09:44:25.814-03:00 DEBUG 76975 --- [nio-8080-exec-1] o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for <http://localhost:8080/hello>
2023-06-14T09:44:25.814-03:00 DEBUG 76975 --- [nio-8080-exec-1] o.s.s.w.access.AccessDeniedHandlerImpl : Responding with 403 status code
2023-06-14T09:44:25.814-03:00 TRACE 76975 --- [nio-8080-exec-1] o.s.s.w.header.writers.HstsHeaderWriter : Not injecting HSTS header since it did not match request to [Is Secure]
메시지를 통해 CSRF 토큰이 없고, 이로 인해 요청이 거부된 것을 확실히 알 수 있습니다.
당신의 어플리케이션이 모든 시큐리티 이벤트들의 로그를 남길 수 있도록 구성하기 위해서는 다음을 어플리케이션에 추가하면 됩니다.
# application.properties in Spring Boot
logging.level.org.springframework.security=TRACE