Spring Security Servlet Applications Architecture (feat. 6.2.2ver)

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]