[Spring Security] Spring Security에서 커스텀 필터 만들기

10000JI·2024년 2월 9일
0

Spring Boot

목록 보기
10/15

Spring Security 내 필터 소개 및 사용법

초기에 우리는 웹 애플리케이션 내의 필터와 서블릿에 대해 공부했었다.

Java 웹 애플리케이션에서 어떤 역할을 하는지 알아보았었다.

필터 개념과 역할

필터는 Java 웹 애플리케이션의 구성 요소 중 하나이다.

웹 애플리케이션 내의 모든 요청이나 응답을 가로채고 차단하는 데 사용될 수 있다.

실제 비즈니스 로직 전에 실행되는 사전 로직을 수행할 수 있다.

Spring Security 필터

Spring Security 프레임워크는 웹 애플리케이션 내의 보안을 강제한다.

내부 흐름 중 필터는 인증 및 권한 부여 흐름에서 중요한 역할을 한다.

Spring Security 내장 필터들은 인증 및 권한 부여 프로세스에 관여한다.

커스텀 필터의 필요성

프로젝트의 특정 상황이나 요구 사항에 따라 인증 및 권한 부여 흐름 중 일부를 수정해야 할 때 필요하다.

이러한 상황에서는 자체적인 커스텀 필터를 작성하여 프로세스를 조정할 수 있다.

Spring Security FilterChain(필터 체인)

Spring Security FilterChain(필터 체인)은 인증을 시도할 때 실행되는 필터들의 연쇄이다.

이 필터 체인은 다양한 내장 필터들로 구성되어 있으며, 연쇄적으로 실행된다.

유저 인증 중에 Spring Security에서 활용된 몇 가지 내장 필터를 살펴보았었다.

UsernamePasswordAuthenticationFilter, BasicAuthenticationFilter 그리고 DefaultLoginPageGenerationFilter 등을 정리했었다.

필터에 관한 중요한 정보 중 하나는 인증을 시도하려고 할 때 실행되는 필터의 수가 상당히 많다는 것이다. 자체적인 역할과 책임을 갖는 10개 이상의 Spring Security 필터가 실행된다.

이러한 필터가 실행되는 방식은 연쇄적이다. 필터 체인 내에서 모든 필터가 필터 1이 실행된 후 필터 2, 필터 3으로 하나씩 진행된다.

그래서 이 체인은 체인 내에서 마지막 필터에 도달할 때까지 다음 필터를 계속해서 동작시킨다.

내장된 필터는 Spring Seucirty를 어떻게 구성하거나 인증을 시도하는지에 따라 어떻게 변경되는지에 따라 다양하다

Spring Security 내장 필터

내장 필터 확인하기

Spring Security 프레임워크에서 유저를 인증할 때 사용하는 내장 필터를 살펴보자.

필터를 확인하기 위해 웹 애플리케이션 설정을 변경해야 한다.

변경 사항 적용하기

  1. @EnableWebSecurity 추가

    Spring Boot 애플리케이션의 주 클래스에 @EnableWebSecurity을 추가한다.

    이 어노테이션에는 (debug = true)로 설정하여 디버그를 활성화한다.

  2. application.properties 수정

    application.properties 파일에서 FilterChainProxy 클래스디버그 로깅을 활성화하는 설정을 추가한다.

    FilterChainProxy 클래스는 Spring Security 프레임워크 내부에서 내장 필터를 연결하는 로직에 사용된다.

    FilterChainProxy 안에는 VirualFilterChain이라는 내부 FilterChain 구현한 클래스가 있다. 이는 들어오는 요청을 해당하는 내부 필터 목록을 통해 전달하는 역할을 한다.

    VirualFilterChaindoFilter()을 살펴보면 Spring Security 필터 체인 내에서 사용가능한 모든 필터를 반복하는 로직이 있다.

    currentPosition이 어디인지를 추적할 수 있다.

    if(this.currentPosition == this.size): 만약 currentPosition이 필터의 총 size와 같다면 다음 필터(=nextFilter.doFilter())는 호출되지 않는다.
    대신에 this.originalChain.doFilter(request,response)를 호출한다.

    if문 조건에 해당하지 않다면 이 체인 내에 가능한 다음 필터(=nextFilter.doFilter())를 불러온다.

이렇게하면 Spring Security 내의 활성화된 모든 내부 필터를 하나씩 반복하면서 각각의 필터 내부의 로직이 실행된다.

주의사항

이러한 변경 사항은 실제 운영 환경에서 사용해서는 안 된다.

보안 위협을 초래할 수 있으며, 민감한 정보가 로그에 기록될 수 있다.

디버깅 모드 실행하기

백엔드 서버를 시작한 후 디버깅 모드로 실행한다.

콘솔에 'Security debugging is enabled' 메시지가 표시되면 설정이 성공적으로 적용된 것이다.

중단점 추가하여 필터 체인 확인하기

Spring Security 필터 체인을 확인하기 위해 중단점을 추가한다.

디버그 콘솔에서 내장 필터들을 확인하고, 각 필터의 역할과 책임을 이해한다.

(1) 현재 필터의 총 size는 17이며 이는 Spring Security가 약 17개의 필터를 실행한다는 뜻이다.

currentPosition이 0이므로 if문에 들어가지 않을 것이다.

(2) 추가 필터 목록을 열면 Spring Security 프레임워크에서 실행되는 모든 필터이다.

(3) 각 필터는 doFilter() 메소드를 호출해서 하나씩 반복된다.

엔드 유저에 인증 중에 실행된 내부 Spring Security 필터들을 볼 수 있다. 따라서 인증 중에 Spring Security의 많은 내부 필터가 실행되고 있음을 확인할 수 있다.

사용자 정의 필터 생성법

커스텀 필터 작성하기

자체적인 커스텀 필터를 정의하고 실행하는 방법에 대해 알아보자.

필터를 작성하기 위해 jakarta.servlet 패키지 내의 Filter 인터페이스를 확장한다.

필터 구현하기

Filter 인터페이스를 구현하는 클래스에서 doFilter 메소드를 구현한다.

doFilter 메소드ServletRequest, ServletResponseFilterChain을 매개변수로 받아 필터링 로직을 실행한다.

Spring Security에 커스텀 필터 주입 시

이미 알려진 필터의 위치 전, 후, 해당 위치에 추가 가능하다.

addFilterBefore, addFilterAfteraddFilterAt 메소드를 사용하여 필터를 주입한다.

필터 인터페이스 살펴보기

jakarta.servlet 패키지에 위치한 Filter 인터페이스를 살펴봅니다.
이 인터페이스는 Java Enterprise Edition에서 사용할 수 있으며, doFilter 외에 initdestroy 메소드를 포함한다.

Filter 인터페이스 메소드 분석하기

initdestroy 메소드는 필수적이지 않으며, 필요에 따라 오버라이드하여 비즈니스 로직을 구현할 수 있다.

doFilter 메소드는 커스텀 필터의 주된 로직을 담당한다.

addFilterBefore() 메소드로 사용자 정의 필터 추가

커스텀 필터 추가

Spring Security FilterChain에 커스텀 필터를 추가해보자.

addFilterBefore 메소드를 사용하여 여러 커스텀 필터 중 CorsFilter 또는 CsrfFilter를 먼저 실행하고 BasicAuthenticationFilter를 그 뒤에 실행한다.

인증 전 로직 실행

BasicAuthenticationFilter 직전에 실행될 커스텀 필터를 만들어 구성하자.

BasicAuthenticationFilter 내에서 자격 증명을 추출하기 전에 선행되는 로직을 커스텀 필터에 구현한다.

RequestValidationFilter 구현

package com.eazybytes.springsecsection2.filter;

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.util.StringUtils;

import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

import static org.springframework.http.HttpHeaders.AUTHORIZATION;

public class RequestValidationBeforeFilter implements Filter {

    public static final String AUTHENTICATION_SCHEME_BASIC = "Basic";
    private Charset credentialsCharset = StandardCharsets.UTF_8;
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        //HttpServletRequest으로 형변환
        HttpServletRequest req = (HttpServletRequest) request;
        //HttpServletResponse으로 형변환
        HttpServletResponse res = (HttpServletResponse) response;
        //Angular로 부터 받은 Header에서 `AUTHORIZATION`을 얻는다
        String header = req.getHeader(AUTHORIZATION);
        //AUTHORIZATION header를 확보하면
        if (header != null) {
            //공백 제거 후
            header = header.trim();
            if (StringUtils.startsWithIgnoreCase(header, AUTHENTICATION_SCHEME_BASIC)) {
                //header 내의 여섯 번째 부분에서 문자열 추출 (`basic `은 제거하고 추출)
                byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
                byte[] decoded;
                try {
                    //base64Token를 디코딩
                    decoded = Base64.getDecoder().decode(base64Token);
                    String token = new String(decoded, credentialsCharset);
                    //':' 를 중심으로 문자열 분리
                    int delim = token.indexOf(":");
                    if (delim == -1) {
                        throw new BadCredentialsException("Invalid basic authentication token");
                    }
                    //':' 를 중심으로 email만 추출
                    String email = token.substring(0, delim);
                    //email이 "test"문자열이라면
                    if (email.toLowerCase().contains("test")) {
                        //400번
                        res.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                        return;
                    }
                } catch (IllegalArgumentException e) {
                    throw new BadCredentialsException("Failed to decode basic authentication token");
                }
            }
        }
        //filterChain의 도움으로 다음 필터로 넘어감
        chain.doFilter(request, response);
    }
}

RequestValidationFilter를 만들어 BasicAuthenticationFilter 앞에 구현한다.

이 필터는 유저 이름에 'test'를 포함한 경우 유효하지 않은 요청으로 처리한다.

프로젝트 내의 filter 패키지RequestValidationBeforeFilter를 생성하여 jakarta.servlet 패키지의 Filter 인터페이스구현한다.

doFilter 메소드를 오버라이드하여 필터링 로직을 구현한다.

HttpServletRequestHttpServletResponse로 형변환하여 요청과 응답을 처리한다.

Authorization header에서 유저 이름을 추출하여 'test' 값을 확인하고, 해당 경우에는 400 에러를 반환한다.

Angular에서 app.request.interceptor.ts를 살펴보면 Basic이라는 값 뒤에 ' ' 공백이 하나 있고, Authorization header를 보낸다.
그리고 이메일과 비밀번호의 base64 값을 콜론으로 구분해서 보내려고 시도한다.

그렇지 않은 경우에는 FilterChain을 호출하여 다음 필터를 실행한다.

ProjectSecurityConfig 클래스에서 addFilterBefore 메소드를 호출하여 커스텀 필터를 구성한다.

Spring Security FilterChain에 주입 - addFilterBefore()

package com.eazybytes.springsecsection2.config;

import com.eazybytes.springsecsection2.filter.CsrfCookieFilter;
import com.eazybytes.springsecsection2.filter.RequestValidationBeforeFilter;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.preauth.RequestAttributeAuthenticationFilter;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;

import javax.sql.DataSource;
import java.util.Collections;

@Configuration
public class ProjectSecurityConfig {

    //람다(Lambda) DSL 스타일 사용을 권장
    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
        requestHandler.setCsrfRequestAttributeName("_csrf");

        http.securityContext((context) -> context.requireExplicitSave(false))
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
                //CorsConfigurationSource 인터페이스를 구현하는 익명 클래스 생성하여 getCorsConfiguration() 메소드 재정의
                .cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
                    //`etCorsConfiguration() 메소드에서 CorsConfiguration 객체를 생성하고 필요한 설정들을 추가
                    @Override
                    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
                        CorsConfiguration config = new CorsConfiguration();
                        //허용할 출처(도메인)를 설정
                        config.setAllowedOrigins(Collections.singletonList("http://localhost:4200"));
                        //허용할 HTTP 메소드를 설정
                        config.setAllowedMethods(Collections.singletonList("*"));
                        //인증 정보 허용 여부를 설정
                        config.setAllowCredentials(true);
                        //허용할 헤더를 설정
                        config.setAllowedHeaders(Collections.singletonList("*"));
                        //CORS 설정 캐시로 사용할 시간을 설정
                        config.setMaxAge(3600L);
                        return config;
                    }
                })).csrf((csrf) -> csrf.csrfTokenRequestHandler(requestHandler).ignoringRequestMatchers("/register","/contact")
                        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
                .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
                .addFilterBefore(new RequestValidationBeforeFilter(),BasicAuthenticationFilter.class)
                .authorizeHttpRequests((requests)->requests
                        .requestMatchers("/myAccount").hasRole("USER")
                        .requestMatchers("/myBalance").hasAnyRole("USER","ADMIN")
                        .requestMatchers("/myLoans").hasRole("USER")
                        .requestMatchers("/myCards").hasRole("USER")
                        .requestMatchers("/user").authenticated()
                        .requestMatchers("/notices","/contact","/register").permitAll())
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }


    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

만들어준 커스텀 필터를 적용시키기 위해 Spring Security FilterChain 내에 addFilterBefore(new RequestValidationBeforeFilter(),BasicAuthenticationFilter.class) 을 추가하여 BasicAuthenticationFilter가 실행하기 전에 RequestValidationBeforeFilter가 실행되도록 설정해준다.

필터 실행 확인

로그인 플로우를 테스트하여 커스텀 필터가 올바르게 동작하는지 확인한다.

커스텀 필터 RequestValidationFilterBasicAuthenticationFilter 직전에 정상적으로 실행되는 것을 확인할 수 있다.

이메일에 'test' 값을 포함한 경우에는 400 에러가 발생함을 확인한다.

주의사항

커스텀 필터를 추가할 때에는 실행 시간이 오래 걸리는 로직을 사용하지 않도록 주의해야 한다.

필수적인 로직만을 포함하여 비즈니스 요구사항을 충족시키자

addFilterAfter() 메소드로 사용자 정의 필터 추가

커스텀 필터 주입

BasicAuthenticationFilter 다음에 Custom AuthenticationFilter를 주입하여 인증 후 비즈니스 로직을 실행해보자.

Custom AuthenticationFilter 구현

인증이 마무리 되면 몇 가지 비즈니스 로직을 실행해야 하는 조건이 있다고 생각해보자.

이런 상황에서 또한 BasicAuthenticationFilter를 선택할 수 있다.

만약 우리가 BasicAuthenticationFilter 바로 다음에 우리의 Custom AuthenticationFilter을 실행한다고 하면 조건에 부합할 것이다.

Custom AuthenticationFilter에 작성할 것은 커스텀 필터 내에 로거를 추가하는 것으로 어떠한 유저 인증이 성공적이고 그가 이러한 권한들을 가졌다를 보여주는 것이다.

인증이 성공하면 엔드 유저에게 로그, 감사 혹은 이메일을 보내는 상황이라고 가정해보자.

Spring Security FilterChain에 추가

addFilterAfter() 메소드를 사용하여 Spring Security FilterChain 내에 커스텀 필터를 추가한다.

첫 번째 매개변수는 커스텀 필터의 객체이고, 두 번째 매개변수는 주입될 필터의 클래스 이름이다.

커스텀 필터 추가

AuthoritiesLoggingAfterFilter 클래스를 생성하여 Filter 인터페이스를 구현한다.

doFilter() 메소드를 구현하여 인증된 유저의 세부 정보를 로깅하는 로직을 작성한다.

비즈니스 로직 실행

SecurityContextHolder를 사용하여 현재 인증된 유저의 정보를 가져온다.

유저가 인증되었을 경우 해당 유저의 이름과 권한을 로그한다.

AuthoritiesLoggingAfterFilter 구현

package com.eazybytes.springsecsection2.filter;

import jakarta.servlet.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

import java.io.IOException;
import java.util.logging.Logger;

public class AuthoritiesLoggingAfterFilter implements Filter {

    private final Logger LOG =
            Logger.getLogger(AuthoritiesLoggingAfterFilter.class.getName());
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        //현재 인증된 유저의 세부정보를 Authentication(인증) 객체의 형태로 제공
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        //authentication이 null이 아니라면 (=인증에 성공했다면)
        if (null != authentication) {
            //인증된 유저 이름과 유저의 권한을 로깅
            LOG.info("User " + authentication.getName() + " is successfully authenticated and "
                    + "has the authorities " + authentication.getAuthorities().toString());
        }
        //체인 내의 다음 필터를 호출
        chain.doFilter(request, response);
    }
}

AuthoritiesLoggingAfterFilter 만들어 BasicAuthenticationFilter 뒤에 구현한다.

이 필터의 목적은 로그인하려는 유저 인증이 성공적이라면 그의 이름 및 권한들을 보여주는 것이다.

프로젝트 내의 filter 패키지에 AuthoritiesLoggingAfterFilter 생성하여 jakarta.servlet 패키지의 Filter 인터페이스를 구현한다.

doFilter 메소드를 오버라이드하여 필터링 로직을 구현한다.

현재 인증된 유저의 세부 정보를 Authentication(인증) 객체로 받아, null이 아니라면 인증된 유저이름과 권한을 로깅한다.

Spring Security FilterChain에 주입 - addFilterAfter()

package com.eazybytes.springsecsection2.config;

import com.eazybytes.springsecsection2.filter.AuthoritiesLoggingAfterFilter;
import com.eazybytes.springsecsection2.filter.CsrfCookieFilter;
import com.eazybytes.springsecsection2.filter.RequestValidationBeforeFilter;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.preauth.RequestAttributeAuthenticationFilter;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;

import javax.sql.DataSource;
import java.util.Collections;

@Configuration
public class ProjectSecurityConfig {

    //람다(Lambda) DSL 스타일 사용을 권장
    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
        requestHandler.setCsrfRequestAttributeName("_csrf");

        http.securityContext((context) -> context.requireExplicitSave(false))
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
                //CorsConfigurationSource 인터페이스를 구현하는 익명 클래스 생성하여 getCorsConfiguration() 메소드 재정의
                .cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
                    //`etCorsConfiguration() 메소드에서 CorsConfiguration 객체를 생성하고 필요한 설정들을 추가
                    @Override
                    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
                        CorsConfiguration config = new CorsConfiguration();
                        //허용할 출처(도메인)를 설정
                        config.setAllowedOrigins(Collections.singletonList("http://localhost:4200"));
                        //허용할 HTTP 메소드를 설정
                        config.setAllowedMethods(Collections.singletonList("*"));
                        //인증 정보 허용 여부를 설정
                        config.setAllowCredentials(true);
                        //허용할 헤더를 설정
                        config.setAllowedHeaders(Collections.singletonList("*"));
                        //CORS 설정 캐시로 사용할 시간을 설정
                        config.setMaxAge(3600L);
                        return config;
                    }
                })).csrf((csrf) -> csrf.csrfTokenRequestHandler(requestHandler).ignoringRequestMatchers("/register","/contact")
                        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
                .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
                .addFilterBefore(new RequestValidationBeforeFilter(),BasicAuthenticationFilter.class)
                .addFilterAfter(new AuthoritiesLoggingAfterFilter(),BasicAuthenticationFilter.class)
                .authorizeHttpRequests((requests)->requests
                        .requestMatchers("/myAccount").hasRole("USER")
                        .requestMatchers("/myBalance").hasAnyRole("USER","ADMIN")
                        .requestMatchers("/myLoans").hasRole("USER")
                        .requestMatchers("/myCards").hasRole("USER")
                        .requestMatchers("/user").authenticated()
                        .requestMatchers("/notices","/contact","/register").permitAll())
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }


    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

ProjectSecurityConfig 클래스에서 addFilterAfter() 메소드를 사용하여 커스텀 필터를 Spring Security FilterChain에 주입한다.

AuthoritiesLoggingAfterFilterBasicAuthenticationFilter 다음에 주입하여 실행 순서를 정한다.

실행 확인

로그인 시 AuthoritiesLoggingAfterFilter가 실행되며 인증된 유저의 세부 정보를 로그한다.

인증 객체 내의 누구의 인증이 성공적인지 엔드 유저의 세부정보를 볼 수 있다.

BasicAuthenticationFilter 다음에 커스텀 필터 AuthoritiesLoggingAfterFilter가 올바르게 실행되었음을 확인한다.

addFilterAt() 메소드로 사용자 정의 필터 추가

addFilterAt 메소드 소개

Spring Security 내부 필터들 중에 하나의 Filter 위치와 정확히 같은 위치에서 커스텀 필터를 구성할 수 있다.

내부 필터는 체인 내부에서 실행될 예정이지만 이것은 매우 까다롭다.

Spring Security가 내부적으로 필터를 무작위로 실행하는 점에 대한 주의 해야 한다.

언제나 비즈니스 로직에 부작용이 없도록 로직을 작성해야 한다.

이 메서드의 목적을 데모로 보여주기 위해 LoggingFilter를 만들어보자. 이 필터 내에서는 인증이 진행 중이라는 새로운 문장을 로그에 남길 것이다.

실제 애플리케이션에는 addFilterAt()을 사용하는 시나리오가 많지 않을 것이다. 대부분은 addFilterBefore() 혹은 addFilterAfter()를 사용한다.

addFilterAt()를 사용되는 경우를 생각해보면 사용자에게 인증이 진행 중임을 알리기 위해 이메일을 보내거나 인증이 성공적이라고 알리기 위해 이메일을 보내거나 내부 응용프로그램에게 알림을 보내는 등의 요구사항이 있을 수 있다.

커스텀 필터 구현: AuthoritiesLoggingAtFilter

package com.eazybytes.springsecsection2.filter;

import jakarta.servlet.*;

import java.io.IOException;
import java.util.logging.Logger;

public class AuthoritiesLoggingAtFilter implements Filter {

    //로거 객체 생성
    private final Logger LOG =
            Logger.getLogger(AuthoritiesLoggingAtFilter.class.getName());

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        //동일한 LOG 객체를 사용하여 인증 검증이 진행 중이라는 문장을 정보로 기록
        LOG.info("Authentication Validation is in progress");
        //체인 내의 다음 필터를 호출
        chain.doFilter(request, response);
    }
}

인증 진행 중임을 알리는 로그를 남기는 AuthoritiesLoggingAtFilter 클래스를 생성한다.

Java의 Filter 인터페이스 구현하여 doFilter 메소드 재정의

커스텀 필터 로직 구현

로그를 남기기 위해 Java 라이브러리의 로거(logger)를 사용한다.

인증 진행 중임을 알리는 문장을 로그에 남기고 FilterChain 내의 다음 필터를 호출한다.

Spring Security FilterChain에 추가

package com.eazybytes.springsecsection2.config;

import com.eazybytes.springsecsection2.filter.AuthoritiesLoggingAfterFilter;
import com.eazybytes.springsecsection2.filter.AuthoritiesLoggingAtFilter;
import com.eazybytes.springsecsection2.filter.CsrfCookieFilter;
import com.eazybytes.springsecsection2.filter.RequestValidationBeforeFilter;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.preauth.RequestAttributeAuthenticationFilter;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;

import javax.sql.DataSource;
import java.util.Collections;

@Configuration
public class ProjectSecurityConfig {

    //람다(Lambda) DSL 스타일 사용을 권장
    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
        requestHandler.setCsrfRequestAttributeName("_csrf");

        http.securityContext((context) -> context.requireExplicitSave(false))
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
                //CorsConfigurationSource 인터페이스를 구현하는 익명 클래스 생성하여 getCorsConfiguration() 메소드 재정의
                .cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
                    //`etCorsConfiguration() 메소드에서 CorsConfiguration 객체를 생성하고 필요한 설정들을 추가
                    @Override
                    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
                        CorsConfiguration config = new CorsConfiguration();
                        //허용할 출처(도메인)를 설정
                        config.setAllowedOrigins(Collections.singletonList("http://localhost:4200"));
                        //허용할 HTTP 메소드를 설정
                        config.setAllowedMethods(Collections.singletonList("*"));
                        //인증 정보 허용 여부를 설정
                        config.setAllowCredentials(true);
                        //허용할 헤더를 설정
                        config.setAllowedHeaders(Collections.singletonList("*"));
                        //CORS 설정 캐시로 사용할 시간을 설정
                        config.setMaxAge(3600L);
                        return config;
                    }
                })).csrf((csrf) -> csrf.csrfTokenRequestHandler(requestHandler).ignoringRequestMatchers("/register","/contact")
                        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
                .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
                .addFilterBefore(new RequestValidationBeforeFilter(),BasicAuthenticationFilter.class)
                .addFilterAfter(new AuthoritiesLoggingAfterFilter(),BasicAuthenticationFilter.class)
                .addFilterAt(new AuthoritiesLoggingAtFilter(),BasicAuthenticationFilter.class)
                .authorizeHttpRequests((requests)->requests
                        .requestMatchers("/myAccount").hasRole("USER")
                        .requestMatchers("/myBalance").hasAnyRole("USER","ADMIN")
                        .requestMatchers("/myLoans").hasRole("USER")
                        .requestMatchers("/myCards").hasRole("USER")
                        .requestMatchers("/user").authenticated()
                        .requestMatchers("/notices","/contact","/register").permitAll())
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults());
        return http.build();
    }


    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

ProjectSecurityConfig 클래스에서 addFilterAt 메소드 호출하여 커스텀 필터를 구성한다.

첫 번째 매개변수는 커스텀 필터의 객체이고, 두 번째 매개변수는 주입될 필터의 클래스 이름이다.

  • 첫 번째 매개변수: AuthoritiesLoggingAtFilter 객체

  • 두 번째 매개변수: 동일한 위치에 배치할 Spring Security 내부 필터 클래스 이름 (BasicAuthenticationFilter.class 등)

실행 확인

애플리케이션에서 로그인 후 AuthoritiesLoggingAtFilter가 올바르게 실행되는지 확인한다.

인증 유효성 검사 진행 중 이라는 문장이 콘솔에 찍히는 것을 확인할 수 있다.

BasicAuthenticationFilter와 같은 위치에 커스텀 필터가 성공적으로 구성됨을 확인한다.

.addFilterAt(new AuthoritiesLoggingAtFilter(),BasicAuthenticationFilter.class)AuthoritiesLoggingAtFilterBasicAuthenticationFilter의 필터 실행 순서가 서로 무작위로 결정되므로 로직 작성 시 주의가 필요하다.

GenericFilterBean과 OncePreRequestFilter 설명

커스텀 필터를 생성할 때마다 사용할 수 있는 다른 옵션들은 어떤 것이 있는지 알아보자.

이전까지 필터라는 인터페이스를 가지고 구현하였다. 그리고 필터를 구현할 때는 비즈니스 로직을 doFilter()라는 메소드를 오버라이딩하여 작성했다.

그러나 필터 인터페이스 외에도 Spring 사용 시에는 다른 옵션들도 있다.

GenericFilterBean

추상 클래스로, 필터 인터페이스의 기본적인 구현을 제공한다

설정 매개변수, 초기 매개변수, 서블릿 컨텍스트 매개변수에 대한 세부 정보를 제공한다.

getEnvironment()(환경 정보), getFilterConfig()(필터 구성 정보), getServletContext()(서블릿 컨텍스트 정보), initFilterBean()(초기 매개변수) 등 다양한 메소드를 제공한다.

설정 매개변수에 접근하여 비즈니스 로직을 구현할 때 유용하다

OncePerRequestFilter

커스텀 필터를 만들고 해당 필터를 Spring Security FilterChain에 구성하려고 할 때 기본적으로 Spring Security는 해당 필터가 각 요청에 대해 한 번만 실행되도록 보장하지 않는다.

여러 이유로 서블릿 컨테이너가 필터를 여러 번 호출할 수 있는 상황이 발생할 수 있다.

그러나 필터가 반드시 요청 당 한 번만 실행해야 하는 경우 이 추상 클래스OncePerRequestFilter를 확장하여 커스텀 필터를 정의해야 한다.

= 각 요청마다 한 번만 실행됨을 보장하는 로직을 내장하고 있다.

필터를 건너 뛰고 한 번만 실행되도록 하는 모든 로직이 이 doFilter() 메서드에 정의되어 있다.

만약 OncePerRequestFilter가 이미 doFilter() 메소드를 오버라이드하여 구현했다면 나만의 비즈니스 로직은 어디에 작성해야 하는가?

바로 이 doFilterInternal() 메소드를 통해 비즈니스 로직을 정의한다.

shouldNotFilter() 메소드 등 유용한 메소드 제공하여 특정 요청에 필터를 적용할지 여부 결정이 가능하다.

예를 들어 shouldNotFilter()는 예외적인 상황이 있을 때 이 필터를 실행하고 싶지 않다면 해당 세부 정보를 정의하고 false 값을 반환하는 대신에 조건에 따라 true를 반환할 수 있다.

GenericFilterBean vs OncePerRequestFilter

GenericFilterBean: 설정 매개변수에 접근하여 비즈니스 로직을 구현할 때 유용하다.

OncePerRequestFilter: 각 요청에 대해 한 번만 실행되도록 보장하며, 특정 요청에 필터를 적용하는 경우 유용하다.

Spring Security에서의 활용

BasicAuthenticationFilter와 같은 Spring Security 필터는 OncePerRequestFilter를 사용하여 구현된다.

OncePerRequestFilter를 활용하여 요청 당 한 번만 실행되는 필터를 정의하고자 할 때 사용한다.

가능하다면 OncePerRequestFilter를 사용하여 커스텀 필터를 만드는 것을 추천한다.

profile
Velog에 기록 중

0개의 댓글