OAuth2와 JWT를 이용한 서비스를 만들기 전에, 서블릿 기반 애플리케이션에서의 Spring Security에 대해 공부하려고 한다.

 

인증(Authentication), 인가(Authorization), 공격에 대한 방어(Protection Against Exploits)에 대해 다루기 전에 먼저 이해해야 하는 것들을 알아보자.

서블릿(Servlet)

자바에서 HTTP 요청을 처리하고 응답을 생성하는 서버 컴포넌트

 

서블릿이 하는 일

  • 요청 객체(HttpServletRequest) 사용
  • 서블릿 메서드 실행 (비지니스 로직, DB 조회 등)
  • 응답 반환 (HttpServletResponse 설정)

서블릿 컨테이너(Servlet Container)

서블릿 컨테이너는 자바 웹 애플리케이션(서블릿/JSP)을 실행해주는 런타임 환경이다. 대표적으로 Tomcat, Jetty 같은 것들이 있다.

 

서블릿 컨테이너가 관리하는 것들

  • 서블릿 생명주기
    • 서블릿 인스턴스 생성부터 소멸까지 책임진다.
    • init() → service() → destroy()
  • HTTP 요청/응답 객체 생성 및 전달
    • HttpServletRequest , HttpServletResponse 객체를 만들어 서블릿에 전달한다.
  • 필터 관리(등록, 실행 순서 등)
  • 스레드 풀
  • 세션 관리

필터(Filter)

서블릿 기반 스프링 시큐리티는 서블릿 필터(Servlet Filter)를 기반으로 하니, 먼저 필터의 역할을 알아야 한다.

 

클라이언트가 애플리케이션에 HTTP 요청을 보내면, 컨테이너는 요청 URI를 기반으로 HttpServletRequest 를 처리해야 하는 Filter 인스턴스들과 Servlet 을 포함한 FilterChain 을 생성하게 된다. 여기서 Servlet 은 Spring MVC에 있는 DispatcherServlet 의 인스턴스다. 그리고 하나의 HTTP 요청은 하나의 서블릿이 최종적으로 HttpServletRequest/HttpServletResponse 쌍을 처리하게 된다.

 

필터는 그림과 같이 여러개가 있을 수 있는데, 다음과 같은 역할을 한다.

  • 하위에 있는(다음으로 이어지는) 필터나 서블릿이 호출하는 것을 막을 수 있다. → 이러면 일반적으로 필터가 HttpServletResponse 를 직접 작성한다.
  • 하위에 있는 필터와 서블릿이 사용하는 HttpServletRequest 또는 HttpServletResponse 를 수정할 수 있다.
@Override
public void doFilter(ServletRequest request, ServletResponse response,
        FilterChain chain) throws IOException, ServletException {
    // 다음 필터로 넘어가기 전에 수행할 작업
    chain.doFilter(request, response); // 다음 체인 호출
    // 다시 돌아왔을 때 수행할 작업
}

DelegatingFilterProxy

서블릿 컨테이너는 자체 표준을 사용해 필터 인스턴스를 등록할 수는 있는데, 스프링이 정의한 빈(Bean)을 인식할 수 없는 문제가 있다. 그래서 스프링은 서블릿 컨테이너의 생명주기와 스프링의 ApplicationContext 를 연결해주는 DelegatingFilterProxy 라는 필터 구현체를 제공한다.

왜 빈을 인식을 못하지?
⇒ 서블릿 컨테이너는 스프링 ApplicationContext/DI 메커니즘을 모를 뿐만 아니라, 필터 초기화 시점이 스프링 컨텍스트이 준비되는 시점보다 빠를 수 있어 직접 빈 필터를 등록하기 어렵기 때문

 

DelegatingFilterProxy 를 서블릿 컨테이너에 필터로 등록하고, 모든 작업을 필터를 구현한 Spring Bean에 위임하여 수행하게 된다.

 

다음 DelegatingFilterProxy Pseudo Code를 봐보자

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

프록시의 doFilter 메서드가 실행되면

  1. 스프링 빈으로 등록된 필터를 lazy하게 얻는다.
  2. 스프링 빈에 작업을 위임한다.

여기서 lazy하게 얻는다는 뭐지?
⇒ 서블릿 컨테이너에 우선 껍데기 역할을 하는 DelegatingFilterProxy 을 등록해놔서 등록 시점에 빈을 찾는게 아니라, doFilter 메서드가 처음 호출되는 시점에 필터 빈을 찾고 캐싱해둔다.

FilterChainProxy

스프링 시큐리티가 제공하는 특별한 필터로, 요청이 들어오면 어떤 SecurityFilterChain을 쓸지 고르고 그 체인을 실행한다. 빈이므로, DelegatingFilterProxy 로 래핑된다.

 

SecurityFilterChain

특정 요청에 대해 적용할 Security Filter 들을 묶은 체인이다.

 

보안 필터들은 일반적으로 빈이지만, DelegatingFilterProxy 가 아닌 FilterChainProxy 에 등록된다.

why? ⇒ DelegatingFilterProxy 에 등록하는 것에 비해 여러 이점이 있다.

 

1. 디버깅의 단일 진입점

시큐리티 관련 문제는 FilterChainProxy 에서 시작하니, 요청이 어떤 체인을 탔는지 어떤 필터가 실행됐는지 확인하기 좋다.

 

2. FilterChainProxy는 Spring Security의 중심점이므로, 필수 작업들을 수행할 수 있다.

예:

  • SecurityContext 정리 ⇒ 메모리 누수 방지
  • HttpFirewall 적용 ⇒ 특정 유형의 공격 차단

 

3. SecurityFilterChain 의 호출 시점을 유연하게 결정한다.

서블릿 컨테이너는 URL 패턴에만 의존해서 필터를 매핑한다. 그런데 FilterChainProxy 를 둠으로써 모든 요청을 받아RequestMatcher 인터페이스를 사용해 HttpServletRequest 의 모든 정보(URL, HTTP 메서드, 헤더 등)를 사용해 해당 체인을 적용할 지 결정할 수 있다.

 

SecurityFilterChain 이 그림처럼 여러 개 있을 때, FilterChainProxy 는 체인을 0번 체인부터 n번 체인까지 훑으면서 처음 매칭된 체인 하나만을 실행하게 된다.

 

위 그림처럼 체인마다 필터 개수는 다를 수 있다. 필터를 0개 두는 것도 가능하다.

Security Filters

보안 필터는 공격 방어, 인증, 인가 등 다양한 목적으로 사용될 수 있고, SecurityFilterChain API를 통해 FilterChainProxy 에 삽입된다.

 

보안 필터는 인증을 수행하는 필터가 인가를 수행하는 필터보다 먼저 호출돼야 하는 것처럼 적절한 시점에 호출되는 것을 보장하기 위해 특정 순서로 실행된다.

 

공식 문서에는 일반적으로 스프링 시큐리티 필터 순서를 알 필요는 없지만, 순서를 알면 도움이 되는 경우가 있다고 한다. 궁금하다면 FilterOrderRegistration 코드를 확인해보자. (주요한 것들은 알아야 하는 것 같다)

 

보안 필터들은 대부분 HttpSecurity 를 사용해 선언된다고 한다.

 

예시 시큐리티 설정:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(Customizer.withDefaults())
            .httpBasic(Customizer.withDefaults())
            .formLogin(Customizer.withDefaults())
            .authorizeHttpRequests((authorize) -> authorize
                .anyRequest().authenticated()
            );

        return http.build();
    }

}

위 코드는 아래와 같은 대표 필터를 만든다. (실제 체인에는 더 많은 필터가 포함된다)

필터 추가 방법
CsrfFilter HttpSecurity#csrf
BasicAuthenticationFilter HttpSecurity#httpBasic
UsernamePasswordAuthenticationFilter HttpSecurity#formLogin
AuthorizationFilter HttpSecurity#authorizeHttpRequests
  1. CsrfFilter 가 CSRF 공격을 막기 위해 호출된다.
  2. 인증 필터가 요청을 인증하기 위해 호출된다.
  3. AuthorizationFilter 가 요청을 인가하기 위해 호출된다.

 

위에 필터 말고도 다른 필터가 있을 수 있는데, 특정 요청에 대해 호출되는 필터 목록을 보기 위해 출력을 해볼 수 있다.

보안 필터 출력

추가한 필터가 보안 필터 목록에 포함됐는지 확인하고 싶을 수 있다.

필터 목록은 애플리케이션 시작 시 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]

개별 필터의 호출에 대해 분석할 수도 있다. 이를 위해 스프링 시큐리티는 모든 보안 관련 이벤트에 대해 DEBUG 및 TRACE 레벨의 로깅을 제공한다.

 

시큐리티에 막혀 요청이 거부되더라도 그 이유에 대한 정보를 response body에 넣지 않는데, 로깅을 하면 어떤 일이 일어났는지 로그를 통해 찾을 수 있다. ⇒ 디버깅에 유용

 

CSRF 보호가 활성화 됐는데, CSRF 토큰 없이 POST 요청을 시도하는 경우의 예시를 보자

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]

원래라면은 403이 발생하더라도 어떤 것 때문에 에러가 뜬건지 모른다. 그런데 로깅을 활성화 하면 위 로그를 볼 수 있는데, Invalid CSRF token found 와 같이 로그가 다 찍히기 때문에 원인을 찾을 수 있다.

 

모든 시큐리티 이벤트들을 보고 싶으면 두 가지 방법이 있다.

 

application.properties

logging.level.org.springframework.security=TRACE

application.yaml

logging:
  level:
    org.springframework.security: TRACE

logback.xml

<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <!-- ... -->
    </appender>
    <!-- ... -->
    <logger name="org.springframework.security" level="trace" additivity="false">
        <appender-ref ref="Console" />
    </logger>
</configuration>

필터 체인에 보안 필터 추가

커스텀 필터를 추가하고 싶은 경우가 있을 수 있다.

 

두 가지 방법이 있다.

  1. HttpSecurity 메서드로 등록
  2. 필터를 스프링 빈으로 선언 (스프링 부트 사용시)

 

먼저 HttpSecurity 로 등록하는 메서드를 알아보자.

  • #addFilterBefore(Filter, Class<?>) — 지정한 Class 필터 앞에 커스텀 필터를 추가
  • #addFilterAfter(Filter, Class<?>) — 지정한 Class 필터 뒤에 커스텀 필터를 추가
  • #addFilterAt(Filter, Class<?>) — 지정한 Class 필터 위치에 커스텀 필터 추가 (오버라이드 X)

직접 필터를 만들면 필터 체인에서의 위치를 결정해야 하는데, 필터 체인에서 발생하는 주요 이벤트를 먼저 살펴봐야 한다.

  1. 세션에서 SecurityContext 가 로드된다.
  2. 일반적인 공격으로부터 보호 (secure headers, CORS, CSRF)
  3. 요청 인증
  4. 요청 인가

필터를 배치하기 전에 어떤 이벤트가 먼저 발생해야 하는지 고려해야 하는데, 문서에서 Rule of thumb(경험에 의한 규칙?)을 제시한다.

 

내가 추가할 필터가 만약

  • Exploit protection filterSecurityContextHolderFilter 뒤에 배치하라 (1번 이벤트가 끝나고)
  • Authentication filterLogoutFilter 뒤에 배치하라 (1, 2번 이벤트가 끝나고)
  • Authorization filterAnonymousAuthenticationFilter 뒤에 배치하라 (1, 2, 3번 이벤트가 끝나고)

우리는 보통 커스텀 인증(로그인, JWT)를 추가하니까 순서상 LogoutFilter 뒤에 오도록 배치하면 된다.

왜 로그아웃 필터 뒤에? ⇒ 로그아웃하는 사용자에게 인증을 시도하는 건 불필요하니, 로그아웃 필터 뒤에 인증 필터가 오는 것이 자연스럽다.

 

물론 UsernamePasswordAuthenticationFilter 앞에 배치해도 무방하다. JWT를 쓴다면, 폼 로그인보다 JWT를 먼저 처리하겠다는 의미로 전달될 수 있다.

 

두 번째로, 빈으로 선언하는 방법이다. (Spring Boot Only)

필터를 @Component 로 어노테이션하거나 Configuration에서 Bean으로 선언해 Spring 빈으로 만들어주면, 스프링 부트가 자동으로 필터를 내장된 서블릿 컨테이너(embedded container)에 등록한다고 한다.

 

이로 인해 필터가 컨테이너에 의해 한 번, Spring Security에 의해 한 번. 총 두 번 호출될 수 있으며 순서도 다를 수 있다고 한다.

필터 순서를 따로 지정하지 않았다면, Spring Security에 있는 필터가 먼저 실행된다. (기본값 순서 -100)

 

이러한 이유로 어떤 필터들은 스프링 빈이 아닌데, 의존성 주입을 활용하기 위해 필터가 Spring 빈이어야 하는 경우가 있다.

이 때, 필터가 두 번 실행되지 않게 FilterRegistrationBean 빈을 선언하고 enabled 속성을 false 로 설정해 스프링 부트가 서블릿 컨테이너에 등록하지 않게 만들 수 있다.

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

이렇게 하면 HttpSecurity 만 필터를 추가하게 된다.

 

그런데 여기서 이런 질문을 할 수 있다.

“필터를 OncePerRequestFilter 로 만들면 한 번만 실행되는거 아닌가?”

⇒ 맞다. 이 필터를 상속받은 같은 빈 인스턴스이고, 요청 attribute 기반으로 중복 실행을 막을 수 있다. 그래도! 명시적으로 setEnabled(false) 하는게 좋아 보인다.

OncePerRequestFilter.java 의 소스 코드를 보면, 이미 한 번 거친 필터인지 확인하는 로직이 있는데, 문자열을 다루면서 속성값을 추가하는 작업들을 볼 수 있다. 즉, 작지만 오버헤드가 존재한다.

그리고 디버깅할 때도 혼란이 있을 수 있다.

 

마지막으로, 필터를 addFilterAt 을 이용해 등록할 때 같은 종류의 필터가 이미 등록되어 있으면 예외가 발생하므로 조심하자.

 

예시:

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    BasicAuthenticationFilter basic = new BasicAuthenticationFilter();
    // ... configure

    http
        .httpBasic(Customizer.withDefaults()) // Basic 필터 추가됨
        // ... BasicAuthenticationFilter가 두 번 추가 => 에러 발생
        .addFilterAt(basic, BasicAuthenticationFilter.class);

    return http.build();
}

보안 예외 처리

ExceptionTranslationFilterAccessDeniedExceptionAuthenticationException을 HTTP 응답으로 변환한다.

ExceptionTranslationFilter는 보안 필터 중 하나로 FilterChainProxy에 삽입된다.

 

  1. 먼저, ExceptionTranslationFilterFilterChain.doFilter(request, response) 를 호출해 나머지를 실행한다.
  2. 사용자가 인증되지 않았거나 AuthenticationException 이면, 인증을 시작하게 된다.
  • SecurityContextHolder 가 클리어된다.
  • RequestCache에 인증이 성공하면 원래 요청을 재실행할 수 있게 HttpServletRequest 가 저장된다.
  • AuthenticationEntryPoint 가 클라이언트에게 자격 증명을 요청하는 데 사용된다. 예를 들어, 로그인 페이지로 리다이렉트(Redirect)하거나 WWW-Authenticate 헤더를 전송할 수 있다.
  1. AccessDeniedException 인 경우, 접근이 거부되고 AccessDeniedHandler 가 호출된다.

근데 위 예시는 FormLogin/HttpBasic 의 경우이고, JWT를 사용하는 환경에서는 요청을 저장한다거나 리다이렉트하지는 않는다. 커스텀으로 AuthenticationEntryPointAccessDeniedHandler 를 만들어 401이나 403 에러를 JSON 응답을 반환하게 된다.

http
    //...
    .exceptionHandling(exception -> exception
        .authenticationEntryPoint(authenticationEntryPoint)
        .accessDeniedHandler(accessDeniedHandler))

RequestCache

Spring Security에서 인증되지 않은 사용자가 원래 하려던 요청(원본 요청)을 세션 저장해뒀다가, 로그인(인증) 성공 후 원래 요청으로 다시 돌아가게 해준다.

 

두 필터의 역할을 살펴보자.

  • ExceptionTranslationFilter : 저장
    • 인증이 필요한 요청에서 AuthenticationException 이 발생하면, 로그인 페이지로 보내기 전에 현재 HttpServletRequest를 RequestCache에 저장
  • RequestCacheAwareFilter : 복원
    • 로그인에 성공해서 필터를 타다가 이 필터를 만나면 RequestCache를 사용해 저장된 요청과 현재 요청이 매칭되는지 확인하고 원래 하려던 요청을 이어서 하게 만든다.

사용자의 인증되지 않은 요청을 세션에 저장하고 싶지 않을 때에는 NullRequestCache 구현체를 사용해 이 기능을 끌 수 있다.

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

 

 


작년에 Spring Security를 처음 접하고 사용하면서 애매한 지점들이 많았는데, 이번에 기본적인 용어와 내용들을 보면서 많이 정리된 것 같다. 다음엔 OAuth2(+ OpenID Connect)와 인증쪽을 다룰 예정이다.

 

레퍼런스

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