study/spring-security-in-action

9장 필터 구현

fd27 2023. 9. 28. 11:54

이 블로그 내용은 아래 도서를 학습한 내용입니다.
학습 도서 정보
스프링 시큐리티 인 액션(Spring Security in Action)
로렌티우 스필카 지음
최민석 옮김
위키북스


  • 필터 체인 이용하기
  • 맞춤형 필터 정의
  • Filter 인터페이스를 구현하는 스프링 시큐리티 클래스 이용

스프링 시큐리티에서 HTTP 필터는 HTTP 요청에 적용되는 다양한 책임을 위임한다. 

  • 인증필터: 인증 책임을 인증 관리자에게 위임
  • 권한 부여 필터: 인증 성공 후 권한 부여 구성을 처리

스프링 시큐리티의 HTTP 필터는 일반적으로 요청에 적용해야 하는 각 책임을 관리하며 책임의 체인을 형성한다.

필터는 요청을 수신하고 그 논리를 실행하며 최종적으로 체인의 다음 필터에 요청을 위임한다.

 

 

스프링 시큐리티는 필터 체인을 원하는 방식으로 모델링할 수 있는 유연성을 제공한다.

  • 사용자 이메일 주소 검사를 추가해 인증을 보강
  • 일회용 암호(OTP)를 이용하는 단계를 추가해 인증을 보강
  • 인증 감사가 이용되는 동안의 디버깅
  • 다른 인증 전략을 구현
  • 권한 부여 이벤트에 대해 외부 시스템에 알림
  • 인증, 권한 부여 이후 추적과 감사에 필요한 성공 또는 실패 기록

 

9.1 스프링 시큐리티 아키텍처의 필터 구현

스프링 시큐리티 아키텍처의 필터는 일반적인 HTTP 필터다. 필터를 만들려면 javax.servlet 패키지의 Filter 인터페이스를 구현한다. 다른 HTTP 필터와 마찬가지로 doFilter() 메서드를 재정의해 논리를 구현한다.

  • ServletRequest: HTTP 요청을 나타낸다. ServletRequest 객체를 이용해 요청에 대한 세부 정보를 얻는다.
  • ServletResponse: HTTP 응답을 나타낸다. ServletResponse 객체를 이용해 응답을 클라이언트로 다시 보내기 전에 또는 더 나아가 필터 체인에서 응답을 변경한다.
  • FilterChain: 필터 체인을 나타낸다. FilterChain 객체는 체인의 다음 필터로 요청을 전달한다.

필터 치엔은 필터가 작동하는 순서가 정의된 필터의 모음을 나타낸다. 스프링 시큐리티에는 몇 가지 필터 구현과 순서가 있다.

  • BasicAuthenticationFilter는 HTTP Basic 인증을 처리한다.
  • CsrfFilter는 CSRF(사이트 간 요청 위조)를 처리한다.
  • CorsFilter는 CORS(교차 출처 리소스 공유) 권한 부여 규칙을 처리한다.

애플리케이션이 필터 체인에 이러한 모든 필터의 인스턴스를 반드시 가질 필요는 없다. 필터 체인은 애플리케이션을 구성하는 방법에 따라 더 길어지거나 짧아질 수 있다. 예를들어 Basic 인증 방식을 이용하기 위해 httpBasic() 메서드를 호출하면 필터 체인에 BasicAuthenticationFilter가 추가된다. 이처럼 개발자가 작성하는 구성에 따라 필터 체인의 정의가 영향을 받는다.

 

각 필터에는 순서 번호가 있다. 이 순서 번호에 따라 요청에 필터가 적용되는 순서가 결정된다. 스프링 시큐리티가 제공하는 필터와 함께 맞춤형 필터를 추가할 수 있다.

새 필터는 필터 체인의 다른 필터를 기준으로 추가된다. 

기존 필터 위치 또는 앞이나 뒤에 새 필터를 추가할 수 있다.

 

같은 위치에 필터를 두 개 이상 추가할 수 있다. 하지만 이는 개발자들 사이에 혼란을 유발한다.
여러 필터가 같은 위치에 있으면 필터가 호출되는 순서는 정해지지 않는다.

 

9.2 체인에서 기존 필터 앞에 필터 추가

 

 

* 애플리케이션 요구사항 *

모든 요청에 Request-Id 헤더가 있다고 가정한다.

이 애플리케이션은 이 헤더로 요청을 추적하므로 헤더는 필수이며 인증을 수행하기 전에 헤더가 있는지 검증하려고 한다. 인증 프로세스에는 데이터베이스 쿼리나 다른 리소스를 소비하는 작업이 포함될 수 있으므로 요청의 형식이 유효하지 않으면 이런 작업을 실행할 필요가 없다.

 

 

1. 필터를 구현한다. 요청에 필요한 헤더가 있는지 확인하는 RequestValidationFilter 클래스를 만든다.

2. 필터 체인에 필터를 추가한다. 구성 클래스에서 configure() 메서드를 재정의해 필터 체인에 필터를 추가한다.

 

 

 

public class RequestValidationFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        var httpRequest = (HttpServletRequest) request;
        var httpResponse = (HttpServletResponse) response;
        String requestId = httpRequest.getHeader("Request-Id");

        if (requestId == null || requestId.isBlank()) {
            httpResponse.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            return;
            // 헤더가 없으면 HTTP 상태가 '400 잘못된 요청'으로 바뀌고 
            // 요청이 필터 체인의 다음 필터로 전달되지 않는다.
        }

        filterChain.doFilter(request, response);
        // 헤더가 있으면 요청을 필터 체인의 다음 필터로 전달한다.
    }
}

 

curl -v http://localhost:8080/hello

*   Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.1.2
> Accept: */*
>
< HTTP/1.1 400
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 1; mode=block
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Content-Length: 0
< Date: Thu, 28 Sep 2023 14:30:56 GMT
< Connection: close
<
* Closing connection 0

 

curl -H "Request-Id:12345" http://localhost:8080/hello

Hello!%

 

 

9.3 체인에서 기존 필터 뒤에 필터 추가

* 애플리케이션 요구사항 *

인증 프로세스 다음에 로깅을 실행

 

public class AuthenticationLoggingFilter implements Filter {

    private final Logger logger =
            Logger.getLogger(AuthenticationLoggingFilter.class.getName());

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        var httpRequest = (HttpServletRequest) request;
        String requestId = httpRequest.getHeader("Request-Id");
        // 요청 ID의 값과 이벤트 기록
        logger.info("Successfully authenticated request with id " +  requestId);
        // 요청을 필터 체인의 다음 필터에 전달
        filterChain.doFilter(request, response);
    }
}
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.
                addFilterBefore(
                new RequestValidationFilter(),
                BasicAuthenticationFilter.class)
                // 필터 체인에서 인증 필터 다음에 AuthenticationLoggingFilter의 인스턴스 추가
            .addFilterAfter(
                new AuthenticationLoggingFilter(),
                BasicAuthenticationFilter.class)
            .authorizeRequests()
                .anyRequest()
                    .permitAll();
    }
}

 

9.4 필터 체인의 다른 필터 위치에 필터 추가

이제까지 인증 방식 중 BasicAuthetnication과 FormLoginAuthentication 방식을 살펴보았다. 이 두가지 인증방식 이외의 다른 인증 방식을 원한다. 그런데 스프링 시큐리티의 기능을 그대로 사용하고 싶다면 어떻게 해야하는가? BasicAuthentication이나 FormLoginAuthentication의 필터 대신에 커스텀 필터를 위치 시키면 된다. 

 

커스텀 인증방식 예시

  • 인증을 위한 정적 헤더 값에 기반을 둔 식별
  • 대칭 키를 이용해 인증 요청 서명
  • 인증 프로세스에 OTP(일회용 암호) 이용

 

단, 커스텀 필터를 위치시키되

1. basicAuthentication이나 FormLogingAuthentication은 활성화 시키지 않는다.

2. 스프링시큐리티는 기본적으로 UserDetailsService를 구성하게 된다. 만약 커스텀한 인증 방식에서 UserDetailsService를 사용하지 않는다면, 비활성화 시킨다.

@SpringBootApplication(exclude = {UserDetailsServiceAutoConfiguration.class })

 

 

인증을 위한 정적 헤더 값에 기반을 둔 식별 예시

// 구성 클래스에서 값을 주입할 수 있도록 스프링 컨텍스트에 클래스의 인스턴스 추가
@Component
public class StaticKeyAuthenticationFilter implements Filter {

    // 예제를 간단하기 위해 키를 속성 파일에 작성, 운영서버스에서는 볼트에 저장하는 것이 좋다.
    @Value("${authorization.key}")
    private String authorizationKey;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        var httpRequest = (HttpServletRequest) request;
        var httpResponse = (HttpServletResponse) response;

        // 요청의 Authorization 헤더에서 값을 얻고 정적 헤더와 비교
        String authentication = httpRequest.getHeader("Authorization");

        if (authorizationKey.equals(authentication)) {
            filterChain.doFilter(request, response);
        } else {
            httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        }
    }
}
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {

    // 스프링 컨텍스트에서 필터의 인스턴스를 주입
    @Autowired
    private StaticKeyAuthenticationFilter filter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 필터 체인에서 기본 인증 필터의 위치에 필터 추가
        http.addFilterAt(filter, BasicAuthenticationFilter.class)
                .authorizeRequests().anyRequest().permitAll();
    }

}
@SpringBootApplication(exclude = {UserDetailsServiceAutoConfiguration.class })
public class Main {

    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }

}
❯ curl -H "Authorization:SD9cICjl1e" http://localhost:8080/hello
Hello!%

 

9.5 스프링 시큐리티가 제공하는 필터 구현

이절에서는 스프링 시큐리티에 기본적으로 있고 Filter 인터페이스를 구현하는 클래스에 관해 알아본다. 이 단원의 에제에서는 이 인터페이스를 직접 구현해 필터를 정의한다.

스프링 시큐리티에는 Filter 인터페이스를 구현하는 여러 추상 클래스가 있으며, 이를 위해 필터 정의를 확장할 수 있다. 또한 이러한 클래스는 구현을 확장할 때 이점을 얻을 수 있는 기능을 추가한다.

 

로깅 기능은 같은 요청이 여러 번 기록되지 않게 해야하므로 OncePerRequestFilter를 이용할 수 있는 좋은 후보다. 스프링 시큐리티는 필터가 두 번 이상 호출되지 않게 보장하지 않으므로 개발자가 직접 처리해야 한다. 가장 쉬운 방법은 OncePerRequestFilter 클래스를 이용해 필터를 구현하는 것이다.

 

// Filter 인터페이스를 구현하지 않고 OncePerRequestFilter 클래스를 확장
public class AuthenticationLoggingFilter extends OncePerRequestFilter {

    private final Logger logger =
            Logger.getLogger(AuthenticationLoggingFilter.class.getName());


    // Filter 인터페이스의 doFilter() 메서드의 용도를 대체하는 doFilterInternal() 재정의
    // OncePerRequestFilter는 HTTP 필터만 지원한다.
    // 이 때문에 HttpServletRequest 및 HttpServletResponse로 매개변수를 직접 지정했다.
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String requestId = request.getHeader("Request-Id");

        logger.info("Successfully authenticated request with id " +  requestId);

        filterChain.doFilter(request, response);
    }
}
@Configuration
public class ProjectConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.addFilterBefore(
                new RequestValidationFilter(),
                BasicAuthenticationFilter.class)
            .addFilterAfter(
                new AuthenticationLoggingFilter(),
                BasicAuthenticationFilter.class)
            .authorizeRequests()
                .anyRequest()
                    .permitAll();
    }
}

 

OncePerRequestFilter 클래스에 관해 알아둘 사항

참고: https://junhyunny.github.io/spring-boot/once-per-request-filter/

  • HTTP 요청만 지원하지만 사실은 항상 이것만 이용한다.
    • 이 클래스의 장점은 형식을 형 변환하면 HttpServletReqeust 및 HttpServletResponse로 직접 요청을 수신한다는 것이다. Filter 인터페이스의 경우에는 요청과 응답을 형 변환해야 한다.
  • 필터가 적용될지 결정하는 논리를 구현할 수 있다.
    • 필터 체인에 추가한 필터가 특정 요청에는 적용되지 않는다고 결정할 수 있다. 이 경우 shouldNotFilter(HttpServletRequest) 메서드를 재정의하면 된다. 기본적으로 필터는 모든 요청에 적용 된다.
  • OncePerRequestFilter는 기본적으로 비동기 요청이나 오류 발송 요청에는 적용되지 않는다.
    • 이 동작을 변경하려면 shouldNotFilterAsyncDispatch() 및 shouldNotFilterErrorDispatch() 메서드를 재정의하면 된다.

요약

  • 웹 애플리케이션의 첫 번째 계층은 HTTP 요청을 가로채는 필터 체인이다. 스프링 시큐리티 아키텍처의 다른 구성 요소는 요구 사항에 맞게 맞춤 구성할 수 있다.
  • 필터 체인에서 기존 필터 위치 또는 앞이나 뒤에 새 필터를 추가해 필터 체인을 맞춤 구성할 수 있다.
  • 기존 필터와 같은 위치에 여러 필터를 추가할 수 있으며 이 경우 필터가 실행되는 순서는 정해지지 않는다.
    • (같은 위치에 여러 필터를 지정하는 것은 안티패턴)
  • 필터 체인을 변경하면 애플리케이션의 요구 사항에 맞게 인증과 권한 부여를 맞춤 구성하는데 도움이 된다.