Servlet Application Security Archtecture
Servlet에서의 Spring Security은 Servlet Filter를 기반으로 한다.
Filter Chain
FilterChain: 필터 인스턴스와 서블릿의 집합
- Spring MVC에서 Servlet은 DispatcherServlet 하나로 HttpServletRequest와 HttpServletResponse를 처리할 수 있지만, 필터 체인을 통해 다운스트림 필터 인스턴스 또는 서블릿의 호출을 막거나, HttpServletRequest 또는 HttpServletResponse를 수정할 수 있다.
- 필터는 다운스트림 필터 인스턴스와 서블릿에만 영향을 미치기 때문에 각 필터가 호출되는 순서는 매우 중요하다.
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
chain.doFilter(request, response);
}
DelegatingFilterProxy: Spring ApplicationContext와 Servlet Container lifecycle을 연결한다.
즉, 서블릿 필터를 통해 서블릿 요청을 가로채서 Spring에서 빈으로 등록한 필터에 넘겨주는 역할이다.
- 서블릿 컨테이너는 자체 표준을 통해 필터 인스턴스를 등록할 수 있지만, 스프링에 정의된 빈을 인식할 수는 없다.
- 표준 서블릿 컨테이너의 매커니즘을 통해 DelegatingFilterProxy를 등록할 수 있지만, 필터를 구현하는 작업은 스프링 빈에게 위임한다.
- DelegatingFilterProxy는 ApplicationContext에서 Bean Filter를 검색한 다음 Bean Filter를 호출한다.
- 이때 DelegatingFilterProxy가 Bean Filter를 검색하고 등록하는 작업을 지연시킬 수 있다.
이유는 컨테이너 실행 시 필터 인스턴스를 등록해야 하는데, Spring에서 ContextLoaderListener를 통해 Spring Beans를 로드하는 과정이 필터 인스턴스가 등록해야하는 시점 이후에 이루어진다. 따라서 실제 필요한 순간에 검색하고 로딩된다.
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
Filter delegate = getFilterBean(someBeanName);
delegate.doFilter(request, response);
}
FilterChainProxy: 스프링 시큐리티의 서블릿은 스프링 시큐리티에서 제공하는 특수 필터인 FilterChainProxy를 포함한다.
- FilterChainProxy가 DelegatingFilterProxy에서 찾아서 등록한다는 Bean Filter 중 하나이며, 시큐리티 필터 체인에 위임하는 역할을 한다.
SecurityFilterChain: 시큐리티 필터 체인은 필터 체인 프록시에서 현재 요청에 대해 호출할 필터를 결정하기 위해 사용된다.
- SecurityFilterChain의 보안 필터는 빈이지만, DelegatingFilterProxy 대신 FilterChainProxy에 등록된다.
- SecurityFilterChain이 DelegatingFilterProxy나 서블릿 컨테이너에 직접 등록되면, 모든 서블릿에 대한 시작점을 제공하여 디버깅이 용이하고, 시큐리티 사용의 중심으로써 메모리 누수를 방지하기 위해 SecurityContext를 지우고 Http 방화벽을 적용하여 애프리케이션을 보호할 수 있다.
- SecurityFilterChain을 호출하는 시기를 결정함에 더 많은 유연성을 제공한다.
서블릿 컨테이너의 필터는 URL 기반으로만 호출되지만, FilterChainProxy는 RequestMatcher 인터페이스를 사용해서 HttpServletRequest의 모든 항목을 기반으로 호출할 수 있다.
@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();
}
}
보안 필터는 위와 같이 SecurityFilterChain API를 통해 FilterChainProxy에 삽입된다.
위 코드에서는 .csrf, .authorizeHttpRequest, .httpBasic, .formLogin이 각각의 필터로 삽입된다.
이때 필터는 순서대로 동작하기 때문에 인증 필터는 권한 부여 필터 보다 앞에 있어야한다.
CustomFilter: Filter 인터페이스를 구현해서 사용자 정의 필터를 생성하고 FilterChain에 추가할 수 있다.
- 요청 당 한 번만 호출되는 OncePerRequestFilter를 상속하는 방법도 있다.
- 필터를 Spring Bean으로 선언할 경우 컨테이너에서 한 번, 시큐리티에서 한번 총 2번 호출될 수 있기 때문에 중복을 방지하기 위해 FilterRegistration Bean을 선언하고 enable 옵션을 false로 지정해줘야한다.
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;
String tenantId = request.getHeader("X-Tenant-Id");
boolean hasAccess = isUserAllowed(tenantId);
if (hasAccess) {
filterChain.doFilter(request, response);
return;
}
throw new AccessDeniedException("Access denied");
}
}
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ...
.addFilterBefore(new TenantFilter(), AuthorizationFilter.class);
return http.build();
}
@Bean
public FilterRegistrationBean<TenantFilter> tenantFilterRegistration(TenantFilter filter) {
FilterRegistrationBean<TenantFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false);
return registration;
}
Exception Handling
ExceptionTranslationFilter를 사용하면 AccessDeniedException 및 AuthenticationException을 HTTP 응답으로 변환할 수 있다.
ExceptionTranslationFilter는 Security Filter 중 하나로 FilterChainProxy에 삽입된다.
- Application에서 AccessDenedException 또는 AuthenticationException을 던지면 예외 핸들링이 시작되며 SecurityContextHolder가 지워진다.
- 인증 정보가 없을 경우 로그인 페이지로 리디렉션하거나 WWW-Authenticate 헤더(401)를 보낼 수 있다.
- 인증되지 않은 것이 아닌 잘못된 인증을 보낸 경우 AccessDenied 예외가 발생하여 Handler에 의해 처리된다.
//pseudocode example
try {
filterChain.doFilter(request, response);
} catch (AccessDeniedException | AuthenticationException ex) {
if (!authenticated || ex instanceof AuthenticationException) {
startAuthentication();
} else {
accessDenied();
}
}
인증 요청 간 저장
요청에 인증 정보가 없을 경우 인증 성공 후에 인증된 리소스에 대한 요청을 저장해야한다. 이때 위 Exception Handling 사진에 있는RequestCache를 구현하여 HttpServletRequest를 저장한다.
기본적으로 HttpSessionRequestCache를 사용하며, 아래 예시에서는 HttpSession을 검사해서 "continue"가 포함된 요청에 대해 RequestCache를 저장하는 코드이다.
RequestCacheAwareFilter는 RequestCache를 사용해서 HttpServletRequest를 저장한다.
@Bean
DefaultSecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
requestCache.setMatchingRequestParameterName("continue");
http
// ...
.requestCache((cache) -> cache
.requestCache(requestCache)
);
return http.build();
}
인증되지 않은 사용자 정보를 RequestCache를 비활성화 할 때는 NullRequestCache를 사용할 수 있다.
@Bean
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
RequestCache nullRequestCache = new NullRequestCache();
http
// ...
.requestCache((cache) -> cache
.requestCache(nullRequestCache)
);
return http.build();
}
Logging
Security는 DEBUG, TRACE 레벨에서 모든 보안 관련 이벤트의 포괄적인 로깅을 제공한다.
Boot에서는 아래와 같이 설정할 수 있으며, XML 설정법도 공식 문서에 잘 나와있다.
그리고 서버 실행 시에 INFO 레벨의 로그로 Filter List를 볼 수 있다.
logging.level.org.springframework.security=TRACE
//Security 로그 예시
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]
//서버 실행 시 Filter 리스트 출력 예시
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]
'개발 > Spring' 카테고리의 다른 글
JWT + Spring Security 환경의 Controller 테스트에 인증 정보 주입하기 (0) | 2024.03.26 |
---|---|
Spring Security Authentication Architecture (feat. 6.2.2ver) (0) | 2024.02.21 |
JPA에서 Entity는 고유한데, Collection Type으로 Set이 아닌 List를 쓰는 이유 (0) | 2024.02.13 |
[JSP] Servlet과 Spring MVC (0) | 2024.02.07 |
Spring Boot html 파일 위치 (0) | 2024.02.02 |