Developer.

[멋사 백엔드 19기] TIL 49일차 Spring Security Servlet

📂 목차


📚 본문

Spring Security

어플리케이션 보안은 서블릿과 리액티브 용으로 나뉘지만 이를 보기 전에 전반적인 아키텍쳐를 바라본다.

Architecture

assets/img/filter-chain.png

우선 서블릿에서의 security 역할을 먼저 보자. 서블릿에서의 Spring Security 는 가장 중요한 개념인 Servlet Filters 를 기반으로 하기 때문에, 먼저 필터의 개념과 역할을 살펴보는게 좋다.

Filters

클라이언트가 어플리케이션에 요청을 보내면, 컨테이너는 FilterChain 을 생성하게 된다. FilterChain 은 요청 URI 의 경로에 따라 해당 요청을 처리해야 하는 FilterChain 인스턴스 와 Servlet 을 포함한다.

Spring MVC 어플리케이션은 이 Servlet 이 DispatcherServlet 의 인스턴스이며, 하나의 HttpServletRequestHttpServletResponse 는 최대 하나의 Servlet 만이 처리할 수 있다.

하지만 여러 개의 Filter 는 서블릿과 다르게 여러 개를 함께 사용할 수 있고, 그 목적은 다음과 같다:

  • FilterChain 내 다음 순서의 다른 Filter 인스턴스나 Servlet 이 호출되지 않도록 차단함, 이 경우에는 해당 Filter 가 직접 HttpServletResponse 를 작성하게 된다.

  • 하위 Filter 인스턴스 혹은 Servlet 이 사용할 HttpServletResponse 를 작성한다. 즉, 현재 필터가 요청이나 응답을 가로채서 바꾼뒤 다음 필터나 서블릿에게 전달이 가능하다는 것이다(doFilter 쪽).

두 번째 경우가 가장 많이 쓰이는데 다음을 보자.

Request 수정

public void doFilter(ServletRequest request,
                     ServletResponse response,
                     FilterChain chain)
        throws IOException, ServletException {
    
    HttpServletRequest httpRequest = (HttpServletRequest) request;

    // HttpServletRequest를 감싸서 헤더를 수정할 수 있는 Wrapper를 만듦
    HttpServletRequestWrapper wrappedRequest = new HttpServletRequestWrapper(httpRequest) {
        @Override
        public String getHeader(String name) {
            if ("Authorization".equalsIgnoreCase(name))
                return "Bearer dummy-token"; // 새로운 헤더 값 주입
            return super.getHeader(name);
        }
    };

    // 수정된 요청 객체를 다음 Filter나 Servlet에게 넘김
    chain.doFilter(wrappedRequest, response);
}

이 경우에 원래 Authorization 헤더가 없던 요청이, 다음 서블릿으로 넘어갈 때에는 Authorization: Bearer dummy-token 을 가진 요청으로 바뀐다. 이 역할은 HttpServletRequestWrapper 로 하게 된다.

Response 수정

응답을 다음처럼 수정하는 경우가 많다. 모든 응답에 공통 보안 헤더를 추가하고 싶을 때:

public void doFilter(ServletRequest request,
                     ServletResponse response,
                     FilterChain chain)
        throws IOException, ServletException {

    HttpServletResponse httpResponse = (HttpServletResponse) response;

    // 체인 내 다음 필터나 서블릿을 먼저 실행
    chain.doFilter(request, response);

    // 응답에 공통 헤더 추가
    httpResponse.setHeader("X-Security-Checked", "true");
}

이 경우는 서블릿이 응답을 만든 뒤, 필터들을 거치게 되는데 그 응답을 받아서 헤더를 덧붙이고 최종 클라이언트로 전달하게 된다.

결국에는 컨트롤러에서는 데이터 바디만 처리하도록 할 수 있고, 책임은 필터쪽으로 넘기게 되는 것이다.

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

DelegatingFilterProxy

Spring 의 필터에는 가장 중요한 필터 구현체인 DelegatingFilterProxy 가 있다. 서블릿 컨테이너(톰캣 등등)SpringApplicationContext 사이를 연결해주는 다리 역할을 한다.

이런게 필요한 이유는 서블릿 컨테이너는 원래 자바 표준인 javax.servlet.Filter 를 인식한다. 이 말은 즉슨 필터 인스턴스를 등록하면 요청과 응답을 가로채서 처리할 수 있게 된다.

그런데 문제는 서블릿 컨테이너는 Spring 이 관리하는 Bean 을 모른다는 것이다(스프링 컨텍스트 IoC 는 완전히 별개의 프로그래밍 언어로 구성되기 때문).

따라서 다음과 같은 상황이 생기게 된다:

  • 톰캣: 필터는 등록했는데 얘는 그냥 자바 객체라 해석할 수 없다
  • 스프링: 필터는 내가 관리하는 빈으로 등록했는데 톰캣이 그걸 모름

그래서 등장한 것이 DelegatingFilterProxy 로 말 그대로 필터를 위임하는 것이다.

  1. 서블릿 컨테이너는 DelegatingFilterProxy 라는 껍데기 필터를 등록하고
  2. 실제 로직은 Spring 이 관리하는 Bean(진짜 필터 구현체) 에게 위임하는 방식
[Client Request]
      ↓
[Servlet Container]
      ↓
[DelegatingFilterProxy]  ← (등록된 표준 서블릿 필터)
      ↓
[Spring Bean: 실제 Filter 구현체]  ← (Spring이 관리)
      ↓
[DispatcherServlet → Controller ...]

따라서 Spring Security 의 진입점이 바로 이 구조이다. DelegatingFilterProxy 가 서블릿 컨테이너에 등록되어 있고, 실제 필터 체인은 스프링 컨텍스트 안의 springSecurityFilterChain 이라는 빈이 담당하게 된다(실제로 springSecurityFilterChain 이라는 것을 함수 빈으로 등록하려고 하면 실행 시 오류가 뜬다. 동일 빈으로 등록할 수 없기 때문이다).

@Bean
public Filter springSecurityFilterChain() {
    // 여기에 수많은 SecurityFilter들이 체인 형태로 연결됨
}

이제 그러면 톰캣 입장에서는 DelegatingFilterProxy 만 알면, 실제 필터는 Spring 이 관리하게 되며 Spring 이 이를 연결시키게 되는 것이다.

정리

  • Servlet Container: 필터의 생명주기를 관리하지만, 스프링 빈은 모름
  • Spring Application: 스프링 빈을 관리하지만, 서블릿 컨테이너는 모름
  • DelegatingFilterProxy: 둘 사이를 연결하는 위임 필터이며 프록시 역할을 함

DelegatingFilterProxy 는 서블릿 컨테이너와 스프링 컨텍스트의 필터 연결 어댑터이다. Spring Security, Spring Session, Spring Web Flow 등등과 같은 보안/세션 관련 기능들은 전부 이 구조를 기반으로 돌아가게 된다.

아래는 그 예시이다.

assets/img/delegating-filter-proxy.png

DelegatingFilterProxyApplicationContext 에서 Filter0 라는 Bean 을 찾아낸 다음 그 Filter0 를 호출하게 된다. 아래 예시는 DelegatingFilterProxy 의 동작 방식을 보여주는 pseudo code 이다.

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

Spring Bean 으로 등록된 Filterlazy 해서 가져오게 되며, DelegatingFilterProxy 의 예시에서는 delegate 가 Bean Filter0 의 인스턴스이다.

  • 실제 작업 처리는 Filter 에게 위임
  • DelegatingFilterProxy 의 또 다른 장점으로 필터 빈 인스턴스 조회를 늦출 수 있다

두 번째 내용은 톰캣과 같은 서블릿 컨테이너는 어플리케이션이 시잘될 때 가장 먼저 Filter들을 등록하게 된다. 필터는 HTTP 요청이 들어오는 가장 첫 시점에서 실행되기 때문에 가장 먼저 인스턴스가 등록되어야 한다.

필터 등록 > 스프링 빈 로딩

하지만 이때 문제가 생기는데, 예를 들어 우리의 Spring Security 의 필터를 스프링 빈으로 등록했다고 하자.

@Bean
public Filter securityFilter() {
    return new MySecurityFilter();
}

톰캣이 필터를 등록해야 하는 시점에 아직 스프링 컨텍스트가 만들어지지 않았다는 것이다. 즉, @Bean 들은 아직 생성되지 않은 상태인데, 톰캣이 필터 인스턴스 좀 달라고 요청이 왔을 때, 스프링은 이와 같은 구현체 빈을 아직 가지고 있지 않아서 오류가 발생하게 된다.

이를 방지하는 것이 바로 DelegatingFilterProxy 이며, 이 일반화 된 필터는 다른 필터를 Lazy Lookup 기법을 사용하여 스프링 컨텍스트가 나중에 열린 후에 거기 있는 진짜 필터를 찾아와서 연결하는 역할을 하게 된다.

DelegatingFilterProxy 와 함께 필터 등록 > 스프링 빈 로딩 > 진짜 필터 찾아서 연결

FilterChainProxy

assets/img/delegating-filter-proxy.png

지금처럼 스프링 시큐리티의 구조에서 이제 DelegatingFilterProxy -> FilterChainProxy 를 보자.

DelegatingFilterProxy 는 톰캣과 스프링의 컨텍스트의 생명주기 불일치를 해결하기 위한 설계적 해법임을 위에서 보았다. 이때 톰캣은 서블릿 필터를 시작 지점에 등록해야 하지만, 스프링은 컨텍스트 초기화 이후에야 필터 빈을 생성할 수 있다.

즉 필터의 등록시점과 생성시점이 어긋나기 때문에, DelegatingFilterProxy 로 이 간극을 메워 등록은 톰캣이 먼저, 실행은 스프링이 나중에 실행하는 구조를 가지게 된다.

그리고 이 구조 위에 FilterChainProxy 가 있는데, FilterChainProxy 는 여러 SecurityFilter 를 체인 형태로 묶어 보안로직의 조립식 파이프라인을 구성하게 된다. Spring Security 의 실질적 동작은 전부 이 FilterChainProxy 내부에서 일어나게 된다.

SecurityFilterChain

assets/img/security-filter-chain.png

이제 스프링 시큐리티의 핵심인 SecurityFilterChain 을 보자. SecurityFilterChain 안에 들어있는 Security Filter 들은 대부분 Spring Bean 이지만, 이 필터들은 DelegatingFilterProxy 에 직접 등록되지 않고 대신 FilterChainProxy 에 등록되게 된다.

이렇게 하는 이유는 다음과 같은 이점을 지니기 때문이다:

  1. Spring Security 서블릿 지원의 시작점 (Entry Point)
    • 서블릿 기반 보안 로직의 출발점이기 때문
  2. FilterChainProxy 는 공통적이고 필수적인 작업을 수행 하는 책임
    • 따라서 중심부에 있기에, 모든 요청에 대해 반드시 수행해야 하는 작업들을 담당하도록 할 수 있음(SecurityContext 초기화 및 정리, HttpFirewall 적용)
  3. 요청 매칭의 유연성
    • 서블릿 컨테이너의 기본 필터 구조는 URL 패턴만을 기준으로 필터를 호출하지만
    • FilterChainProxyRequestMatcher 인터페이스 등을 사용하여 요청의 어떤 속성이든 매칭 조건으로 활용할 수 있게 된다.
  4. 여러 개의 SecurityFilterChain
    • 하나의 어플리케이션에 여러 개의 SecurityFilterChain 이 존재할 수 있음

즉, FilterChainProxy 는 들어오는 요청을 보고 어떤 SecurityFilterChain 이 이 요청을 처리할지를 결정하고 그 체인 안의 필터들을 순서대로 실행하게 된다.

DelegatingFilterProxy스프링과 서블릿 세계를 연결하고,
FilterChainProxy요청 흐름을 보안 체인 단위로 관리하며,
SecurityFilterChain각 요청에 맞는 보안 규칙 세트를 정의

다음과 같은 여러 개의 보안 필터 체인을 가질 때 Spring Security 가 어떤 체인을 선택해서 실행하는지 보자.

assets/img/multi-security-filter-chain.png

FilterChainProxy 는 들어오는 요청에 대해 어떤 SecurityFilterChain 을 사용할지 결정한다. 여러 체인 중 가장 먼저 매칭되는(SecurityFilterChain) “하나만” 실행된다.

예시

요청 URL 이 /api/messages 인 경우

  1. 첫번째 체인인 SecurityFilterChain0 의 패턴 /api/** 에 매칭
  2. 따라서 SecurityFilterChain0 만 실행된다
  3. 비록 /api/messages/SecurityFilterChainN 의 패턴과 맞을 수도 있지만, 가장 먼저 일치한 체인만을 사용하는 것을 원칙으로 한다.

심지어 필터가 0개 일 수도 있다.

SecurityFilters

SecurityFilterChain 은 내부적으로 여러 보안 필터(Security Filter) 들을 포하마고 있으며, 이 필터들은 SecurityFilterChain API 를 통해 FilterChainProxy 안에 삽입되게 된다.

즉, FilterChainProxy 가 전체 보안 파이프라인의 컨테이너라면, 그 안에 들어있는 개별 보안 모듈들이 바로 Security Filter 들이다.

필터들은 다양한 역할을 가질 수 있다.

역할

  • 익스플로잇(취약점) 방어 - CSRF, 세션 고정, XSS 등 공격 차단
  • 인증(Authentication) - 사용자의 신원 확인
  • 인가(Authorization) - 해당 사용자가 특정 리소스에 접근 가능한지 확인

이 모든 과정이 필터 체인 내부에서 순차적으로 일어나게 된다.

위에서 말했지만, 실행 순서는 중요하다. 보안 필터들은 정해진 순서로 실행되며, 인증이 끝나야 인가를 할 수 있고, 세션 검증이 끝나야 인증 절차를 시작할 수 있기 때문이다.

하지만 대부분의 경우는 순서를 직접 알 필요는 없으며, 일반적으로 Spring Security 가 내부적으로 필터들을 올바른 순서로 등록해주기 때문이다.

순서를 알아야할 경우는 다음 때가 있다:

  • 커스텀 필터를 특정 필터 앞이나 뒤에 추가하고 싶을때
  • 디버깅 중 어떤 필터가 먼저 실행되는지 알고 싶을때

이럴 땐 FilterOrderRegistration 코드를 참고하면, 각 필터가 어떤 순서로 실행되는지 확인할 수 있다.

필터 등록 방법 - HttpSecurity

이 보안 필터들을 대부분 HttpSecurity 를 통해 선언된다. 즉 우리가 흔히 작성하는 다음과 같은 설정 코드가 결국은 SecurityFilterChain 안의 필터들을 구성하는 명령이 된다.

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .csrf().and()
        .authorizeRequests()
            .antMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated()
        .and()
        .formLogin();
}
  • .csrf() - CsrfFilter 추가
  • .authorizeRequests() - AuthorizationFilter 추가
  • .formLogin() - UserPasswordAuthenticationFilter 추가

즉, HttpSecurity 는 필터 체인을 선언적으로 조립하는 DSL 이라고 이해할 수 있다.

위 설정은 다음 실행 순서를 가진다

  1. CsrfFilter: HttpSecurity#csrf, CSRF 공격 차단을 위한 유효한 토큰을 포함하고 있는가?

  2. Authentication 관련 필터
    • BasicAuthenticationFilter: HttpSecurity#httpBasic, Http Basic 인증
    • UsernamePasswordAuthenticationFilter: HttpSecurity#formLogin, 폼 로그인 기반 인증
  3. AuthorizationFilter: HttpSecurity#authorizeHttpRequests
    • 승인된 사용자(role 또는 권한을 가진 사용자) 인지 확인

이 외에도 다양한 필터들이 존재한다.
SecurityContextPersistenceFilter
LogoutFilter
ExceptionTranslationFilter
RequestCacheAwareFilter

이들은 HttpSecurity 설정의 세부 옵션에 따라 추가되기도 하고 생략되기도 한다.

체인 디버깅하기

Spring Security 는 내부적으로 요청마다 어떤 필터 체인이 구성되어 있는지 로깅할 수 있다.

import org.springframework.security.web.FilterChainProxy;

@Autowired
private FilterChainProxy filterChainProxy;

@PostConstruct // 자바 표준(JSR-250) 에 정의된 애너테이션이며, 이 객체가 완전히 준비된 다음에 딱 한 번 실행해줘 라는 의미이다.
public void printFilters() {
    filterChainProxy.getFilterChains().forEach(chain -> {
        System.out.println("=== Security FilterChain for pattern: " + chain.getRequestMatcher());
        chain.getFilters().forEach(f -> System.out.println(" - " + f.getClass().getSimpleName()));
    });
}

보통 스프링 시큐리티의 Configuration 이나 Component 로 등록된 하나에만 작성하면 된다.
다만 FilterChainProxy 빈은 Spring Security 가 내부적으로 등록하기 때문에
SpringAutoConfiguration 이 완료된 이후에만 접근할 수 있다.

Spring Filters 출력

보통 특정 요청에 대해 어떤 보안 필터들이 실제로 호출되는지 확인하고 싶을 때가 있을 것이다.

어플리케이션 시작 시 필터 목록 보기

어플리케이션 시작 시점에 각 보안 필터 체인(SecurityFilterChain) 에 어떤 필터들이 등록되어 있는지를 DEBUG 레벨 로그로 출력하기 때문에 다음과 같이 설정해준다.

logging.level.org.springframework.security=DEBUG
2023-06-14T08:55:22.321-03:00  DEBUG 76975 --- [main]
o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [
    DisableEncodeUrlFilter,
    WebAsyncManagerIntegrationFilter,
    SecurityContextHolderFilter,
    HeaderWriterFilter,
    CsrfFilter,
    LogoutFilter,
    UsernamePasswordAuthenticationFilter,
    DefaultLoginPageGeneratingFilter,
    DefaultLogoutPageGeneratingFilter,
    BasicAuthenticationFilter,
    RequestCacheAwareFilter,
    SecurityContextHolderAwareRequestFilter,
    AnonymousAuthenticationFilter,
    ExceptionTranslationFilter,
    AuthorizationFilter
]

필터들이 순차적으로 어떻게 실행됐는지 볼 수 있다.

요청마다 필터 호출 과정을 로그로 확인

이게 가장 유용하며, 단순히 필터가 등록되었는가 뿐 아니라 특정 요청에서 실제로 어떤 필터들이 실행되었는지를 알 수 있다. Security Events 를 켜면 각 필터의 실행 시점, 예외 발생 위치, 인증/인가 처리 흐름이 전부 로그로 찍히게 설정할 수 있다.

이건 나중에 본다.

필터 체인에 필터 추가하기

보통은 Spring Security 가 기본적으로 제공하는 보안 필터들만으로도 어플리케이션 보안을 충분히 구성할 수 있다.

하지만 때로는 기본 필터 외에 내가 직접 만든 커스텀 필터를 SecurityFilterChain 안에 추가해야 할 때가 있다.

  • 요청 로깅이나 트래킹 하고 싶을 때
  • 커스텀 JWT 토큰 검증을 하고 싶을 때
  • 특정 요청 헤더를 가로채서 변환하고 싶을 때

addFilterBefore(Filter, Class<?>)

특정 필터 앞(before) 에 새 필터를 추가한다.

http.addFilterBefore(new JwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

즉 이 UsernamePasswordAuthenticationFilter 보다 먼저 실행되게 할 수 있다.

addFilterAfter(Filter, Class<?>)

특정 필터 뒤(after) 에 커스텀 필터를 추가한다.

http.addFilterAfter(new LoggingFilter(), BasicAuthenticationFilter.class);

보통 어떤 필터가 끝난 뒤 처리 결과를 가공하거나 후처리, 로깅할 때 쓰인다.

addFilterAt(Filter, Class<?>)

특정 필터를 대체하게 된다.

http.addFilterAt(new CustomLoginFilter(), UsernamePasswordAuthenticationFilter.class);

주로 기본 폼 로그인 방식 대신 커스텀 로그인 처리를 하고 싶을 때 사용하며, OAuth2 나 외부 인증 서버와 연동하여 로그인 로직을 직접 구성할 때 사용한다.

Adding Custom Filter

직접 만든 커스텀 필터를 필터 체인에서 어디에 배치해야 하는지 모를 수 있다.

자신만의 커스텀 필터를 만든다면, 그 필터를 필터 체인 안의 어느 위치에 넣을지 결정해야 한다. 필터의 실행 위치는 보안 이벤트의 순서에 따라 달라진다.

즉, 이 필터가 실행되기 전에 어던 일이 이미 끝나 있어야 하는가? 를 먼저 생각해야 한다.

보통 Spring Security 필터 체인 내의 주요 이벤트 순서는 다음과 같다.

  1. SecurityContext 로드
    • 세션에서 SecurityContext 를 불러와 인증 정보(Authentication) 를 복원
    • 이 작업은 SecurityContextHolderFilter 가 담당
  2. 익스플로잇 방어: CSRF, CORS, 보안 헤더(X-Frame-Options, X-Content-Type-Options 등) 으로 요청을 보호한다.
    • 사용자의 신원을 검증하는 단계
    • 로그인 폼, 토큰(JWT, OAuth2), 세션 등 다양한 방식이 여기에 해당한다.
  3. 요청 인증(Authentication)
    • 사용자 신원을 검증
    • 로그인 폼, 토큰(JWT, OAuth2), 세션 등 다양한 인증 방식이 사용
  4. 요청 인가(Authorization)
    • 인증된 사용자가 요청한 리소스에 접근할 권한이 있는지 검사

가장 흔한 커스텀 필터는 커스텀 인증 필터이다, 이는 보통 LogoutFilter 뒤에 위치시킨다.

실습으로 테넌트 커스텀 필터를 추가해보자.

public class TenantFilter implements Filter {
	@Override
	public void doFilter(ServletRequest request,
	                     ServletResponse response,
	                     FilterChain chain) throws IOException, ServletException {
		HttpServletRequest httpRequest = (HttpServletRequest) request;
		HttpServletResponse httpResponse = (HttpServletResponse) response;

		String tenantId = httpRequest.getHeader("X-Tenant-Id");
		boolean hasAccess = isUserAllowed(tenantId);
		if (hasAccess) {
			chain.doFilter(httpRequest, httpResponse);
			return;
		}
		throw new AccessDeniedException("Access Denied");
	}

	private boolean isUserAllowed(String tenantId) {
		return true;
	}
}

위와 같이 설정할 수 있다. 이제 이 필터를 적용만 하면 되는데 그 적용하는 것은 SecurityFilterChain 에 끼워넣어야 하므로 HttpSecurity 를 토대로 넣어주면 된다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {
	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
		http
				.addFilterAfter(new TenantFilter(), AnonymousAuthenticationFilter.class);
		return http.build();
	}
}
Declaring Custom Filter as Bean with FilterRegistrationBean

위에서는 Configuration 으로 등록했다면 이번에는 Bean 을 통해 등록하는 경우를 보자

Filter 는 요청이 Controller 로 가기 전에 또는 응답이 나가기 전에 특정 로직을 수행하는 객체이다. 이때 로그인 정보 확인, 로그 기록, 테넌트 ID 확인 등을 할 수가 있을 것이다.

이때 필터를 다음과 같이 등록할 수 있다.

@Component
public class TenantFilter implements Filter {
    ...
}

@Bean
public TenantFilter tenantFilter() {
    return new TenantFilter();
}

이렇게 하면 Spring Boot가 자동으로 Filter서블릿 컨테이너(내장 Tomcat 등)에 등록하긴 하지만, Spring Security 도 자체적으로 Filter 체인을 관리하기 때문에 이 Filter 가 한 번은 Tomcat 쪽에서 실행되고 또 한 번은 Spring Security FilterChain 에서 실행되게 된다. 즉 두 번 호출되며, 호출 순서도 달라질 수 있어서 의도치 않은 보안 순서 문제가 생기게 된다.

그래서 Bean 은 만들되 자동 등록은 막는 방법으로 다음이 있다.

@Bean
public FilterRegistrationBean<TenantFilter> tenantFilterRegistration(TenantFilter filter) {
    FilterRegistrationBean<TenantFilter> registration = new FilterRegistrationBean<>(filter);
    registration.setEnabled(false); // 자동 등록 비활성화!
    return registration;
}

필터 안에서 DI 를 써야 해서 Bean 으로 만들어야 한다면 자동 등록만 위처럼 꺼버리면 된다.

Customizing a Spring Security Filter

보통 Security 는 자동으로 HttpSecurity DSL 로 설정만 해도 필요한 필터들을 추가해준다.

http.httpBasic(Customizer.withDefaults());

이 한 줄이면 내부적으로 BasicAuthenticationFilter 가 자동 추가되게 된다. 하지만 여기서 직접 만든 버전의 필터를 쓰고 싶을때 다음을 등록할 수 있다.

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    BasicAuthenticationFilter basic = new BasicAuthenticationFilter(...);
    // 커스텀 설정 추가

    http
        // ...
        .addFilterAt(basic, BasicAuthenticationFilter.class);

    return http.build();
}

여기서 주의할 점은 같은 필터를 두 번 추가하면 안된다는 것이다.

ExceptionTranslationFilter

assets/img/exception-translation-filter.png

보안 로직 중에서도 인증/인가에 실패를 하였을 때 HTTP 응답으로 바꿔주는 역할을 하는 필터이다.

  • AuthenticationException 인증 실패
  • AccessDeniedException 인가 실패

이 두 가지를 HTTP 응답으로 변환하는 게 주 역할이다. 이 필터의 위치는 다음에 위치된다

[인증 관련 필터들]
     ↓
[AuthorizationFilter]
     ↓
[ExceptionTranslationFilter] ← 여기서 예외 처리 담당
     ↓
[나머지 필터 / 애플리케이션 로직]

즉, 아래쪽 필터들이 던진 예외(AuthenticationException, AccessDeniedException)를 맨 위에서 받아서 처리하는 “보안 예외의 수문장” 역할을 한다.

실제 동작 순서

  1. 요청 실행 시도
    • 먼저 ExceptionTranslationFilter 는 단순히 필터 체인만 실행
  2. AuthenticationException 발생
    • 하위 필터에서 위 예외 발생
    • SecurityContextHolder 안의 인증 정보(Authentication) 를 비움
    • 현재 요청을 저장해둠 (로그인 성공 후 다시 원래 요청을 리플레이 하기 위함)
    • AuthenticationEntryPoint 호출
      • 로그인 페이지로 리다이렉팅
      • WWW-Authenticate 헤더를 응답에 추가
  3. AccessDeniedException 발생
    • 하위 필터에서 위 예외 발생 시
    • 인증은 되었지만 권한이 없는 경우이며,
    • AccessDeniedHandler 가 호출되고
    • 이 핸들러가 403 Forbidden 응답을 보낸다.
    • 즉, 로그인을 했음에도 불구하고 이 리소스에 접근할 권한이 없다고 내뱉는 것이다.
Saving Requests Between Authentication

Spring Securty 에서 인증 전 요청을 저장하고 복원하는 메커니즘 즉, RequestCache 에 대해 이제 보자.

실제 로그인 처리에 대해 굉장히 중요한 부분이다.

로그인 전 요청 복원

요청을 저장하는 이유는 사용자가 로그인 하지 않는 상태에서 /mypage 같은 보호된 리소스를 요청해야 한다고 가정하자. 이때

  1. Spring Security 는 “이 URL 은 인증이 필요함” 을 감지하게 되고
  2. ExceptionTranslationFilterAuthenticationException 을 받아, RequestCache 에 현재 요청을 저장해둔다.
  3. 그리고 로그인 페이지로 리다이렉팅 시키며,
  4. 로그인 성공 시에 원래 가려던 /mypageRequestCacheAwareFilter 에서 RequestCache 의 저장된 요청을 다시 복원시키게 된다. 이로써 로그인 전 요청 복원 기능이 완성되게 된다.

전체 흐름

[사용자: /mypage 요청]  [인증  ]
     
[ExceptionTranslationFilter]
  └─> RequestCache.save(request)
  └─> 로그인 페이지로 redirect
     
[사용자 로그인 성공]
     
[RequestCacheAwareFilter]
  └─> RequestCache.getSavedRequest()
  └─> 원래 /mypage로 redirect

기본 구현체 - HttpSessionRequestCache

기본적으로 Spring Security 는 HttpSessionRequestCache 를 사용하며 HttpSession 에 요청 정보를 저장한다.

  • 원래 URL
  • HTTP 메서드
  • 쿼리 파라미터
  • 헤더 정보

커스터마이징 - 저장 조건 변경

예를들어 continue 라는 파라미터가 있을 때만, 요청을 저장하도록 설정할 수 있다.

@Bean
SecurityFilterChain springSecurity(HttpSecurity http) throws Exception {
    HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
    requestCache.setMatchingRequestParameterName("continue");

    http
        .requestCache(cache -> cache
            .requestCache(requestCache)
        );

    return http.build();
}

이렇게 하면 요청 URL 이 /mypage?continue=true 일때만 요청을 저장하게 된다.

요청 저장 기능 끄기 - NullRequestCache

어떤 앱은 로그인 성공 후 무조건 홈으로 가게 하고 싶을 수 있을 것이다. 이때는 아예 요청을 처리하지 않도록 설정하면 된다.

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

Spring Security 는 이전 요청을 절대 저장하지 않음 => 로그인 후 항상 /home 같은 기본 페이지로 이동함

RequestCacheAwareFilter

  • 로그인 성공 시점에 RequestCache 에서 저장된 요청을 찾아내고
  • 원래 요청이 있었다면 해당 URL 로 리다이렉트하는 기능을 가진다
  • 없으면 기본 페이지로 이동한다

저장된 요청을 복원하는 담당자


용어

Tenant

임차인이라는 의미이며, 소프트웨어에서는 하나의 시스템을 여러 조직이나 사용자가 공유할 때, 각 사용자를 구분하는 단위를 의미한다. 이 개념은 멀티테넌시(Multi-Tenancy) 구조에서 중요하다.

예를 들어 어떤 회사가 exam.com 이라는 SaaS 를 운영하고 있다고 하자. 그러면 이 서비스를 여러 A 회사, B 회사가 함께 사용한다고 친다면, A 회사 직원은 tenantId = A, B 회사의 직원은 tenantId = B 를 가질 수 있다.

이때 A 회사와 B 회사는 같은 어플리케이션 서버와 DB를 사용하지만, 서로의 데이터에는 접근할 수 없어야 한다.

여기서 Tenant 는 다음 역할을 하게 된다.

  • 데이터 분리: 각 테넌트마다 별도의 데이터베이스나 스키마를 사용하거나 테이블 내에서 tenant_id 컬럼으로 데이터 구분
  • 인증 및 인가 구분: 로그인 한 사용자가 속한 tenant 에 따라 접근 가능한 리소스 제한
  • 설정 및 커스터마이징 분리: 각 테넌트마다 설정, 로고, 테마, 정책 등을 다르게 유지

TenanFilter

테넌트 피터는 요청에 포함된 헤더나 토큰에서 X-Tenant-Id 를 읽어서 현재 요청이 어느 테넌트 소속인지 판별하고, 현재 로그인한 사용자가 그 테넌트에 접근할 권한이 있는지 없는지를 검사하게 된다.

GET /api/data
X-Tenant-Id: companyA
Authorization: Bearer <token>