개발하는 자몽

Spring Security 6 - Architecture 본문

Java & Kotlin/Spring

Spring Security 6 - Architecture

jaamong 2024. 6. 7. 20:00

Spring Security 6 - Architecture 공식 문서를 번역했습니다. 필요에 의해 설명을 추가한 부분도 있습니다.

 

Architecture :: Spring Security

The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more. The filters are executed in a spec

docs.spring.io

 

 

 

🔖목차🔖


 

 

 

 

Spring Security Architecture

Spring Security는 인증, 검증, 권한 부여를 편리하게 할 수 있도록 도와주는 Spring Framework의 라이브러리이다. 

 

Filter

Spring Security는 Spring MVC의 Dispatcher Servlet 보다 앞인 Servlet Container 내부에 존재하는 Filter에서 시작한다. 

Servlet Container를 보면 Filter가 Dispatcher Servlet 보다 앞에 위치하고 있다.

 

따라서 Filter의 역할을 먼저 찾아보는 것이 이해에 도움 된다. 아래 그림은 단일 HTTP 요청에 대한 핸들러의 일반적인 계층을 보여준다. 

 

FilterChain

클라이언트가 애플리케이션으로 요청을 보내면 컨테이너는 요청 URI 경로를 따라 HttpSevletRequest를 처리해야 하는 필터 인스턴스와 서블릿을 포함하는 FtilerChain을 생성한다.

하나 이상의 Filter를 사용하여 다음을 할 수 있다.

 

  • 다운 스트림 필터 인스턴스(Downstream Filter Instance) 또는 서블릿이 호출되지 않도록 한다. 이 경우 필터는 일반적으로 HttpServletResponse를 작성한다.
  • 다운 스트림 필터 인스턴스 및 서블릿에서 사용하는 HttpServletRequest 또는 HttpServletResponse를 수정한다.

 

Note 자신보다 먼저 호출되는 필터를 Upstream Filter, 나중에 호출되는 필터를 Downstream Filter라고 한다.

 

 

여러 개의 필터가 연결되어 이루어진 FilterChain은 서블릿 컨테이너 내부에서 클라이언트의 요청이 서블릿에 도달하기 전에 인가 처리, 권한 부여, 요청 로깅, 예외 처리 등과 같은 작업을 처리한다.

 

아래 코드는 FilterChain 예제이다. 모든 필터를 통과하고 애플리케이션에서 할 일을 마친 후 바로 클라이언트로 응답이 가는 것이 아닌, 다시 필터를 통해서 돌아가는 것을 알 수 있다.

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
    // do something before the rest of the application
    chain.doFilter(request, response); // invoke the rest of the application
    // do something after the rest of the application
}

 

필터는 다운 스트림 필터 인스턴스와 서블릿에만 영향을 미치므로 각 필터가 호출되는 순서는 매우 중요하다.

 

DelegatingFilterProxy

Spring은 서블릿 컨테이너 생명 주기와 Spring의 ApplicationContext 사이를 연결해 주는 DelegatingFilterProxy라는 필터 구현체를 제공한다. 일반적인 필터들은 Spring에서 정의된 Bean을 인지할 수 없지만, DelegatingFilterProxy는 필터이면서 Bean에게 작업을 위임할 수 있다.

 

 

아래 코드는 DelegatingFilterProxy의 의사 코드(pseudo code)이다.

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
    Filter delegate = getFilterBean(someBeanName);
    delegate.doFilter(request, response);
}

위 코드의 첫 줄은 Bean으로 등록된 필터를 Lazy하게 가져오는 작업을 수행한다. DelegatingFilterProxy는 필터 빈 인스턴스 조회를 지연시킬 수 있는 장점을 갖고 있다.

필터 인스턴스는 컨테이너가 시작되기 전에 등록되어야 한다. 그러나 Spring은 일반적으로 ContextLoadListener를 사용하여 Spring Bean을 로드하는데, 이는 필터 인스턴스를 등록한 후에 수행되므로 필터 빈 인스턴스 조회를 지연시킬 수 있다. 

 

FilterChainProxy

SpringSecurity가 제공하는 FilterChainProxySecurityFilterChain을 통해 많은 필터 인스턴스에 위임할 수 있게 하는 필터이다. 또한 FilterChainProxyBean이기 때문에 일반적으로 DelegatingFilterProxy로 감싸지게 된다.

 

FilterChainProxy

 

SecurityFilterChain

FilterChainProxy는 현재 요청에 대해 호출되어야 하는 Spring Security 필터 인스턴스를 결정하기 위해 SecurityFilterChain을 사용한다. 

 

SecurityFilterChain

SecurityFilterChain 내부의 Security Filter들은 일반적으로 Bean이지만, DelegatingFilterProxy가 아닌 FilterChainProxy에 등록된다. FilterChainProxy는 서블릿 컨테이너 또는 DelegatingFilterProxh에 직접 등록하는 것보다 여러 가지 이점을 제공한다. 

 

  • Spring Security가 서블릿 기반으로 제공하는 모든 기능의 시작점이므로, 트러블 슈팅 시 FilterChainProxy에 디버그 포인트를 추가하기 좋다.
  • FilterChainProxy는 Spring Security 사용의 핵심이다. 이는 SecurityContext를 초기화하여 메모리 누수를 피하거나, HttpFirewall을 적용하여 특정 유형의 공격으로부터 애플리케이션을 보호하도록 한다.
  • SecurityFilterChain이 언제 호출되어야 하는지 보다 유연하게 결정할 수 있도록 한다. 서블릿 컨테이너 안에서 필터 인스턴스는 URL만을 기반으로 호출되지만, FilterChainProxyRequestMatcher 인터페이스를 사용하여 HttpServletRequest의 모든 것을 기반으로 호출을 결정할 수 있다. 

 

다중 SecurityFilterChain

FilterChainProxy는 멤버 변수로 List<SecurityFliterChain> filterChains를 가직 ㅗ있어 여러 개의 SecurityFilterChain을 모두 저장할 수 있으며, 현재 요청에 따라 어떤 SecurityFilterChain을 사용할지 결정한다(위 FilterChainProxy의 이점 3번).

 

아래 그림을 보면, SecurityFilterChain 0은 총 3개의 필터로, SecurityFilterChain n은 4 개의 필터로 구성되어 있는 것을 확인할 수 있다. 이를 통해 각 SecurityFilterChain은 유니크하고 독립적으로 구성될 수 있음을 알 수 있다. SecurityFilter를 통해 검사할 필요가 없는 요청에 대해서는 0개의 필터로 구성된 SecurityFilterChain을 적용할 수 있다. 

 

Multiple SecurityFilterChain

 

아래 코드는 실제 Spring Security의 FilterChainProxy 코드 중 일부이다.

private List<Filter> getFilters(HttpServletRequest request) {
        int count = 0;
        Iterator var3 = this.filterChains.iterator();

        SecurityFilterChain chain;
        do {
            if (!var3.hasNext()) {
                return null;
            }

            chain = (SecurityFilterChain)var3.next();
            if (logger.isTraceEnabled()) {
                ++count;
                logger.trace(LogMessage.format("Trying to match request against %s (%d/%d)", chain, count, this.filterChains.size()));
            }
        } while(!chain.matches(request));

        return chain.getFilters();
    }

while문의 조건을 보면, 요청과 일치하는 SecurityFilterChain의 차례가 되면 반복문이 종료되는 것을 확인할 수 있다. 이를 통해 가장 먼저 매칭되는 단 하나의 SecurityFilterChain만이 호출됨을 알 수 있다. 위에서 언급한 것처럼 순서가 매우 중요하다는 의미이다.

위 그림을 다시 보면, SecurityFilterChain 0/api/**에 대해, SecurityFilterChain n/**에 매칭되도록 설정되어 있다. 예를 들어, /api/message/ URL이 요청되면 SecurityFilterChain 0이 가장 처음으로 매칭되므로 SecurityFilterChain 0만이 호출되어 이를 통한 검증이 이루어지게 된다.

 

Security Filters

Security FilterSecurityFilterChain API를 사용하여 FilterChainProxy에 삽입된다 Security Filter는 다양한 목적(인증, 인가, 악용 방지 등)으로 사용될 수 있으며, 적시에 호출되도록 특정 순서로 실행된다.

예를 들어, 인증(Authentication)을 수행하는 필터는 권한 부여(Authorization)를 수행하는 필터보다 호출되어야 한다. 두 필터가 반대의 순서로 호출되면 정상적인 작동을 보장할 수 없다.

 

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(Customizer.withDefaults()) //CsrfFilter
            .authorizeHttpRequests(authorize -> authorize //AuthorizationFilter
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults()) //BasicAuthenticationFilter
            .formLogin(Customizer.withDefaults()); //UsernamePasswordAuthenticationFilter
        return http.build();
    }

}

위와 같이 설정할 경우, 아래와 같은 순서대로 필터가 실행될 것이다.

 

  • CSRF 공격에 대항하여 CsrfFilter가 호출된다.
  • 요청을 인증하기 위해 인증 필터(authentication filters)가 호출된다.
  • 요청을 인가하기 위해 AuthorizationFilter가 호출된다.

 

Security Filter 출력하기

추가한 필터가 Security Filter 목록에 있는지 확인하고 싶을 때와 같이, 특정 요청에 대해 호출되는 Security Filter 목록 확인이 필요할 때가 있다.

필터 목록은 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]

뿐만 아니라 각 요청에 대해 각 필터의 호출을 출력하도록 애플리케이션을 구성할 수도 있다. 이렇게 하면 추가한 필터가 특정 요청에 대해 호출되는지 확인하거나, 예외가 발생하는 위치를 찾는 데 유용하다. 이를 위해 보안 이벤트(security event)를 기록하도록 애플리케이션을 구성할 수 있다.

 

Filter Chain에 사용자 정의 필터 추가하기

대부분 기본 필터로도 충분하지만, 커스텀/사용자 정의 필터가 필요할 수도 있다. 예를 들어 tenant id header를 가져오는 필터를 추가하여 현재 사용자가 해당 tenant에 액세스 할 수 있는지 확인하려고 한다고 가정해 보자. 이전 설명에서 이미 필터를 추가할 위치에 대한 단서를 제공했는데, 현재 사용자를 알아야 하므로 인증 필터(authentication filter) 다음에 해당 기능을 수행하는 필터를 추가해야 한다.

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;

        String tenantId = request.getHeader("X-Tenant-Id"); (1)
        boolean hasAccess = isUserAllowed(tenantId); (2)
        if (hasAccess) {
            filterChain.doFilter(request, response); (3)
            return;
        }
        throw new AccessDeniedException("Access denied"); (4)
    }

}
  1. request header에서 tenant id를 가져온다.
  2. 현재 사용자가 가져온 tenant id에 대해 접근 권한이 있는지 확인한다.
  3. 권한이 있으면, chain에 남은 필터들을 호출한다.
  4. 권한이 없으면, AccessDeniedException을 던진다.

 

Note Filter를 구현하는 대신 OncePerRequestFilter를 구현해도 된다. 이는 요청 당 한 번만 호출되는 필터의 베이스 클래스이며, HttpServletRequestHttpServletResponse 매개 변수와 함께 doFilterInternal 메서드를 제공한다.

 

 

이제 위 필터를 security filter chain에 등록해야 한다.

HttpSecurity#addFilterBefore를 사용하여 사용자 정의 필터인 TenantFilterAuthorizationFilter 앞에 등록한다.

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        // ...
        .addFilterBefore(new TenantFilter(), AuthorizationFilter.class);
    return http.build();
}

TenantFilter가 인증 필터 뒤에 호출되도록 AuthorizationFilter 앞에 해당 필터를 등록했다.

HttpSecurity#addFilterAfter를 사용하여 특정 필터 뒤에 호출되도록 하거나 HttpSecurity#addFilterAt을 사용하여 filter chain의 특정 필터 위치에 필터를 등록할 수 있다.

 

Filter를 구현한 사용자 정의 필터를 @Bean 또는 @Component을 사용하여 Spring Bean으로 등록할 때 주의해야 할 점이 있다.

Spring Boot는 해당 커스텀 필터를 자동으로 임베디드 컨테이너에 등록한다. 이로 인해 필터가 다른 순서로 두 번 호출되게 할 수도 있다; 컨테이너에 의해 한 번, Spring Security에 의해 한 번.

하지만 해당 커스텀 필터가 의존관계 주입이 필요한 경우에는 Spring Bean으로 명시적으로 선언해야 한다. 이는 위에서 언급한 Security FilterSpring Bean 사이의 초기화 순서와 연관되어 있다.

 

  1. Spring Security Filter 초기화
    FilterOncePerRequestFilter를 구현한 커스텀 필터는 메인 애플리케이션 컨텍스트가 완전히 로드되고 모든 bean이 인스턴스화되기도 전에 애플리케이션 시작 프로세스에서 매우 일찍 초기화되고 등록된다. 
  2. Spring Bean 초기화
    커스텀 필터가 필요로 하는 의존관계를 포함한 Spring Beansecurity filter가 등록된 후 시작 프로세스 후반에 초기화되고 인스턴스화된다.
  3. 의존관계 주입
    커스텀 필터가 의존관계 주입을 필요로 하면 Spring은 의존관계 주입 없이는 필터를 자동으로 인스턴스화하고 등록할 수 없다. 이는 Spring Security가 해당 필터를 초기화하고 등록할 때 아직 의존 관계는 사용할 수 없기 때문이다.
  4. Spring Bean으로 등록하기
    커스텀 필터를 Spring Bean으로 명시적으로 등록하면, Spring은 커스텀 필터 bean의 생명 주기를 관리하고 필터의 의존 관계를 올바르게 해결할 수 있게 된다. 메인 애플리케이션 컨텍스트가 완전히 로드될 때 Spring은 커스텀 필터 bean을 인스턴스화하고 필요한 의존 관계를 주입할 수 있다.

 

의존 관계 주입을 활용하고 중복 호출을 피해야 하는 경우, FilterRegistrationBeanbean으로 선언하고 해당 enabled 속성을 false로 설정하여 컨테이너에 등록하지 않도록 Spring Boot에게 지시할 수 있다.

@Bean
public FilterRegistrationBean<TenantFilter> tenantFilterRegistration(TenantFilter filter) {
    FilterRegistrationBean<TenantFilter> registration = new FilterRegistrationBean<>(filter);
    registration.setEnabled(false);
    return registration;
}

 

OncePerRequestFilter와 중복 호출에 관련된 개인적인 고찰🤔
OncePerRequestFilter를 구현한 커스텀 필터의 경우에는 FilterRegistrationBean 빈을 선언하고 enabled 속성을 false로 설정할 필요는 없는 것 같다. OncePerRequestFilter는 한 요청 당 한 번만 호출되도록 Spring Security에서 내부적으로 처리하기 때문이다.
실제로 해당 커스텀 필터가 호출될 때 로그를 찍어보면 한 번만 출력되는 것을 확인할 수 있다. 

 

Security Exception 처리하기

ExceptionTranslationFilterAccessDeniedExceptionAuthenticationException을 HTTP Response로 변환할 수 있다. 해당 예외 필터는 security filter 중 하나로 FilterChainProxy에 삽입된다.

 

Relationship of `ExceptionTranslationFilter` to other components

 

  1. ExceptionTranslationFilterFilterChain.doFilter(request, response)를 호출한다.
  2. 인증되지 않은 사용자 거나 AuthenticationException이 발생한 경우, 인증 프로세스를 시작한다.
    • SecurityContextHolder를 비운다.
    • 인증이 성공하면 원래의 요청을 다시 실행할 수 있도록 HttpServletRequest를 저장해둔다.
    • AuthenticationEntryPoint는 클라이언트로부터 자격 증명(request credentails)을 요청하는 데 사용된다. 예를 들어 로그인 페이지로 리디렉션 되거나 WWW-Authenticate 헤더를 보낼 수도 있다.
  3. 2번이 아닌 AccessDeniedException인 경우, 접근이 거부된다(Access Denied). 거부된 접근을 처리하기 위해 AccessDeniedHandler가 호출된다.

 

Note 애플리케이션이 AccessDeniedException 또는 AuthenticationException을 던지지 않으면, ExceptionTranslationFilter는 아무것도 하지 않는다.

 

아래는 ExceptionTranslationFilter 의사코드이다. 

try {
    filterChain.doFilter(request, response); //(1)
} catch (AccessDeniedException | AuthenticationException ex) {
    if (!authenticated || ex instanceof AuthenticationException) {
        startAuthentication(); //(2)
    } else {
        accessDenied(); //(3)
    }
}
  1. Filter에서 설명한 것처럼 FilterChain.doFilter(request, response)는 나머지 애플리케이션을 호출하는 것과 동일하다. 이는 애플리케이션의 다른 부분(FilterSecurityIntercepter 또는 method security)이 AuthenticationException 또는 AccessDeniedException을 던지는 경우, 여기서 catch되어 처리될 수 있음을 의미한다.
  2. 사용자가 인증되지 않았거나 AuthenticationException이 발생한 경우, 인증 프로세스를 시작한다.
  3. 2번이 아니라면, 접근이 거부된다(Access Denied).

 

인증 간 Request 저장하기

어떤 요청이 인증되지 않았는데 인증이 필요한 자원에 관한 것일 때 인증 성공 후 다리 요청하기 위해 인증된 자원에 대한 요청을 저장해두어야 한다. Spring Security에서는 RequestCahe 구현체를 사용한 HttpServletRequest를 저장하여 이 작업을 수행한다.

 

🔹RequestCache

HttpServletRequestRequestCache에 저장된다. 사용자가 인증을 성공할 때 RequestCache는 원래의 요청을 다시 실행하는 데 사용된다. RequestCacheAwareFilter는 사용자가 인증한 후 RequestCache를 사용하여 저장된 HttpServletRequest를 가져오고, ExceptionTranslationFilter는 사용자를 로그인 엔드포인트로 리디렉션 하기 전에  AuthenticationException을 감지한 후 RequestCache를 사용하여 HttpServletRequest를 저장한다.

기본적으로 하나의 HttpSessionRequestCache가 사용된다. 아래의 코드는 continue라는 매개변수가 있는 경우, 저장된 요청에 대해 HttpSession을 확인하는 데 사용되는 RequestCache 구현체를 사용자 정의(customize)하는 방법을 보여 준다.

@Bean
DefaultSecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
	HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
	requestCache.setMatchingRequestParameterName("continue");
	http
		// ...
		.requestCache((cache) -> cache
			.requestCache(requestCache)
		);
	return http.build();
}

 

🔹요청이 저장되지 않도록 방지하기

세션에 인증되지 않은 요청을 저장하지 않고, 대신 브라우저나 DB로 옮겨 저장하고 싶을 수도 있다. 또는 로그인하지 않은 사용자가 방문하려는 페이지 대신 홈페이지로 리디렉션 하는 것을 원하면 이 기능을 사용하지 않을 수도 있다.

이 경우에는 NullRequestCache 구현체를 사용하면 된다.

@Bean
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
    RequestCache nullRequestCache = new NullRequestCache();
    http
        // ...
        .requestCache((cache) -> cache
            .requestCache(nullRequestCache)
        );
    return http.build();
}

 

🔹RequestCacheAwareFilter

RequestCacheAwareFilterRequestCache를 사용하여 원래의 요청을 다시 실행한다.

 

Logging

Spring Security는 모든 보안 관련 이벤트에 대해 DEBUGTRACE 레벨에서 포괄적인 로깅을 제공한다. Spring Security는 요청이 거부된 자세한 이유를 response body에 담지 않기 때문에 이는 애플리케이션을 디버깅할 때 유용하다.  401/403 에러가 발생하면 무슨 일이 일어나고 있는지 이해에 도움이 되는 로그를 발견할 수도 있다.

logging:
  level:
    org:
      springframework:
        security: TRACE

 

 

 

Comments