Spring Framework/스프링 시큐리티

[스프링 시큐리티 무작정 따라하기] 5. 필터(Filter) 이해하기

석이 2022. 9. 22. 19:09

들어가기 앞서

본 글은 스프링 시큐리티 서적인 Spring Security in Action을 읽고 책 속에 나와 있는 예제를 공부하며 얻은 지식을 바탕으로 적은 글입니다.

 

이전 글

[스프링 시큐리티 무작정 따라하기] 1. Hello Spring Security

[스프링 시큐리티 무작정 따라하기] 2. 회원 관리하기

[스프링 시큐리티 무작정 따라하기] 3. 인증 구현하기

[스프링 시큐리티 무작정 따라하기] 4. 권한 설정하고 인가(authorization) 처리 하기


오늘의 목표

  1. 필터(Filter) 이해하기
  2. 필터의 복합체, 필터 체인(Filter Chain) 이해하기
  3. 스프링 시큐리티의 필터 종류
  4. [실습] 커스텀 필터 등록하고 필터 체인에 추가하기

 

1. 필터란?

독자분들은 필터라는 단어를 들으시면 어떤 모습이 떠오르시나요? 저는 필터라는 단어를 들으면 정수기나 공기청정기 같이 필요한 물 또는 공기를 추출하기 위해 이물질을 거르는 얇은 막을 떠오릅니다. 필터는 한국어로 '여과 장치'로 번역할 수 있는데, 처음 제가 스프링의 필터를 공부했을 때(정확히는 스프링이 아니라 서블릿입니다만), 필터라는 일반적인 의미랑 기술적으로 쓰이는 이 필터라는 존재가 매칭이 참 잘된다, 하는 생각을 했습니다.

 

필터, 조금 더 정확히 말하면 JE22의 표준 스펙인 서블릿 필터(Servlet Filter)는 클라이언트의 요청이 스프링 컨테이너로 들어가기 전 단계에서 동작하는 객체 혹은 계층입니다. 위 문단에서 이야기 나눈 일반적인 개념의 필터처럼 서블릿 필터도 클라이언트의 요청을 검증하고 가공해 스프링이 원하는 형태만을 스프링 컨테이너 내부로 흘려보낼 수 있도록 필터링 하는 역할을 합니다. 서블릿 필터는 일반적으로 인증, 인가와 같은 작업을 수행하지만 필요에 따라서는 불필요한 요청에 에러 메세지를 담은 응답(response)를 내리거나, 인가된 요청에 대한 로그 기록을 서버 상에 기록할 수도 있습니다.

 

서블릿 필터, 즉 필터는 엄밀히 말해 스프링 시큐리티는 아닙니다. 다만 스프링 시큐리티가 이 필터 계층을 중심으로 동작하기 때문에 스프링 시큐리티를 이해하는데 있어 이 필터라는 개념을 이해하는 것은 매우 중요합니다. 보통 스프링 시큐리티를 이야기 할 때 '스프링 시큐리티는 스프링 MVC에 종속적이지 않다'라고 이야기 하곤 하는데, 이는 스프링 시큐리티가 필터를 중심으로 동작하고, 필터는 스프링에 속한 기술이 아니기 때문에 그러한 것입니다. 앞선 글에서 종종 언급하곤 했던 Authentication Filter도 사실 오늘 설명하는 서블릿 필터의 하나입니다.

 

필터를 선언하기 위해서는 클래스를 만들고, javax.servlet 패키지의 Filter 인터페이스를 구현하면 됩니다. Filter 인터페이스에는 init, doFilter, destroy의 세 메소드가 존재하는데, init 메소드와 destroy 메소드는 해당 필터 객체가 초기화되고 종료될 때 실행되고, doFilter 메소드는 해당 필터가 스프링 컨테이너에 의해 호출될 때 실행됩니다.

 

이쯤에서 한 가지 이상한 부분을 눈치챈 분이 계실지 모르겠습니다. 서블릿 필터는 스프링 기술이 아닌데 윗 문단에서 '스프링 컨테이너에 의해 호출'된다고 적혀 있거든요. 이상하지 않으신가요? 스프링이 아닌데, 더더군다나 스프링이 실행되는 시점 이전에 동작하는데, 스프링에 의해 실행될 수 있다.. 서블릿 필터는 논리적으로 스프링에 의해 실행될 수 없어야 하지 않을까요?

 

이와 관련된 흥미로운 게시글이 있어 첨부합니다. 필터에 대해서 보다 자세한 이해를 하시는데 도움이 될 것입니다.

 

[Spring] 필터(Filter)가 스프링 빈 등록과 주입이 가능한 이유(DelegatingFilterProxy의 등장) - (2)

 

[Spring] 필터(Filter)가 스프링 빈 등록과 주입이 가능한 이유(DelegatingFilterProxy의 등장) - (2)

몇몇 포스팅과 조금 오래된 책들을 보면 필터(Filter)는 서블릿 기술이라서 Spring의 빈으로 등록할 수 없으며 빈을 주입받을수도 없다는 내용이 나옵니다. 하지만 실제로 테스트를 해보면 Filter 역

mangkyu.tistory.com

 

 

2.필터의 복합체, 필터 체인이란?

필터 체인이란 무엇일까요? 다시 한번 앞서 앞 챕터에서 사용한 화법을 사용해 보겠습니다. 체인이란 무엇일까요? 독자분들은 체인이라는 단어를 보시면 어떤 이미지가 떠오르시나요? 저는 자전거의 구동계와 맞물려 돌아가는 체인이나 탱크의 무한궤도와 같이 같은 형태의 작은 단위가 맞물려 큰 군체를 이루는 모습이 떠오릅니다.

 

필터 체인은 필터들의 집합체입니다. 시스템 상, 보다 정확하게는 톰캣과 같은 서블릿 컨테이너(톰캣을 서블릿 컨테이너로 1대 1로 비유할 수 있을지 모르겠습니다. 관련된 부분에 대해 보다 자세한 부분을 아시는 독자분들은 댓글로 알려주시면 감사하겠습니다.)에는 한 개의 필터만이 구현될 수도 있지만 그렇지 않은 경우도 있을 것입니다. 사실, 그렇지 않은 경우가 훨씬 더 많을 것입니다. 만약 인증에 대한 요구사항을 처리하는 필터를 구현한다면, 인증된 요청이 향하는 서버의 리소스가 보안적으로 모든 인증된 요청에 열려 있는 리소스인지 판단할 수 있는 추가적인 필터가 필요할 수 있습니다. 이를테면, 앞서 배운 권한과 역할에 따른 '인가'를 담당하는 필터 말이죠. 혹은 단순히 인증된 모든 회원의 정보를 서버 상에 기록하는 로깅 필터를 만들 수도 있구요.

 

지금 우리가 만든 가상의 시스템이 있고 이 시스템에는 인증, 인가, 그리고 인증과 인가 단계를 통과한 회원의 ip를 로깅하는 세 개의 필터가 있다고 가정해보겠습니다. 각각의 필터는 서로 밀접한 연관을 가지고 있는데, 인증을 진행한 요청이 인가 단계로 들어가야 하고 그 이후에 해당 ip가 로깅되어야 합니다. 즉, 한 개의 필터가 다른 필터에 영향을 주고 받는 것이죠. 조금 더 분명하게 말하면 필터간의 순서가 있는 것입니다.

 

이러한 요구사항을 해결하기 위해 필터 체인이 필요한 것입니다. 각각의 필터들은 필터 체인 내부에 순서에 따라 차곡차곡 보관되고 클라이언트의 요청이 있을 때마다 필터 체인에 의해 순서대로 호출됩니다. 사실 필터는 호출되는 순서가 정말 중요한데, 이러한 역할을 필터 체인이 담당하는 것이죠.

 

서블릿 필터를 구현할 때 오버라이딩 해야 하는 메소드인 doFilter 메소드는 세 번째 매개변수로 필터 체인 객체를 받습니다. 그리고 필터가 종료될 때는 이 세 번째 매개변수인 필터 체인을 호출해 반드시 다음 필터를 실행해야 합니다. 그렇지 않으면, 필터 체인을 다음 필터를 실행하지 않고, 요청은 현재 필터 내부에 갇혀 더 이상 진행되지 않습니다.

 

@Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        chain.doFilter(request, response); // 호출된 필터는 반드시 필터 체인을 호출해 다음 필터를 실행해야 합니다.
    }

 

아래는 스프링 시큐리티의 공식문서 중 아키텍처와 관련된 문서를 번역한 자료입니다. DelegatingFilterproxy와 필터 체인, 그리고 스프링 시큐리티가 어떻게 동작하는지 개략적으로 파악이 가능하므로 첨부합니다.

 

[스프링 시큐리티] 공식문서 번역하며 공부하기 - 아키텍처

 

[스프링 시큐리티] 공식문서 번역하며 공부하기 - 아키텍처

일러두기 본 글은 스프링 공식 페이지의 Architecture 절을 한국어로 번역한 자료입니다. 전문적인 교육을 받은 번역가가 번역한 글이 아니기 때문에 다소의 번역 실수가 있을 수 있습니다. (아마

gaebalsogi.tistory.com

 

 

3. 스프링 시큐리티의 필터 종류

아래는 스프링 시큐리티의 공식문서를 번역한 자료로 스프링 시큐리티가 가지고 있는 필터의 이름과 순서를 보여줍니다.

 

시큐리티 필터는 SecurityFilterChain API를 이용하여 FilterChainProxy 내에 삽입됩니다. 필터의 순서가 중요합니다. 스프링 시큐리티 필터의 순서를 아는 것이 일반적으로 필수적인 것은 아니지만, 순서를 아는 것이 분명 유익할 때가 있습니다.
 
아래는 스프링 시큐리티 필터의 종합적인 순서를 보여줍니다.
 
ChannelProcessingFilter
WebAsyncManagerIntegrationFilter
SecurityContextPersistenceFilter
HeaderWriterFilterCorsFilter
CsrfFilterLogoutFilter
OAuth2AuthorizationRequestRedirectFilter
Saml2WebSsoAuthenticationRequestFilter
X509AuthenticationFilter
AbstractPreAuthenticatedProcessingFilter
CasAuthenticationFilter
OAuth2LoginAuthenticationFilter
Saml2WebSsoAuthenticationFilter
UsernamePasswordAuthenticationFilter
OpenIDAuthenticationFilter
DefaultLoginPageGeneratingFilter
DefaultLogoutPageGeneratingFilter
ConcurrentSessionFilter
DigestAuthenticationFilter
BearerTokenAuthenticationFilter
BasicAuthenticationFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
JaasApiIntegrationFilter
RememberMeAuthenticationFilter
AnonymousAuthenticationFilter
OAuth2AuthorizationCodeGrantFilter
SessionManagementFilter
ExceptionTranslationFilter
SecurityInterceptor
SwitchUserFilter

 

위의 필터의 순서와 종류를 아는 것이 도움이 될 수 있지만 조금 추상적으로 다가올 수 있습니다. 현재 내가 가지고 있는 프로젝트에 어떤 시큐리티 필터와 각각의 필터의 순서가 어떻게 되는지 알고 싶을 수 있는데, 이는 설정 파일에서 EnableWebSecurity 애노테이션을 작성한 후 디버그 모드를 실행해 확인할 수 있습니다. 스프링 시큐리티 의존성을 가진 프로젝트에서 다음과 같이 코드를 작성한 후 프로젝트를 실행해 봅시다.

 

@Configuration
@EnableWebSecurity(debug = true)
public class SecurityConfiguration {
}

 

프로젝트를 실행한 후 아래처럼 스프링 시큐리티의 디버그 모드가 활성화되었다는 메세지를 확인했다면, localhost:8080 등으로 요청을 날려봅니다.

 

디버그 모드 활성화 안내 로그. 민감 정보를 포함할수 있기 때문에 프로덕션 레벨에서는 사용하지 말라는 경고문구가 눈에 띈다.

 

localhost:8080 으로 접근했을 때 확인 가능한 필터의 종류, 그리고 순서

현재 애플리케이션에 적용되어 있는 스피링 시큐리티 필터가 무엇이 있고, 각각의 순서는 어떠한지 확인할 수 있습니다!

 

연습: 전 글에서 배운 csrf 필터를 비활성화하는 방법을 사용해 csrf를 비활성화 한 후, 다시 프로젝트를 돌려 적용된 필터를 확인해 봅니다. 위의 캡처에는 보이는 CsrfFilter가 없어진 것을 알 수 있습니다.

 

 

4. [실습] 커스텀 필터 생성 및 적용하기

필터와 필터체인이 무엇인지 학습하였고 스프링 시큐리티에 적용되어 있는 필터의 종류와 순서가 어떠한지 파악하였으니 실습을 통해 커스텀 필터를 생성하고 스프링 컨테이너에 빈으로 적용하는 방법을 학습해 보도록 하겠습니다. 스프링 시큐리티에서 커스텀 필터를 등록하는 방법은 크게 세 가지로 HttpSecurity 객체를 이용해 특정 필터의 앞과 뒤, 그리고 특정 필터와 같은 위치에 적용하는 방법이 있습니다. 하나하나 예제로 살펴보도록 하겠습니다.

 

우선 실습에 앞서 새로운 프로젝트를 생성하거나 기존 프로젝트를 준비합니다. 필요한 디펜던시는 아래와 같습니다.

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

추가적으로 오늘의 실습에서는 http request에 request header를 추가해 요청을 보내야 하기 때문에 이를 편하게 사용할 수 있는 툴인 포스트맨 프로그램을 사용할 예정입니다. 필요하신 분들은 다운로드 받아 실습을 진행해주시기 바랍니다. 포스트맨 설치와 관련해서는 아래의 게시글을 참고해 주십시오.

 

https://nhj12311.tistory.com/393

 

포스트맨(postman) 사용법(설치, 다운로드)

전부터 웹 개발을 하면서 아주 유용하게 사용했던 프로그램(서비스)가 있어 소개해보려고 합니다. 바로 포스트맨(postman)입니다. 쉽게 말하자면 http(https 포함) 요청을 날리고 응답을 보여주는 서

nhj12311.tistory.com

 

특정 필터 이전에 커스텀 필터 적용하기 : addFilterBefore

RequestValidationFilter

security.filte 패키지를 생성하거나 원하는 패키지를 생성한 후, RequestValidationFilter라는 이름의 클래스를 생성합니다. 클래스의 내부에는 다음과 같이 코드를 작성합니다.

 

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Slf4j
@Component
public class RequestValidationFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("필터 초기화 : {}", this.getClass());
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        String secretAccessCode = request.getHeader("Secret-Access-Code");

        if (secretAccessCode == null || secretAccessCode.isBlank()) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
        }

        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
        log.info("필터 종료 : {}", this.getClass());
        Filter.super.destroy();
    }
}

앞서 설명한 것처럼 init 메소드와 destroy 메소드는 등록된 해당 필터가 초기화되고 종료될 때 실행되는 메소드입니다. slf4j를 사용해 어떤 클래스가 실행되고 종료되는지 로깅해주었습니다. 

 

중요한 부분은 doFilter 메소드로 요청의 헤더 중 'Secret-Access-Code'라는 이름의 헤더가 있는지 검출하여 헤더가 존재하지 않으면 400 Bad Request 에러 메세지를 응답으로 내보내고, 존재한다면 다음 필터를 실행하는 코드를 작성했습니다. 만약 Secret-Access-Code가 존재하지 않을 경우 요청은 그대로 종료되며 클라이언트는 400 Bad Request 에러 메세지를 받게 됩니다.

 

위의 코드에서 한 가지 눈여겨 볼 부분은 @Component 애노테이션으로 해당 클래스를 직접 스프링 빈에 등록했다는 점입니다. 

 

 

ProjectConfiguration

다음으로 configuration 패키지 혹은 원하는 패키지 내부의 ProjectConfiguration 클래스로 돌아와 다음과 같이 코드를 작성합니다. 

 

@Configuration
@EnableWebSecurity(debug = true)
@RequiredArgsConstructor
public class ProjectConfiguration {

    private final RequestValidationFilter requestValidationFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.httpBasic();
        http.addFilterBefore(requestValidationFilter, BasicAuthenticationFilter.class);
        return http.build();
    }
}

 

@RequestArgsConstructor 애노테이션으로 생성자 주입 롬복 처리를 한 후, 앞서 @Component 애노테이션으로 빈으로 등록한 RequestValidationFilter 객체를 DI 해 주었습니다.

 

이후 http.addFilterBefore 메소드를 호출해 첫 번째 매개변수에는 조금 전에 만든 커스텀 필터를, 두 번째 매개변수에는 타겟이 되는 필터를 적어주어 RequestValidationFilter가 BasicAuthenticationFilter 이전에 적용될 수 있도록 하였습니다.

 

이제 프로젝트를 실행 해 localhost:8080으로 테스트 요청을 보내보겠습니다. 아래처럼 RequestValidationFilter가 BasicAuthenticationFilter 위에 적용된 것이 확인 되었다면 성공입니다.

 

 

다음으로 포스트맨을 실행한 후 + 버튼을 눌러 요청 탭을 생성합니다. 이후 url 위치에 localhost:8080 을 기입하고 메소드를 GET으로 설정한 후 send 버튼을 누릅니다. 

 

아래의 캡처처럼 400 Bad Request 메세지가 출력되었다면 성공입니다.

 

만약 Secret-Access-Code를 비활성화한 후 요청을 보내면 404 에러가 나오는 것을 확인할 수 있습니다. (컨트롤러와 html 혹은 응답 메세지를 잡아주지 않았기 때문에 정상적으로 스프링 시큐리티를 통과한 후 404 에러가 내려온 것입니다. 에러이기는 하지만 정상적인 호출이라고 생각하셔도 괜찮습니다.)

 

특정 필터 이후에 커스텀 필터 적용하기 : addFilterAfter

AuthenticationLogFilter

RequestValidationFilter와 같은 패키지에 AuthenticationLogFilter 클래스를 생성합니다. 그리고 아래와 같이 코드를 작성합니다. 

 

@Slf4j
public class AuthenticationLogFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("필터 초기화 : {}", this.getClass());
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        log.info("인증 성공 IP : {}", request.getHeader("Secret-Access-Code"));
        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
        log.info("필터 종료 : {}", this.getClass());
        Filter.super.destroy();
    }
}

이 필터는 인증을 통과한 이후 인증 결과 이후 클라이언트가 요청 헤더에 담은 Secret-Access-Code를 로깅하는 필터입니다. 이 필터는 이전의 예제와는 달리 클래스단에 @Component 애노테이션이 달려 있지 않습니다. ProjectConfiguration 설정 파일에서 직접 이 객체를 스프링 빈으로 등록해 보기 위함입니다.

 

ProjectConfiguration

@Configuration
@EnableWebSecurity(debug = true)
@RequiredArgsConstructor
public class ProjectConfiguration {

    private final RequestValidationFilter requestValidationFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.httpBasic();
		http.addFilterBefore(requestValidationFilter, BasicAuthenticationFilter.class)
        // 아래 코드 추가
                .addFilterAfter(new AuthenticationLogFilter(), BasicAuthenticationFilter.class);        
        return http.build();
    }
}

addFilterAfter 메소드를 이용해 BasicAuthenticationFilter 이후에 앞서 만든 AuthenticationLogFilter를 등록해 주었습니다. 만약 등록하고자 하는 필터가 다른 빈을 주입(DI) 받지 않는다면 이 필터는 시큐리티와 관련된 필터다, 라는 것을 보다 용이하게 표현할 수 있게 이 방법을 고려하는 것도 좋을 것 같습니다.

 

다시 프로젝트를 구동한 후 localhost:8080으로 접근해보겠습니다. 아래와 같이 우리가 만든 AuthenticationLogFilter가 BasicAuthenticationFilter 이후에 위치해 있다면 성공입니다.

이번엔 다시 포스트맨을 켜신 후 지난 예제처럼 Headers 탭 내부에 Secret-Access-Code를 이름으로 하는 헤더를 생성합니다. 그리고 임의의 값을 넣습니다. 저는 hello world라는 값을 적었습니다.

 

 

이후 준비가 완료되었다면 우측의 Send 버튼을 눌러 요청을 서버 측으로 전송합니다. 포스트맨의 응답 코드에 404가 출력되었다면 로깅 확인을 위해 자바 코드의 콘솔창을 확인합니다. 저는 Secret-Access-Code의 값으로 hello world를 적어주었으니 로깅값으로 hello world가 출력된다면 필터가 정상적으로 적용되었고 사용된 것입니다.

 

 

🧠 BasicAuthenticationFilter 뒤에 있는데 인증값을 주지 않았는데도 로깅이 된다구요?

추가적으로 한 가지 살펴보도록 하겠습니다. 방금 예제에서 AuthenticationLogFilter의 위치는 분명 BasicAuthenticationFilter의 다음이었습니다. 그리고 우리는 이전의 테스트 요청에서 분명 BasicAuthenticationFilter가 우리의 커스텀 필터인 AuthenticationLogFilter 앞쪽에 위치해 동작하고 있다는 것을 확인했습니다.

 

그런데 한 가지 이상합니다. 방금 예제에서 포스트맨을 사용해 요청을 서버 상에 넣을 때, 우리는 어떠한 인증 정보도 포함하지 않았기 때문입니다. 그냥 localhost:8080의 주소에 GET 메소드로 Secret-Access-Code라는 헤더만을 추가했지 username이나 password 혹은 jwt같은 인증 정보를 기술하지 않았습니다. 그런데 서버에서는 분명 BasicAuthenticationFilter가 통과되었습니다. 무슨 일이 일어난 것일까요? BasicAuthenticationFilter가 동작하지 않는 것일까요?

 

결론은 아닙니다. 편한 이해를 위해 잠시 BasicAuthenticationFilter의 코드 일부를 살펴보도록 하겠습니다.

 

UsernamePasswordAuthenticationToken authRequest = this.authenticationConverter.convert(request);
if (authRequest == null) {
   this.logger.trace("Did not process authentication request since failed to find "
         + "username and password in Basic Authorization header");
   chain.doFilter(request, response);
   return;
}

BasicAuthenticationFilter 클래스의 doFilterInternal 메소드의 일부 코드입니다. doFilterInternal 메소드는 Filter의 doFilter와 같은 역할을 하는 메소드로 Filter를 구현한 스프링의 추상 메소드 OncePerRequestFilter의 추상 클래스입니다. BasicAuthenticationFilter는 OncePerRequestFilter를 상속해 doFilter 대신 doFilterInternal 메소드를 구현한 것입니다.

 

위의 코드의 첫 번째 줄은 회원의 요청에서 UsernamePasswordAuthenticationToken 즉, username과 password 기반의 인증 정보가 있는지를 검출합니다. authenticationConverter는 BasicAuthenticationConverter 객체이며, 만약 회원의 요청에서 회원이름과 비밀번호 기반의 인증 정보가 있다면 UsernamePasswordAuthenticationToken을, 없다면 null을 리턴합니다. 관련 코드는 다음과 같습니다.

// BasicAuthenticationConverter 클래스
// 인증정보가 username, password 기반이 아니라면 null을 리턴하는 것을 알 수 있다.

@Override
public UsernamePasswordAuthenticationToken convert(HttpServletRequest request) {
   String header = request.getHeader(HttpHeaders.AUTHORIZATION);
   if (header == null) {
      return null;
   }
   header = header.trim();
   if (!StringUtils.startsWithIgnoreCase(header, AUTHENTICATION_SCHEME_BASIC)) {
      return null;
   }
   if (header.equalsIgnoreCase(AUTHENTICATION_SCHEME_BASIC)) {
      throw new BadCredentialsException("Empty basic authentication token");
   }
   byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
   byte[] decoded = decode(base64Token);
   String token = new String(decoded, getCredentialsCharset(request));
   int delim = token.indexOf(":");
   if (delim == -1) {
      throw new BadCredentialsException("Invalid basic authentication token");
   }
   UsernamePasswordAuthenticationToken result = UsernamePasswordAuthenticationToken
         .unauthenticated(token.substring(0, delim), token.substring(delim + 1));
   result.setDetails(this.authenticationDetailsSource.buildDetails(request));
   return result;
}

 

그리고 이후 두 번째 줄에서 획득한 인증 정보가 null인지를 검증해, 만약 null이라면 doFilter 메소드를 호출해 그대로 필터를 통과시키는 로직이 작성되어 있습니다. 즉, username, password 기반의 인증 정보가 있다면 인증을 진행하고, 그렇지 않다면 인증 작업을 패스하는 로직인 것이죠.

 

 

특정 필터 이후에 커스텀 필터 적용하기 : addFilterAt

마지막으로, 특정 필터의 위치에 또 다른 필터를 적용하는 메소드 addFilterAt 메소드를 살펴 보겠습니다. addFilterAt 메소드를 사용할 때는 한 가지 유의할 점이 있는데, 그것은 바로 addFilterAt에 의해 특정 필터의 자리에 등록된 커스텀 필터가 기존 필터를 대신하거나 덮어쓰지 않는다는 것입니다. 새로 등록된 필터는 단순히 기존 필터보다 높은 우선순위를 부여받게 되는 것이지 기존의 필터를 지우고 그 위에 등록되는 것이 아닙니다. 이 부분은 잠시 후에 다시 한번 살펴보도록 하겠습니다.

 

먼저 SecretCodeAuthenticationFilter라는 이름의 커스텀 필터 클래스를 생성합니다. 그리고 아래와 같이 코드를 작성합니다.

 

SecretCodeAuthenticationFilter

@Slf4j
public class SecretCodeAuthenticationFilter implements Filter {

    @Value("${custom.authentication.code}")
    private String secretCode;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("필터 초기화 : {}", this.getClass());
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        String authentication = request.getHeader("Secret-Access-Code");

        if (!authentication.equals(secretCode)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
        log.info("필터 종료 : {}", this.getClass());
        Filter.super.destroy();
    }
}

SecretCodeAuthenticationFilter 클래스는 회원의 요청에 헤더 값으로 실려 있는 Secret-Access-Code가 자신이 필드로 가지고 있는 secretCode와 맞는지 비교하여, 맞다면 필터를 통과시키고 맞지 않다면 401 UnAuthorized 에러를 내보내는 로직을 가지고 있습니다. SecretCodeAuthenticationFilter가 필드로 가지고 있는 secretCode는 보안상 민감하고 중요한 정보에 해당되기 때문에 로직 상에 기록하지 않고 보다 안전한 파일 혹은 데이터베이스에서 가져오는 방식을 취했습니다. 이 경우에는, application.properties에 해당 정보가 보관되어 있습니다.

 

프로젝트의 application.properties를 다음과 같은 정보를 추가합니다. application.properties 내부의 값에 대한 key의 이름과 @Value 애노테이션 내부의 값이 일치한다면, 스프링은 자동으로 application.properties 내의 값을 해당 필드에 주입합니다.

custom.authentication.code=1234

 

ProjectConfiguration

@Configuration
@EnableWebSecurity(debug = true)
@RequiredArgsConstructor
public class ProjectConfiguration {

    private final RequestValidationFilter requestValidationFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.httpBasic();

        http
                .addFilterBefore(requestValidationFilter, BasicAuthenticationFilter.class)
                .addFilterAfter(new AuthenticationLogFilter(), BasicAuthenticationFilter.class)
                .addFilterAt(new SecretCodeAuthenticationFilter(), BasicAuthenticationFilter.class);

        return http.build();
    }
}

다음으로 ProjectConfiguration로 돌아와 filterChain 메소드에 addFilterAt 코드를 추가합니다. 이후 프로젝트를 실행해 테스트용으로 localhost:8080을 호출해 봅니다. 다음과 같이 결과가 나온다면 성공입니다.

 

보통 addFilterAt이라는 메소드로 특정 필터의 위치에 다른 필터를 추가한다면, 새로 추가되는 필터가 기존의 필터를 덮어쓸 것 같지만, 위의 결과에서 보이듯 사실 그렇지 않습니다. 같은 위치에 조금 더 높은 우선순위로 놓이게 되고, 기존 필터 또한 실행됩니다. 만약 같은 필터의 위치에 (addFilterAt을 여러 번 사용해서) 여러 개의 커스텀 필터를 등록한다면 신규 등록된 두개 이상의 필터들의 순서는 시스템에 의해 결정되므로 개발자는 시스템이 실행되는 순간 이전에는 알 수 없습니다. 따라서 정교한 순서를 요구하는 필터를 여러 개 배치해야 할 때는 addFilterAt보다는 이전에 본 addFilterBefore과 addFilterAfter를 이용하는 것이 좋습니다.

 

연습: 이제 포스트맨을 켠 후, Secret-Access-Code의 값을 바꿔보며 요청을 보내봅니다. 서버에서 내려오는 에러 코드를 보며 어떻게 하면 404 에러(에러이지만 이 경우는 정상 동작 호출)가 출력될 수 있을지 고민해봅니다.

 

🧠 인증에 실패했는데 인증 필터 뒤에 있는 AuthenticationLogFilter가 실행된다구요?

이는 스프링 시큐리티의 필터가 한 번 호출되지 않기 때문에 발생하는 이유로, AuthenticationLogFilter가 Filter 인터페이스를 구현하지 않고, OncePerRequestFilter를 상속하도록 코드를 수정하면 해결할 수 있습니다. 보다 자세한 정보는 아래의 링크에서 확인 부탁드립니다.

 

https://minkukjo.github.io/framework/2020/12/18/Spring-142/

 

OncePerRequestFilter와 Filter의 차이

OncePerRequestFilter와 Filter

minkukjo.github.io