본문 바로가기
Coding/Java Spring

스프링 시큐리티 아키텍처

by Hide­ 2023. 6. 19.
반응형

Architecture

이번 섹션에서는 서블릿 기반의 어플리케이션에서 동작하는 스프링 시큐리티의 아키텍처에 대해 다룬다.

A Review of Filters

스프링 시큐리티는 서블릿 필터를 기반으로 한다. 따라서 일반적인 필터의 역할에 대해 먼저 알아보자. 아래의 그림은 단일 HTTP 요청을 처리할 때의 구조이다.

클라이언트가 요청을 보내면 컨테이너는 FilterChain을 생성한다. 해당 클래스는 요청 URI기반으로 HttpServletRequest를 다루는 Filter와 Servlet인스턴스를 포함하고 있다. 스프링 MVC에서 Servlet은 DispatcherServlet의 인스턴스이다. 적어도 한개 이상의 서블릿이 단일 HttpServletRequest 및 HttpServletResponse를 처리할 수 있다. 하지만 하나 이상의 필터를 통해 아래의 작업을 수행할 수 있다.

- 다음 차례의 필터 또는 서블릿이 실행되지 않게 한다. 이 경우, Filter는 일반적으로 HttpServletResponse를 다룬다.

- 다음 차례의 필터가 사용하는 HttpServletRequest 또는 HttpServletResponse를 수정한다.

이러한 강력한 기능은 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
}

Filter는 오직 그 다음의 필터 또는 서블릿에게만 영향을 끼치기 때문에 각 Filter간의 순서는 굉장히 중요한 부분이다.

DelegationFilterProxy

스프링은 서블릿 컨테이너의 라이프 싸이클과 스프링의 ApplicationContext 사이를 연결하는 역할을 하는 DelegatingFilterProxy 필터를 제공한다. 서블릿 컨테이너는 필터 그 자체에 등록할 수 있는 기능을 제공해주지만 스프링 빈으로 인식되진 않는다.

당신은 서블릿 컨테이너 표준 기술을 통해 DelegatingFilterProxy를 등록할수는 있지만 이는 필터를 구현하는 스프링 빈에 모든 작업을 위임한다. 아래 그림은 DelegatingFilterProxy가 어떻게 필터와 FilterChain 사이를 연결하는지를 나타낸다.

DelegatingFilterProxy는 ApplicationContext에서 빈으로 등록된 모든 필터를 조회하고 실행시킨다. 아래 블록은 이를 코드로 나타낸 것이다.

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

1. 스프링을 통해 빈으로 등록된 필터를 lazy하게 가져온다. 

2. 가져온 빈에 작업을 위임한다.

DelegatingFilterProxy를 사용함에 따라 얻는 또다른 이점은, 빈으로 등록된 필터를 가져오는 작업을 지연시킬 수 있다는 점이다. 이는 컨테이너가 실행되기 전 필터를 등록해야하기 때문에 굉장히 중요하다. 하지만 일반적으로 ContextLoaderListener를 사용하여 스프링 빈을 로드하는데, 필터가 등록되기 전까지 완료하지는 않는다.

FilterChainProxy

스프링 시큐리티의 서블릿 지원은 FilterChainProxy에 포함되어있다. FilterChainProxy는 SecurityFilterChain을 통해 많은 필터들을 위임하는 특별한 필터이다. 또한 FilterChainProxy는 빈이기 때문에 일반적으로 DelegationFilterProxy로 감싸져있다. 아래의 그림은 FilterChainProxy의 역할을 나타낸다.

SecutiryFilterChain

SecurityFilterChain은 FilterChainProxy에 의해 사용되며 현재 HTTP 요청에 대해 어떠한 필터가 실행되어야 하는지 결정하는 작업을 수행한다. 아래의 그림은 SecurityFilterChain의 역할에 대해 나타낸다.

SecurityFilterChain안에 있는 시큐리티 필터들은 일반적으로 스프링 빈이지만 DelegatingFilterProxy가 아닌 FilterChainProxy에 등록된다. FilterChainProxy는 서블릿 컨테이너 또는 DelegatingFilterProxy에 직접 등록할 수 있는 많은 이점을 제공해준다.

첫 번째로, 스프링 시큐리티가 서블릿을 지원하기 위한 시작점을 제공한다. 이러한 이유로 인해 스프링 시큐리티와 서블릿을 디버깅할 때 FilterChainProxy를 디버깅 포인트로 두는것은 좋은 선택지가 된다.

두 번째로, FilterChainProxy는 스프링 시큐리티 사용의 중심이기 때문에 선택사항이 아닌 작업을 수행할 수 있다. 예를 들어 메모리릭을 방지하기 위해 SecurityContext를 초기화시켜줄 수 있으며 스프링 시큐리티의 HttpFirewall을 통해 외부에 대한 공격을 방지할 수 있다.

추가적으로, SecurityFilterChain을 호출하는 시기를 결정하는데에 많은 유연성을 제공한다. 서블릿 컨테이너에서 필터는 URL만을 기준으로 호출된다. 하지만 FilterChainProxy는 RequestMathcer 인터페이스를 사용하여 HttpServletRequest의 모든 항목을 기반으로 호출을 결정할 수 있다. 아래의 그림은 여러개의 SecurityFilterChain 인스턴스의 동작을 나타낸다.

위 그림에서 FilterChainProxy는 어떤 SecurityFilterChain을 사용해야하는지 결정한다. 그리고 일치하는 첫 번째 SecurityFilterChain만을 호출한다. 만약 /api/messages 라는 URL로 요청이 왔다면 /api/**라는 패턴을 가지고 있는 첫 번째 필터와 매칭되기 때문에 해당 필터만 호출된다. 만약 /messages 라는 URL로 요청이 왔다면 첫 번째 필터는 매칭되지 않기 때문에 다른 필터와 패턴이 매칭되는지 찾는다. 그리고 N번째의 다른 필터 중 매칭되는것이 있다면 해당 필터를 호출한다.

그림에서 첫 번째 SecutiryFilterChain이 3개의 필터를 가지고 있다는 점에 주목하자. 반면에 N번째 필터는 4개의 필터를 가지고 있다. 이는 각 SecurityFilterChain이 독립적으로 구성됨을 의미하기에 굉장히 중요한 요소이다. 실제로 어플리케이션에서 시큐리티가 특정 요청을 무시하기를 원하는 경우 SecurityFilterChain에는 필터가 존재하지 않을수도 있다.

Security Filters

https://docs.spring.io/spring-security/reference/servlet/architecture.html#servlet-security-filters 에서 시큐리티가 제공하는 많은 필터 목록을 확인할 수 있다.

Handling Secutiry Exceptions

ExceptionTranslationFilter를 사용하면 AccessDeniedException 또는 AuthenticationException을 HTTP응답으로 변환할 수 있다. ExceptionTranslationFilter는 시큐리티에서 제공하는 필터 중 하나로써 FilterChainProxy에 삽입된다. 아래 그림은 ExceptionTranslationFilter와 다른 요소들의 관계를 나타낸다.

첫 번째로, ExceptionTranslationFilter는 FilterChain.doFilter(request, response)를 호출하여 나머지 작업을 수행한다.

두 번째로 만약 유저가 인증에 실패했거나 AuthenticationException 자체라면 아래의 과정을 통해 인증 절차를 수행한다.

- SecurityContextHolder가 초기화된다.

- 인증 절차가 끝난 후 다시 원본 요청을 수행하기 위해 HttpServletRequest를 저장한다.

- AuthenticationEntryPoint를 통해 클라이언트로부터 들어온 요청에서 권한을 검증한다. 예를 들어 페이지를 로그인 페이지로 리다이렉트 시키거나 WWW-Authenticate 헤더를 보내는 등의 작업을 수행한다.

세 번째로, 2번까지 수행한 결과가 AccessDeniedException이라면 AccessDeniedHandler를 호출하여 요청을 거절한다. 

참고로 어플리케이션이 AccessDeniedException 또는 AuthenticationException를 throw하지 않는다면 ExceptionTranslationFilter는 아무런 동작을 하지 않는다. 아래는 이를 코드로 나타낸 부분이다.

try {
	filterChain.doFilter(request, response); 
} catch (AccessDeniedException | AuthenticationException ex) {
	if (!authenticated || ex instanceof AuthenticationException) {
		startAuthentication(); 
	} else {
		accessDenied(); 
	}
}

- 위에서 설명했듯이 FilterChain.doFilter(request, response)는 어플리케이션의 나머지 작업을 수행하는것과 같다. 예를 들어 어플리케이션의 다른 코드에서 AccessDeniedException 또는 AuthenticationException를 throw한다면 ExceptionTranslationFilter에서 해당 예외를 잡아서 처리하게 된다.

- 만약 유저가 인증되지 않았거나 AuthenticationException 자체라면 인증을 시작한다.

Saving Requests Between Authentication

HTTP요청이 인증되지 않았거나 인증이 필요한 엔드포인트에 접근하는 경우 인증이 성공한 후 다시 요청을 처리해줘야 한다. 스프링 시큐리티에서는 이러한 작업을 위해 RequestCache의 구현체에 HttpServletRequest를 저장해둔다.

RequestCache

HttpServletRequest는 RequestCache에 저장된다. 그리고 유저가 인증에 성공한 경우 RequestCache에 저장된 데이터를 통해 원본 Request를 다시 실행시킨다. RequestCacheAwareFilter는 HttpServletRequest를 RequestCache에 저장할 때 사용된다.

기본적으로 HttpSessionRequestCache가 사용된다. 아래의 코드는 HTTP요청에서 continue라는 파라미터가 존재하는지 검사하는 용도로 RequestCache를 수정한 예제를 나타낸다.

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

Prevent the Request From Being Saved

아마 여러가지의 이유로 인해 유저의 비인증 요청이 세션에 저장되지 않길 원할수도 있다. 오히려 데이터베이스에 저장하는것을 원할수도 있을것이다. 또는 사용자가 로그인하기 전에 방문하려했던 페이지 대신 항상 특정 페이지로 리다이렉션 시키고 싶을수도 있을 것 같다. 그러한 경우 NullRequestCache를 사용하면 된다.

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

Reference

https://docs.spring.io/spring-security/reference/servlet/architecture.html