개발하는 자몽

Spring Security 6 - Architecture 본문

Java/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가 제공하는 `FilterChainProxy`는 `SecurityFilterChain`을 통해 많은 필터 인스턴스에 위임할 수 있게 하는 필터이다. 또한 `FilterChainProxy`는 `Bean`이기 때문에 일반적으로 `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만을 기반으로 호출되지만, `FilterChainProxy`는 `RequestMatcher` 인터페이스를 사용하여 `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 Filter`는 `SecurityFilterChain 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`를 구현해도 된다. 이는 요청 당 한 번만 호출되는 필터의 베이스 클래스이며, `HttpServletRequest` 및 `HttpServletResponse` 매개 변수와 함께 `doFilterInternal` 메서드를 제공한다.

 

 

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

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

@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 Filter`와 `Spring Bean` 사이의 초기화 순서와 연관되어 있다.

 

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

 

의존 관계 주입을 활용하고 중복 호출을 피해야 하는 경우, `FilterRegistrationBean`을 `bean`으로 선언하고 해당 `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 처리하기

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

 

Relationship of `ExceptionTranslationFilter` to other components

 

  1. `ExceptionTranslationFilter`가 `FilterChain.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

`HttpServletRequest`는`RequestCache`에 저장된다. 사용자가 인증을 성공할 때 `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

`RequestCacheAwareFilter`는 `RequestCache`를 사용하여 원래의 요청을 다시 실행한다.

 

Logging

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

logging:
  level:
    org:
      springframework:
        security: TRACE

 

 

 

Comments