티스토리 뷰

지난 글에서 스프링 시큐리티의 전반적인 필터 구조 및 흐름을 살펴봤다. 이번 글에서는 스프링 시큐리티가 기본적으로 제공하는 DefaultSercurityFilterChain(일련의 시큐리티 관련 작업을 수행하는 필터들의 묶음) 내 필터들의 종류와 각 필터의 역할에 대해 간단히? 알아보고, 해당 필터들을 기반으로 커스텀 필터를 생성하고 등록해보자. 
 

스프링 시큐리티의 DefaultSecurityFilterChain

스프링 시큐리티의 필터 구조는 DelegatingFilterChain  FilterChainProxy  SecurityFilterChain  List<Filter> 순으로 구조화되어 있다고 했다. 참고 이때 스프링 시큐리티가 기본적으로 제공하는 DefaultSecurityFilterChain(SecurityFilterChain 인터페이스의 구현 클래스)이 있는데 이는  FilterChainProxy에 중단점을 찍어서 확인할 수 있다. 


FilterChainProxy는 내부에 여러 SecurityFilterChain을 관리하며 이 중 특정 요청을 처리할 수 있는 하나의 SecurityFilterChain에게 처리를 위임한다. 스프링 시큐리티 의존성을 추가하고 아무 설정 클래스도 생성하지 않았을 때에 기본적으로 DefaultSecurityFilterChain 하나만 존재하며, 해당 클래스는 다시 (현재는) 16개의 Filter에 의존하게 된다.

 

여러 종류의 Filter, List<Filter>

DefaultSecurityFilterChain이 의존하는 기본 필터 목록

 

이렇게 스프링 시큐리티는 내부적으로 필터 체인을 유지하며 각각의 필터들은 로그아웃, 로그인 등 주요한 로직을 담당한다. 따라서 서비스 요구사항에 따라 특정 역할을 수행하는 필터가 추가되거나 제거될 수 있다.

실제로 각 필터들은 주요 로직을 직접 실행하기 보다는, 요청을 가로 채 스프링 컨테이너 내 시큐리티 관련 빈에게 요청 처리를 위임한다.(각 로직의 시작점 역할을 함) 즉 DefaultSecurityFilterChain 내부에 등록된 여러 필터들을 거치면서 특정 요청에 대해 로그인 처리가 필요하다면 로그인 관련 필터가 이를 가로 채고 실제 로그인 처리는 스프링 컨테이너에 등록된 시큐리티 관련 빈들에게 위임하는 것이다. 요청 처리가 완료된 후 다시 로그인 관련 필터로 되돌아 오고 다시 다음 필터로 요청 흐름이 이어진다. (필터 내부적으로 모두 처리할 수 있을 만큼 간단한 작업이라면 시큐리티 관련 빈에게 요청 처리 위임하지 않고 직접 처리하기도 한다.)

 
 

GenericFilterBean & OncePerRequestFilter

시큐리티 관련 필터들은 모두 GenericFilterBean 또는 OncePerRequestFilter 추상 클래스를 상속하고 있다. 이때 GenericFilterBean는 자바 서블릿 필터 기반으로, 자바 서블릿 영역에서 스프링 영역에 접근할 수 있도록 구현되어 있다. OncePerRequestFilter GenericFilterBean를 상속한 추상 클래스로, 둘의 가장 큰 차이점은 GenericFilterBean는 필터를 여러번 통과한다면 통과 수만큼 내부 로직 실행되는 반면, OncePerRequestFilter는 클라이언트의 한번 요청에 대해 해당 필터를 여러번 거칠 경우 딱 한번만 로직이 실행된다는 것이다.

이때 중요한 것은 클라이언트의 요청이 기준이라는 것이다. 즉 302 redirect 상황에서는 실제 클라이언트 → 서버로 총 2번의 요청을 보내는 것으로 OncePerRequestFilter가 2번 동작하는 반면, 서버 내부적으로 재요청을 하는 forward의 경우에는 1번만 동작하게 된다.

뒤에서 설명하겠지만 커스텀 필터를 SecurityFilterChain에 등록하기 위해 GenericFilterBean 또는 OncePerRequestFilter를 구현한 필터를 생성할 것이다. 그럼 이제 본격적으로 DefaultSecurityFilterChain에 등록되는 16개의 기본 필터들의 역할과 동작 원리를 살펴보자. 조금 길 수 있으니 끊어 읽으면 좋을듯 하다. (각 필터의 주요 코드 및 로직을 중점적으로 살펴보고자 하였으며 더 자세한 내용은 첨부한 Spring Security Docs를 참고하시길. 기본적으로 스프링 시큐리티로 인증, 인가 로직을 한번이라도 구현해보고 관련 코드를 대략적으로 까본적이 있다는 것을 가정하고 글을 썼다.)

 

DisableEncodeUrlFilter

필터 체인의 가장 첫번째에 위치하는 필터로, URL로 간주되지 않는 부분(보안 위협이 존재할 수 있는 부분)을 포함하지 않도록 설정하는 역할을 한다. 이게 뭔말인가 하면, 세션 기반 로그인을 구현한 경우 기본적으로 응답의 URL 파라미터에 세션 ID가 인코딩되어 전달되거나 로그로 남게 된다. 세션 ID가 유출되면 세션 공격이 가능해지기 때문에 이를 방지하는 역할을 DisableEncodeUrlFilter가 하는 것이다. 물론 세션 기반 로그인과 관련 있는 필터이기 때문에 다음과 같이 스프링 시큐리티의 세션 관리 설정을 disable 하면 비활성화 되며, 커스텀 SecurityFilterChain을 생성해도 자동 등록된다. 

@EnableWebSecurity
@Configuration
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.sessionManagement(config -> config.disable());
        return http.build();
    }
}

 
DisableEncodeUrlFilter doFilterInternal 메소드에서 다음 필터를 호출 시 DisableEncodeUrlResponseWrapper로 현재 response 객체를 한번더 감싸서 전달하는 것을 확인할 수 있다. DisableEncodeUrlResponseWrapperDisableEncodeUrlFilter의 정적 내부 클래스로, 오버라이드된 메소드를 보면 순수 url을 그대로 반환하고 있다.

public class DisableEncodeUrlFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
        filterChain.doFilter(request, new DisableEncodeUrlResponseWrapper(response)); //response 객체를 wrapping
    }
    
    private static final class DisableEncodeUrlResponseWrapper extends HttpServletResponseWrapper {

        private DisableEncodeUrlResponseWrapper(HttpServletResponse response) {
            super(response);
        }

        @Override
        public String encodeRedirectURL(String url) {
            return url; //순수 URL만 반환
        }

        @Override
        public String encodeURL(String url) {
            return url;
        }
    }
}

 
그럼 기존의 HttpServletResponse 구현체인 org.apache.catalina.connector.Response encodedRedirectURL, encodeURL 메소드는 어떻게 정의되어 있길래 세션 ID가 파라미터로 전달되는지 확인해보자.

public class Response implements HttpServletResponse {

    public String encodeRedirectURL(String url) {
        return this.isEncodeable(this.toAbsolute(url)) ? this.toEncoded(url, this.request.getSessionInternal().getIdInternal()) : url;
    }

    public String encodeURL(String url) {
        String absolute;
        try {
            absolute = this.toAbsolute(url);
        } catch (IllegalArgumentException var4) {
            return url;
        }

        if (this.isEncodeable(absolute)) {
            if (url.equalsIgnoreCase("")) {
                url = absolute;
            } else if (url.equals(absolute) && !this.hasPath(url)) {
                url = url + "/";
            }

            return this.toEncoded(url, this.request.getSessionInternal().getIdInternal());
        } else {
            return url;
        }
    }
    
    //URL에 세션 ID 추가
    protected String toEncoded(String url, String sessionId) {
        if (url != null && sessionId != null) {
            String path = url;
            String query = "";
            String anchor = "";
            int question = url.indexOf(63);
            if (question >= 0) {
                path = url.substring(0, question);
                query = url.substring(question);
            }

            int pound = path.indexOf(35);
            if (pound >= 0) {
                anchor = path.substring(pound);
                path = path.substring(0, pound);
            }

            StringBuilder sb = new StringBuilder(path);
            if (sb.length() > 0) {
                sb.append(';');
                sb.append(SessionConfig.getSessionUriParamName(this.request.getContext()));
                sb.append('=');
                sb.append(sessionId); //세션 ID가 쿼리 파라미터로 전달
            }

            sb.append(anchor);
            sb.append(query);
            return sb.toString();
        } else {
            return url;
        }
    }
}

encodedRedirectURL, encodeURL 메소드에서는 toEncoded를 호출하고 있으며, toEncoded 메소드는 기존 url에 세션 ID값을 쿼리 파라미터로 추가하여 반환하고 있다. 이렇게 세션 ID가 응답으로 전달되어 탈취될 가능성을 막고자 DisableEncodeUrlFilter가 동작하는 것이다. 

 

 

DisableEncodeUrlFilter (spring-security-docs 6.3.2 API)

org.springframework.web.filter.OncePerRequestFilter org.springframework.security.web.session.DisableEncodeUrlFilter

docs.spring.io

 

 

WebAsyncManagerIntegrationFilter

WebAsyncManagerIntegrationFilter는 비동기로 처리되는 작업에 대해 알맞은 SecurityContext를 설정해주는 역할을한다. 즉 서블릿 단에서 비동기 작업을 수행할 때 서블릿 입출력 쓰레드와 작업 쓰레드가 동일한 SecurityContextHolder SecurityContext를 참조하도록 도와주는 것이다. 다시 말해 하나의 요청을 처리하는데 여러 쓰레드가 사용될 경우 모두 같은 SecurityContext를 참조할 수 있도록 싱크를 맞춰주는 역할을 한다.

 

SecurityContextHolder SecurityContext 기본 관리 전략은 ThreadLocal로, 따라서 동일한 쓰레드에서만 동일한  SecurityContext에 접근 가능하다. (SecurityContextHolderStrategy 내부에 ThreadLocal<Supplier<SecurityContext>> 필드로 SecurityContext 객체를 쓰레드 로컬 저장소에서 관리하고 있다.) 비동기 방식의 경우 하나의 작업을 2개 이상의 쓰레드로 수행하기 때문에 이 부분을 보완하기 위해 해당 필터가 필요하다. (커스텀 SecurityFilterChain을 생성해도 항상 자동 등록)
 

@Slf4j
@RestController
public class AsyncController {

    @GetMapping("/async")
    public Callable<String> asyncTest() {
        log.info("Thread = {}, SecurityContext = {}", Thread.currentThread().getName(), SecurityContextHolder.getContext().hashCode());
        return () -> {
            Thread.sleep(4000);
            log.info("Thread = {}, SecurityContext = {}", Thread.currentThread().getName(), SecurityContextHolder.getContext().hashCode());
            return "async";
        };
    }
}
Thread = http-nio-8080-exec-2, SecurityContext = -1114173449
Thread = task-1, SecurityContext = -1114173449

위 컨트롤러를 직접 호출해보면 각기 다른 쓰레드에서 로그가 출력되지만 같은 SecurityContext 객체를 참조하는 것을 확인할 수 있다. 그럼 어떻게 컨트롤러 단에서 발생하는 멀티 쓰레드 문제를 필터에서 처리할 수 있는 것일까?? 이를 이해하기 위해서는 WebAsyncManagerIntegrationFilter와 반환 타입이 Callable인 컨트롤러의 동작 방식을 먼저 살펴봐야 한다. 
 
WebAsyncManagerIntegrationFilter는 실제로 요청 쓰레드(서블릿 컨테이너가 요청 하나를 처리하기 위해 할당한 쓰레드)의 SecurityContext를 관리하는 SecurityContextCallableProcessingInterceptor WebAyncManager에 등록하는 작업만을 수행하다. 실질적인 SecurityContext의 동기화 작업은 MVC 단에서 수행되는 것이다. 

public final class WebAsyncManagerIntegrationFilter extends OncePerRequestFilter {

    //...

    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
        SecurityContextCallableProcessingInterceptor securityProcessingInterceptor = (SecurityContextCallableProcessingInterceptor)asyncManager.getCallableInterceptor(CALLABLE_INTERCEPTOR_KEY);
        if (securityProcessingInterceptor == null) {
            SecurityContextCallableProcessingInterceptor interceptor = new SecurityContextCallableProcessingInterceptor();
            interceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
            asyncManager.registerCallableInterceptor(CALLABLE_INTERCEPTOR_KEY, interceptor); //인터셉터 등록
        }

        filterChain.doFilter(request, response);
    }
}

 
그리고 Callable 객체를 반환하는 컨트롤러 호출 시 동작 원리는 아주 간단하게 말하자면, Controller  CallableMethodReturnValueHandler  WebAsyncManager  SecurityContextCallableProcessingInterceptor가 차례대로 실행된다. 이 과정에서 미리 WebAsyncManagerIntegrationFilter를 통해 등록한 SecurityContextCallableProcessingInterceptor를 이용해 같은 SecurityContext 객체에 접근할 수 있는 것이다. 
 
컨트롤러가 Callable 객체를 반환하는 경우 동작하는 핸들러를 좀더 살펴보면, handleReturnValue 메소드에서 비동기 작업을 WebAyncManager startCallableProcessing 메소드를 호출함으로써 처리를 위임하고 있다. 

public class CallableMethodReturnValueHandler implements HandlerMethodReturnValueHandler {
    public CallableMethodReturnValueHandler() {
    }

    public boolean supportsReturnType(MethodParameter returnType) {
        return Callable.class.isAssignableFrom(returnType.getParameterType());
    }

    public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
        if (returnValue == null) {
            mavContainer.setRequestHandled(true);
        } else {
            Callable<?> callable = (Callable)returnValue;
            WebAsyncUtils.getAsyncManager(webRequest).startCallableProcessing(callable, new Object[]{mavContainer}); //callable 객체의 비동기 작업을 위임
        }
    }
}

 
코드가 좀 긴데, 주요하게 볼 메소드 호출 흐름은 startCallableProcessing  applyBeforeConcurrentHandling  applyPreProcess  call 순이다. applyBeforeConcurrentHandling 메소드에서는 본격적인 비동기 작업 처리 전 현재 요청 쓰레드의 쓰레드 로컬에 저장된 SecurityContext 객체를 SecurityContextCallableProcessingInterceptor의 내부 필드로 초기화, 즉 다른 쓰레드에서도 접근할수 있도록 SecurityContext 객체를 임시 저장해둔다. 그리고 applyPreProcess 메소드에서는 현재 작업 쓰레드의 쓰레드 로컬에 앞서 임시 저장했던 SecurityContext 객체를 옮겨 놓는다. 따라서 call 메소드 내에서 SecurityContextHolder를 통해 SecurityContext에 접근하더라도 (분명 다른 쓰레드임에도 불구하고) 같은 객체를 참조할 수 있는 것이다. 

public final class WebAsyncManager {

    //WebAsyncManagerIntegrationFilter에서 SecurityContextCallableProcessingInterceptor 등록을 위해 호출되는 메소드
    public void registerCallableInterceptors(CallableProcessingInterceptor... interceptors) {
        CallableProcessingInterceptor[] var2 = interceptors;
        int var3 = interceptors.length;

        for(int var4 = 0; var4 < var3; ++var4) {
            CallableProcessingInterceptor interceptor = var2[var4];
            String var10000 = interceptor.getClass().getName();
            String key = var10000 + ":" + interceptor.hashCode();
            this.callableInterceptors.put(key, interceptor);
        }
    }
    
    public void startCallableProcessing(Callable<?> callable, Object... processingContext) throws Exception {
        this.startCallableProcessing(new WebAsyncTask(callable), processingContext);
    }

    public void startCallableProcessing(final WebAsyncTask<?> webAsyncTask, Object... processingContext) throws Exception {
        if (!this.state.compareAndSet(WebAsyncManager.State.NOT_STARTED, WebAsyncManager.State.ASYNC_PROCESSING)) {
            throw new IllegalStateException("Unexpected call to startCallableProcessing: [" + this.state.get() + "]");
        } else {
            List<CallableProcessingInterceptor> interceptors = new ArrayList();
            interceptors.add(webAsyncTask.getInterceptor());
            interceptors.addAll(this.callableInterceptors.values()); //WebAsyncManagerIntegrationFilter에서 등록한 SecurityContextCallableProcessingInterceptor 추가
            interceptors.add(timeoutCallableInterceptor);
            
            Callable<?> callable = webAsyncTask.getCallable();
            CallableInterceptorChain interceptorChain = new CallableInterceptorChain(interceptors);

            //타임아웃, 에러, 종료 핸들러 등록

            interceptorChain.applyBeforeConcurrentHandling(this.asyncWebRequest, callable); //현재 요청 쓰레드 로컬에 저장된 SecurityContext를 인터셉터 내부 필드에 저장
            this.startAsyncProcessing(processingContext);

            try {
                Future<?> future = this.taskExecutor.submit(() -> {
                    Object result = null;

                    try {
                        interceptorChain.applyPreProcess(this.asyncWebRequest, callable); //SecurityContextCallableProcessingInterceptor 내부 필드에 저장된 SecurityContext를 현재 Callable 실행 쓰레드의 쓰레드 로컬에 저장
                        result = callable.call(); //callable 실행
                    } catch (Throwable var8) {
                        Throwable ex = var8;
                        result = ex;
                    } finally {
                        result = interceptorChain.applyPostProcess(this.asyncWebRequest, callable, result); //현재 Callable 실행 쓰레드의 SecurityContext 초기화
                    }
                    //...
                });
                interceptorChain.setTaskFuture(future);
            } catch (Throwable var10) {
                //...
            }
        }
    }
    //...
}
public final class SecurityContextCallableProcessingInterceptor implements CallableProcessingInterceptor {

    private volatile SecurityContext securityContext;
    private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();

    public SecurityContextCallableProcessingInterceptor() {
    }

    public SecurityContextCallableProcessingInterceptor(SecurityContext securityContext) {
        this.setSecurityContext(securityContext);
    }

    //현재 요청 쓰레드의 쓰레드 로컬의 SecurityContext로 내부 필드 초기화
    public <T> void beforeConcurrentHandling(NativeWebRequest request, Callable<T> task) {
        if (this.securityContext == null) {
            this.setSecurityContext(this.securityContextHolderStrategy.getContext());
        }
    }

    //내부 필드로 작업 쓰레드의 쓰레드 로컬의 SecurityContext 객체 초기화
    public <T> void preProcess(NativeWebRequest request, Callable<T> task) {
        this.securityContextHolderStrategy.setContext(this.securityContext);
    }

    //작업 쓰레드의 쓰레드 로컬의 SecurityContext 객체 무효화
    public <T> void postProcess(NativeWebRequest request, Callable<T> task, Object concurrentResult) {
        this.securityContextHolderStrategy.clearContext();
    }

    public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) {
        this.securityContextHolderStrategy = securityContextHolderStrategy;
    }

    private void setSecurityContext(SecurityContext securityContext) {
        this.securityContext = securityContext;
    }
}

 
그러나 아쉽게도 해당 필터는 Callable 객체를 통한 컨트롤러 단에서의 비동기 작업만을 보완해주며 @Async 방식의 비동기 작업에는 별도의 설정이 필요하다. 이것까지 알아보면 글의 주제와 벗어나고 글이 너무 길어지므로 일단 패스한다.
 

 

WebAsyncManagerIntegrationFilter (Spring Security 4.0.4.RELEASE API)

protected void doFilterInternal(javax.servlet.http.HttpServletRequest request, javax.servlet.http.HttpServletResponse response, javax.servlet.FilterChain filterChain) 

docs.spring.io

 

 

SecurityContextHolderFilter

접근한 유저에 대해 시큐리티 컨텍스트를 관리하는 필터로, 이전 요청을 통해 이미 인증된 사용자 정보를 현재 요청의 SecurityContextHolder SecurityContext에 할당하는 역할을 수행하며, 현재 요청이 끝나면 자동으로 SecurityContext를 무효화한다. 세션 기반 로그인에서 유지되고 있는 세션의 유저 정보를 SecurityContext에 저장하기 위해 주로 사용되며, JWT 기반 로그인에서도 응답 전달 시 현재 요청에 대한 SecurityContext를 자동 무효화해주기 때문에 해당 필터가 유용하게 사용될 수 있다. (특히 SecurityContext는 기본적으로 쓰레드 로컬 저장소를 사용하기 때문에, 쓰레드 풀을 통해 쓰레드를 재사용하는 서블릿 컨테이너에서는 이를 초기화 해주는 것이 중요하다.) 해당 필터는 다음과 같이 비활성화 할 수 있다. (커스텀 SecurityFilterChain 생성 시에도 자동 등록)

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.securityContext(config -> config.disable());
        return http.build();
    }
}

 
SecurityContextHolderFilter의 코드를 좀더 살펴보자.
이전 요청에서 사용자가 로그인을 진행한 경우, 서버 세션 또는 레디스와 같은 저장 매체에 로그인한 유저 정보가 존재한다. 이를 SecurityContextRepository loadDeferredContext 메소드를 호출하여 저장된 정보를 조회한다. 참고로 loadDeferredContext는 로딩을 지연시켜 SecurityContext를 조회하는 동작인 Supplier를 반환하고, 필요 시점에 Supplier를 실행시켜 SecurityContext를 조회해오도록 한다. (없다면 Authentication 객체가 null인 SecurityContext 반환) 이렇게 조회된 유저 정보를 SecurityContextHolder에 저장하고 다음 필터를 호출한다. 이후 요청 처리가 끝나 되돌아 오는 경우에는 SecurityContext를 무효화 해준다.

public class SecurityContextHolderFilter extends GenericFilterBean {

    private static final String FILTER_APPLIED = SecurityContextHolderFilter.class.getName() + ".APPLIED";
    private final SecurityContextRepository securityContextRepository;
    private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();

    //...

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        if (request.getAttribute(FILTER_APPLIED) != null) { //해당 필터가 한번 동작했다면 다음 필터로 넘어감
            chain.doFilter(request, response);
        } else {
            request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
            Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request); //저장된 SecurityContext 조회

            try {
                this.securityContextHolderStrategy.setDeferredContext(deferredContext); 
                chain.doFilter(request, response);
            } finally {
                this.securityContextHolderStrategy.clearContext(); //SecurityContext 무효화
                request.removeAttribute(FILTER_APPLIED);
            }

        }
    }
}

 

여기서 SecurityContext의 저장 매체에 따라 SecurityContextRepository의 다른 구현체를 사용한다. 주요 구현체는 다음과 같다.
- HttpSessionSecurityContextRepository: SecurityContext HttpSession에 저장해 영속성을 유지

- RequestAttributeSecurityContextRepository: SecurityContext ServletContext에 저장해 후속 요청 시 영속성 유지 불가능 
- NullSecurityContextRepository: 빈 SecurityContext 객체 반환 (세션을 사용하지 않는 JWT, OAuth2 인증 방식 등 stateless하게 동작하는 경우 사용)
- DelegatingSecurityContextRepository: 기본 설정돼 내부적으로 HttpSessionSecurityContextRepository, RequestAttributeSecurityContextRepository 리스트의 delegates 필드 관리

 

이 외에도 직접 SecurityContextRepository를 구현하여 아래와 같이 설정해줄 수 있다.

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.securityContext(config -> config.securityContextRepository(new NullSecurityContextRepository()));
    return http.build();
}

  

 

SecurityContextPersistenceFilter vs SecurityContextHolderFilter

SecurityContextHolderFilter SecurityContextPersistenceFilter 후속으로 시큐리티 5.8 버전부터 내부 구현이 변경되면서 기존 클래스는 deprecated 되었다. 둘의 기능은 거의 동일하며 가장 큰 차이점은 응답이 되돌아올 때 변경된 SecurityContext 객체를 저장소에 재저장할 것인지 여부이다.

이전 클래스는 SecurityContext가 변경되면 변경된 객체를 SecurityContextRepository 저장소에 업데이트한 반면, 현재 클래스(SecurityContextHolderFilter)는 변경된 객체를 재저장하지 않는다. 즉 이전까지 사용됐던 SecurityContextPersistenceFilter saveContext 메서드를 강제로 수행해 세션에 인증 정보를 저장했는데, SecurityContextHolderFilter는 사용자가 명시적으로 saveContext를 실행하도록 한다.

 

인증 필터에서 인증 처리 시, 필요한 경우에만 인증 정보를 세션에 저장해야 한다. 따라서 SecurityContextHolderFilter 필터는 시큐리티가 무조건 인증 정보를 저장하도록 강제하기 보다는, 인증 매커니즘에 따라 다르게 구현할 수 있도록 유연성을 제공한 것이다. 

@Deprecated
public class SecurityContextPersistenceFilter extends GenericFilterBean {

    static final String FILTER_APPLIED = "__spring_security_scpf_applied";
    private SecurityContextRepository repo;
    private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
        .getContextHolderStrategy();
    private boolean forceEagerSessionCreation = false;

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

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
	        throws IOException, ServletException {
        if (request.getAttribute(FILTER_APPLIED) != null) {
            chain.doFilter(request, response);
            return;
        }
        request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
        
        if (this.forceEagerSessionCreation) {
            HttpSession session = request.getSession();
        }
        HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
        SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
        try {
            this.securityContextHolderStrategy.setContext(contextBeforeChainExecution);
            //...
            chain.doFilter(holder.getRequest(), holder.getResponse());
        }
        finally {
            SecurityContext contextAfterChainExecution = this.securityContextHolderStrategy.getContext();
            this.securityContextHolderStrategy.clearContext();
            this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse()); //인증 정보 세션에 항상 저장
            request.removeAttribute(FILTER_APPLIED);
        }
    }
}

 

그러나 항상 인증 정보를 세션에 저장하도록 설정하고 싶다면 다음과 같이 SecurityConfig를 작성해주면 되고, 이 경우에는 SecurityContextHolderFilter가 아닌 SecurityContextPersistenceFilter가 필터 체인에 등록된다. 

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.securityContext(config -> config.requireExplicitSave(false));
        return http.build();
    }
}

 

 

 

Persisting Authentication :: Spring Security

The first time a user requests a protected resource, they are prompted for credentials. One of the most common ways to prompt for credentials is to redirect the user to a log in page. A summarized HTTP exchange for an unauthenticated user requesting a prot

docs.spring.io

 

 

HeaderWriterFilter

HTTP 응답 헤더에 사용자 보호를 위한 시큐리티 관련 헤더를 추가해주는 필터로, 아래 설정을 통해 비활성화 할 수 있다. (커스텀 SecurityFilterChain 생성 시에도 자동 등록)

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.headers(config -> config.disable());
        return http.build();
    }
}

 
HeaderWriterFilter 코드를 살펴보면 헤더를 설정하는 타이밍이 요청 처리(filterChain.doFilter 호출) 전/후인지에 따라 각기 다른 메소드를 호출한다. (기본값은 false로 요청을 모두 처리 후 헤더를 설정) 그리고 실제 헤더에 write하는 책임은 HeaderWriter 리스트에게 위임한다.

public class HeaderWriterFilter extends OncePerRequestFilter {

    private final List<HeaderWriter> headerWriters;
    private boolean shouldWriteHeadersEagerly = false;

    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (this.shouldWriteHeadersEagerly) {
            this.doHeadersBefore(request, response, filterChain); //요청 처리 전
        } else {
            this.doHeadersAfter(request, response, filterChain); //요청 처리 후
        }

    }

    private void doHeadersBefore(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        this.writeHeaders(request, response);
        filterChain.doFilter(request, response);
    }

    private void doHeadersAfter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        HeaderWriterResponse headerWriterResponse = new HeaderWriterResponse(request, response);
        HeaderWriterRequest headerWriterRequest = new HeaderWriterRequest(request, headerWriterResponse);

        try {
            filterChain.doFilter(headerWriterRequest, headerWriterResponse);
        } finally {
            headerWriterResponse.writeHeaders();
        }

    }

    void writeHeaders(HttpServletRequest request, HttpServletResponse response) {
        Iterator var3 = this.headerWriters.iterator();

        while(var3.hasNext()) {
            HeaderWriter writer = (HeaderWriter)var3.next();
            writer.writeHeaders(request, response);
        }

    }
}


HeaderWriterFilter가 동작함에 따라 기본 등록된 XContentTypeOptionsHeaderWriter, XXssProtectionHeaderWriter, CacheControlHeadersWriter, HstsHeaderWriter, XFrameOptionsHeaderWriter들이 순서대로 동작해 아래 7개의 응답 헤더가 기본 설정된다.

 

Cache-Control: no-cache, no-store, max-age=0, must-revalidate 
Pragma: no-cache 
Expires: 0 
X-Content-Type-Options: nosniff 
Strict-Transport-Security: max-age=31536000 ; includeSubDomains 
X-Frame-Options: DENY 
X-XSS-Protection: 1; mode=block

 

 

물론 원하는 응답 헤더를 커스텀 설정할 수도 있다. 

 

Security HTTP Response Headers :: Spring Security

Refer to the relevant sections to see how to customize the defaults for both servlet and webflux based applications. Historically, browsers, including Internet Explorer, would try to guess the content type of a request by using content sniffing. This allow

docs.spring.io

 
  

CorsFilter

먼저 Cors란? Corss-Origin Resource Sharing(교차 출처 리소스 공유)의 약자로, 출처가 다른 서버 간의 리소스 공유를 의미한다. 여기서 출처는 프로토콜, 도메인(호스트 이름), 포트로 구성되며 이 중 하나라도 다르다면 다른 출처로 간주된다. 브라우저는 본래 보안 상의 이유로 출처가 다른 리소스에 대한 접근을 차단하지만, 출처가 다르더라도 요청과 응답을 주고 받을 수 있도록 서버에 리소스 호출이 허용된 출처(Origin)을 명시해주는 것이 바로 Cors 정책이다. 참고  
 

cors 에러를 방지하기 위해서는 허용하는 출처 관련 데이터를 CorsConfigurationSource에 설정해주는 것이 필요하며, 이 설정값을 기반으로 CorsFilter가 cors 관련 응답 헤더를 설정해주는 역할을 한다. CorsConfigurationSource는 아래와 같이 스프링 빈으로 등록할 수도 있으며 여러 커스텀 SecurityFilterChain을 설정하는 경우에는 configurationSource 메소드를 통해 직접 설정해줄 수도 있다. 아래 설정을 통해 이를 비활성화 할 수 있으며 커스텀 SecurityFilterChain 생성 시에도 자동 등록된다. 

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.cors(
            config -> config.disable()
//                            .configurationSource(corsConfigurationSource()) //이렇게도 설정 가능
        );
        return http.build();
    }
    
    @Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList("https://example.com"));
        configuration.setAllowedMethods(Arrays.asList("GET","POST"));
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

 
 

스프링 시큐리티 뿐만 아니라 스프링 MVC에서도 아래와 같은 WebConfig 클래스 정의 또는 @CorssOrigin 어노테이션을 통해 cors 설정이 가능하다. 시큐리티, MVC 각 cors 설정의 동작 방식이 어떻게 다른지 아주 간단히만 보고 넘어가자. 

@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {

        registry.addMapping("/api/**")
		    .allowedOrigins("https://domain2.com")
            .allowedMethods("PUT", "DELETE")
            .allowedHeaders("header1", "header2", "header3")
            .exposedHeaders("header1", "header2")
            .allowCredentials(true).maxAge(3600);
    }
}

 

MVC에서의 cors 설정은 스프링 MVC의 HandlerMapping(AbstractHandlerMapping 클래스의 getHandler 메소드 내에서 getCorsHandlerExecutionChain 메소드를 호출하여 cors 설정)와 Interceptor(CorsInterceptor)가 동작하는 반면, Security에서의 cors 설정은 org.springframework.web.filter 패키지의 CorsFilter가 동작한다. (스프링 MVC의 cors 설정이 나중에 동작하므로 MVC의 cors 설정이 우선하지 않을까 싶다... 확실하지 않음) 아무튼 스프링 시큐리티 의존성을 추가할 경우 MVC, Security 모두 cors 설정을 할 필요 없이 다음과 같이 설정해주면 시큐리티에서도 MVC의 cors 설정을 그대로 사용할 수 있다. 

@EnableWebSecurity
@Configuration
public class WebSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // if Spring MVC is on classpath and no CorsConfigurationSource is provided,
            // Spring Security will use CORS configuration provided to Spring MVC
            .cors(Customizer.withDefaults());
        return http.build();
    }
}


마지막으로 CorsFilter의 코드를 살펴보자. 필터가 동작하는 doFilterInternal 메소드 내에서 CorsProcessor processRequest 메소드를 호출함으로써 cors 관련 응답 헤더를 설정한 후 현재 요청이 pre-flight 요청이 아니라면 다음 필터로 넘어가고 있다. (위에서 언급했듯 해당 필터는 스프링 시큐리티가 제공하는 필터가 아니기 때문에 시큐리티 의존성을 추가하지 않고도 사용할 수 있다.)

public class CorsFilter extends OncePerRequestFilter {

    private final CorsConfigurationSource configSource;
    private CorsProcessor processor = new DefaultCorsProcessor();

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {

        CorsConfiguration corsConfiguration = this.configSource.getCorsConfiguration(request);
        boolean isValid = this.processor.processRequest(corsConfiguration, request, response); //cors 관련 응답 헤더 설정
        if (!isValid || CorsUtils.isPreFlightRequest(request)) {
            return;
        }
        filterChain.doFilter(request, response);
    }
}
public class DefaultCorsProcessor implements CorsProcessor {

    //...

    //cors 관련 응답 헤더 설정
    @Override
    @SuppressWarnings("resource")
    public boolean processRequest(@Nullable CorsConfiguration config, HttpServletRequest request,
	    HttpServletResponse response) throws IOException {

        Collection<String> varyHeaders = response.getHeaders(HttpHeaders.VARY);
        if (!varyHeaders.contains(HttpHeaders.ORIGIN)) {
            response.addHeader(HttpHeaders.VARY, HttpHeaders.ORIGIN);
        }
        if (!varyHeaders.contains(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD)) {
            response.addHeader(HttpHeaders.VARY, HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD);
        }
        if (!varyHeaders.contains(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS)) {
            response.addHeader(HttpHeaders.VARY, HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS);
        }

        if (!CorsUtils.isCorsRequest(request)) {
            return true;
        }

        if (response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN) != null) {
            logger.trace("Skip: response already contains \"Access-Control-Allow-Origin\"");
            return true;
        }

        boolean preFlightRequest = CorsUtils.isPreFlightRequest(request);
        if (config == null) {
            if (preFlightRequest) {
                rejectRequest(new ServletServerHttpResponse(response));
                return false;
            }
            else {
                return true;
            }
        }

        return handleInternal(new ServletServerHttpRequest(request), new ServletServerHttpResponse(response), config, preFlightRequest);
    }
    
    //...
}

 

 

CORS :: Spring Security

Spring Framework provides first class support for CORS. CORS must be processed before Spring Security, because the pre-flight request does not contain any cookies (that is, the JSESSIONID). If the request does not contain any cookies and Spring Security is

docs.spring.io

 
  

CsrfFilter

먼저 CSRF 공격이란? Corss-Site Request Forgery(사이트 간 요청 위조)의 약자로, 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 하는 웹 취약점 공격 중 하나로, 세션 기반 로그인에서 주로 발생한다. 사이트 간 스크립팅(XSS)을 이용한 공격이 사용자가 특정 웹사이트를 신용하는 점을 노린 것이라면, 사이트간 요청 위조는 특정 웹사이트가 사용자의 웹 브라우저를 신용하는 상태를 노린 것이다. 사용자가 웹 사이트에 로그인한 상태에서 사이트간 요청 위조 공격 코드가 삽입된 페이지를 열면, 공격 대상이 되는 웹사이트는 위조된 공격 명령이 믿을 수 있는 사용자로부터 발송된 것으로 판단하여 공격에 노출된다. 참고
 

스프링 시큐리티의 CsrfFilter의 검증 방식은 토큰 방식으로, csrf 공격 방어를 위해 HTTP 메소드 중 GET, HEAD, TRACE, OPTIONS 메소드를 제외한 요청에 대해 검증을 진행한다. 해당 요청에 대해 서버에 저장된 csrf 토큰이 없다면, csrf 토큰을 생성하여 서버 저장소에 저장하고 응답으로 csrf 토큰을 전달한다. 이후 클라이언트는 모든 요청에 csrf 토큰을 헤더(X-XSRF-TOKEN) 또는 파라미터(_csrf)로 함께 전달하고, 서버는 클라이언의 csrf 토큰과 서버에 저장된 토큰을 비교 검증한다. 마찬가지로 다음 설정을 통해 필터를 비활성화할 수 있으며 커스텀 SecurityFilterChain을 생성해도 자동 등록된다. 

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(config -> config.disable())
        return http.build();
    }
}

 

CsrfFilter 코드를 대략적으로 살펴보자면, 먼저 현재 요청 메소드가 GET, HEAD, TRACE, OPTIONS 중 하나인 경우 csrf 토큰 검증을 할 필요가 없으므로 바로 다음 필터로 넘어간다. 만약 csrf 토큰 검증이 필요하다면 서버에 저장된 csrf 토큰과 클라이언트가 요청으로 보낸 csrf 토큰을 각각 조회하여 둘의 값이 일치하는지 검증한다. 만약 일치하지 않는 경우 403(FORBIDDEN) 에러를 뱉는다. 

public final class CsrfFilter extends OncePerRequestFilter {

    public static final RequestMatcher DEFAULT_CSRF_MATCHER = new DefaultRequiresCsrfMatcher();
    private static final String SHOULD_NOT_FILTER = "SHOULD_NOT_FILTER" + CsrfFilter.class.getName();
    private final Log logger = LogFactory.getLog(this.getClass());
    private final CsrfTokenRepository tokenRepository;
    private RequestMatcher requireCsrfProtectionMatcher;
    private AccessDeniedHandler accessDeniedHandler;
    private CsrfTokenRequestHandler requestHandler;
    
    //...

    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        DeferredCsrfToken deferredCsrfToken = this.tokenRepository.loadDeferredToken(request, response); //토큰 저장소에서 csrf 토큰 조회
        request.setAttribute(DeferredCsrfToken.class.getName(), deferredCsrfToken); //다음 요청을 위해 추가
        
        CsrfTokenRequestHandler var10000 = this.requestHandler;
        var10000.handle(request, response, deferredCsrfToken::get);
        
        if (!this.requireCsrfProtectionMatcher.matches(request)) { //요청 메소드가 GET, HEAD, TRACE, OPTIONS 중 하나인 경우
            filterChain.doFilter(request, response);
        } else {
            CsrfToken csrfToken = deferredCsrfToken.get(); //서버에 저장된 토큰 
            String actualToken = this.requestHandler.resolveCsrfTokenValue(request, csrfToken); //클라이언트가 요청으로 보낸 토큰
            
            if (!equalsConstantTime(csrfToken.getToken(), actualToken)) { //서버 토큰 != 클라이언트 토큰 인 경우
                boolean missingToken = deferredCsrfToken.isGenerated();
                AccessDeniedException exception = !missingToken ? new InvalidCsrfTokenException(csrfToken, actualToken) : new MissingCsrfTokenException(actualToken);
                this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);
            } else {
                filterChain.doFilter(request, response);
            }
        }
    }
    
    //...

    private static boolean equalsConstantTime(String expected, String actual) {
        if (expected == actual) {
            return true;
        } else if (expected != null && actual != null) {
            byte[] expectedBytes = Utf8.encode(expected);
            byte[] actualBytes = Utf8.encode(actual);
            return MessageDigest.isEqual(expectedBytes, actualBytes);
        } else {
            return false;
        }
    }

    private static final class DefaultRequiresCsrfMatcher implements RequestMatcher {
        
        private final HashSet<String> allowedMethods = new HashSet(Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS"));

        public boolean matches(HttpServletRequest request) {
            return !this.allowedMethods.contains(request.getMethod());
        }
    }
}

 
참고로 클라이언트의 요청으로부터 csrf 토큰을 조회하는 CsrfTokenRequestHandler resolveCsrfTokenValue 메소드를 보면, 요청 헤더 또는 파라미터로부터 csrf 토큰을 가져오는 것을 확인할 수 있다. 

@FunctionalInterface
public interface CsrfTokenRequestHandler extends CsrfTokenRequestResolver {

    void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken);

    default String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
        //요청 헤더 또는 파라미터로부터 csrf 토큰 get
        String actualToken = request.getHeader(csrfToken.getHeaderName());
        if (actualToken == null) {
            actualToken = request.getParameter(csrfToken.getParameterName());
        }

        return actualToken;
    }
}

 
csrf 토큰의 생성 및 관리는 CsrfTokenRepository라는 인터페이스 구현체에게 위임하며, 대표적인 구현체에는 HttpSessionCsrfTokenRepository(세션 기반, 기본값), CookieCsrfTokenRepository(쿠키 기반)가 있으며 아래와 같이 커스텀하게 설정 가능하다.

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.csrf(config -> config.csrfTokenRepository(new HttpSessionCsrfTokenRepository()))
    return http.build();
}

 
 

기본 동작은 SSR(Server Side Rendering) 세션 기반으로 설정되어 있어, 뷰를 렌더링할 때 html form 태그의 hidden 타입의 input 태그에 csrf 토큰이 다음과 같이 포함되어야 한다. 이 경우 POST /some-endpoint 요청 시 csrf 토큰이 자동 포함되어 CsrfFilter를 무사히 통과할 수 있다. 

<html>
<head></head>
<body>
    <form method="POST" action="/some-endpoint">
        <input type="hidden"
           name="_csrf"
           value="IcxWu3KPl2VbRxPh_4vjMYBt4ohK16rFG-gN5wT0o9dkeeSXRfk33xO_old2fyLXnqbXBrIIz7B555joKt1uhmKQkbZXT9Oj"/>
    </form>
</body>
@Controller
public class CsrfController {

    @GetMapping("/csrf")
    public String csrf(CsrfToken csrfToken, Model model) {
        model.addAttribute("_csrf", csrfToken);
        return "csrf.html"; //csrf 토큰값으로 뷰 렌더링
    }
}
<html xmlns:th="http://www.thymeleaf.org">
<head></head>
<body>
<form method="POST" action="/some-endpoint">
    <input type="hidden"
           th:name="${_csrf.parameterName}"
           th:value="${_csrf.token}"/>
</form>
</body>
</html>

 
 
반면 Stateless한 REST API에서는 해당 필터를 사용할 일이 거의 없는데, 그 이유는 JSESSION에 대한 서버 세션이 상태를 가지지 않아 csrf 공격 위험 자체가 없기 때문이다. 따라서 이 경우에는 csrf 설정을 disable 하는 것이 보편적이지만, JWT를 쿠키에 저장하는 경우에는 csrf 공격 위험이 있을 수 있기 때문에 활성화하는 것이 좋다. 이 경우에는 발급된 csrf 토큰을 클라이언트에게 전달해줄 수 있도록 별도 API가 필요하다. 아래 코드에서 GET /csrf 요청 시 응답값인 token을 클라이언트는 매 요청마다 헤더 또는 파라미터로 실어 보내야 한다. 

@RestController
public class CsrfController {

    @GetMapping("/csrf")
    public CsrfToken csrf(CsrfToken csrfToken) {
        return csrfToken;
    }
}
{
    "parameterName": "_csrf",
    "token": "_NGSH1_BJ9cZsEMnC5Db2SGQBe0V9t1KaWsZP_84SA600oX4mOTzez7xEuU0iHIRar3v7hP1KNUmxu9nWF56Xplcem-H5LLM",
    "headerName": "X-XSRF-TOKEN"
}

 

 

Cross Site Request Forgery (CSRF) :: Spring Security

To handle an AccessDeniedException such as InvalidCsrfTokenException, you can configure Spring Security to handle these exceptions in any way you like. For example, you can configure a custom access denied page using the following configuration: Configure

docs.spring.io

 
 

LogoutFilter

말 그대로 로그아웃을 수행하는 필터로 내부적으로 여러 로그아웃 핸들러를 의존하여 처리를 위임한다. 기본값으로 세션 기반 로그아웃이 구현되어 있어 JWT 방식이나 추가할 로직이 많은 경우 필터를 커스텀해야 한다. 마찬가지로 다음과 같이 추가 설정이 가능하며 커스텀 SecurityFilterChain 생성 시에도 자동 등록된다. 

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.logout(config -> config.addLogoutHandler(new CompositeLogoutHandler()) //로그아웃 핸들러 추가 
                .logoutSuccessHandler(new SimpleUrlLogoutSuccessHandler()) //로그아웃 성공 핸들러 추가
                .disable());
        return http.build();
    }
}

 

public class LogoutFilter extends GenericFilterBean {

    private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
    private RequestMatcher logoutRequestMatcher;
    private final LogoutHandler handler;
    private final LogoutSuccessHandler logoutSuccessHandler;

    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain);
    }

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        if (this.requiresLogout(request, response)) { //로그아웃 요청인지 확인
            Authentication auth = this.securityContextHolderStrategy.getContext().getAuthentication();
            this.handler.logout(request, response, auth); //쿠키, 세션, 시큐리티 컨텍스트 등 삭제
            this.logoutSuccessHandler.onLogoutSuccess(request, response, auth);
        } else {
            chain.doFilter(request, response);
        }
    }

    protected boolean requiresLogout(HttpServletRequest request, HttpServletResponse response) {
        if (this.logoutRequestMatcher.matches(request)) {
            return true;
        } else {
            return false;
        }
    }
}

 

LogoutFilter의 주요 로직은 로그아웃 요청인지 확인 → 로그아웃 처리 → 로그아웃 성공 후처리 순으로 진행된다. 실제 로그아웃 처리는 LogoutHandler 인터페이스의 구현체인 CompositeLogoutHandler에게 위임하며, CompositeLogoutHandler는 다시 여러 LogoutHandler에게 처리를 위임하고 있다. 기본적으로 동작하는 핸들러에는  CsrfLogoutHandler(csrf 설정이 활성화된 경우, csrf 토큰 저장소에 토큰을 null값으로 저장), SecurityContextLogoutHandler(세션 및 SecurityContext 무효화), LogoutSuccessEventPublishingLogoutHandler(LogoutSuccessEvent를 발행)가 있다.
 

public final class CompositeLogoutHandler implements LogoutHandler {

    private final List<LogoutHandler> logoutHandlers;
    
    //...

    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        Iterator var4 = this.logoutHandlers.iterator();

        while(var4.hasNext()) {
            LogoutHandler handler = (LogoutHandler)var4.next();
            handler.logout(request, response, authentication);
        }
    }
}

 
로그아웃 성공 후에는 URL 리다이렉션과 같은 로그아웃 성공 후처리 작업을 수행하기 위한 LogoutSuccessHandler가 동작하며 기본 구현체에는 DelegatingLogoutSuccessHandler, ForwardLogoutSuccessHandler, HttpStatusReturningLogoutSuccessHandler, SimpleUrlLogoutSuccessHandler 등이 있다. 

 
 

UsernamePasswordAuthenticationFilter

말 그대로 유저의 아이디와 비밀번호로 인증하는 필터로, POST /login 경로에서 form 기반 인증을 진행할 수 있도록 multipart/form-data 형태의 username, password 데이터를 받아 실제 인증 클래스에게 값을 넘겨주는 역할을 수행한다. 커스텀 SecurityFilterChain 생성 시 자동 등록되지 않아 아래 구문을 통해 필터를 활성화해주어야 한다.

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.formLogin(Customizer.withDefaults());
        return http.build();
    }
}

  
UsernamePasswordAuthentication 필터에는 템플릿 메소드 패턴이 적용되어 있다. 따라서 기능의 골격이 정의된 부모 클래스 AbstractAuthenticationProcessingFilter의 요청 처리 흐름을 먼저 살펴보자. 현재 요청이 인증이 필요한 요청인지 확인 후, 만약 인증이 필요하다면 UsernamePasswordAuthenticationFilter에서 구현하고 있는 메소드 attemptAuthentication를 호출하여 인증이 완료된 Authentication 객체를 반환받는다. 이후 다음 필터를 호출하거나 인증 결과에 따라 성공/실패 핸들러를 호출한다. 

public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {

    //실제 인증 처리 클래스들
    private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
    protected ApplicationEventPublisher eventPublisher;
    protected AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource = new WebAuthenticationDetailsSource();
    private AuthenticationManager authenticationManager;
    protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
    private RememberMeServices rememberMeServices = new NullRememberMeServices();
    private RequestMatcher requiresAuthenticationRequestMatcher;
    private boolean continueChainBeforeSuccessfulAuthentication = false;
    private SessionAuthenticationStrategy sessionStrategy = new NullAuthenticatedSessionStrategy();
    private boolean allowSessionCreation = true;
    private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
    private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();
    private SecurityContextRepository securityContextRepository = new RequestAttributeSecurityContextRepository();

    //...

    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain);
    }

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        if (!this.requiresAuthentication(request, response)) {
            chain.doFilter(request, response); //인증이 필요하지 않은 요청이라면 다음 필터로 진행
        } else {
            try {
                Authentication authenticationResult = this.attemptAuthentication(request, response); //UsernamePasswordAuthenticationFilter 구현 메소드 호출 
                if (authenticationResult == null) {
                    return;
                }

                this.sessionStrategy.onAuthentication(authenticationResult, request, response);
                if (this.continueChainBeforeSuccessfulAuthentication) {
                    chain.doFilter(request, response);
                }
                
                this.successfulAuthentication(request, response, chain, authenticationResult); //인증 성공 핸들러 호출
            } catch (InternalAuthenticationServiceException var5) {
                InternalAuthenticationServiceException failed = var5;
                this.unsuccessfulAuthentication(request, response, failed); //인증 실패 핸들러 호출
            } catch (AuthenticationException var6) {
                AuthenticationException ex = var6;
                this.unsuccessfulAuthentication(request, response, ex); //인증 실패 핸들러 호출
            }
        }
    }

    protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
        if (this.requiresAuthenticationRequestMatcher.matches(request)) {
            return true;
        } else {
            return false;
        }
    }

    //UsernamePasswordAuthenticationFilter에서 구현하는 메소드
    public abstract Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException;

    //인증 성공 처리
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        //SecurityContext 설정
        SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
        context.setAuthentication(authResult);
        this.securityContextHolderStrategy.setContext(context);
        this.securityContextRepository.saveContext(context, request, response);

        this.rememberMeServices.loginSuccess(request, response, authResult);
        if (this.eventPublisher != null) {
            this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
        }

        this.successHandler.onAuthenticationSuccess(request, response, authResult);
    }

    //인증 실패 처리
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
        this.securityContextHolderStrategy.clearContext();
        this.rememberMeServices.loginFail(request, response);
        this.failureHandler.onAuthenticationFailure(request, response, failed);
    }
}

 
UsernamePasswordAuthenticationFilter에서 눈여겨 보아야 할 부분은 실제 인증 처리를 수행하는 attemptAuthentication 메소드이다. 코드를 보면 알겠지만 요청 객체 request로부터 username, password를 조회한 후 아직 인증되지 않은 객체 UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager에게 인증 처리를 위임하고 있다.  

public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
    public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login", "POST");
    private String usernameParameter = "username";
    private String passwordParameter = "password";
    private boolean postOnly = true;

    //인증 처리 수행
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        if (this.postOnly && !request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        } else {
            String username = this.obtainUsername(request);
            username = username != null ? username.trim() : "";
            String password = this.obtainPassword(request);
            password = password != null ? password : "";
            //요청으로 전달된 username, password를 가진 미인증 객체 Authentication 생성  
            UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest); //인증 처리 위임
        }
    }
}

 
이렇게 실질적인 인증 처리는 AuthenticationManager의 구현체에게 위임하며, UsernamePasswordAuthenticationFilter는 단순히 인증의 시작점 역할을 할 뿐이다. 인증 관련 내부 로직을 상세히 이해하는 것도 중요한데, 이번 글의 주제와는 약간 벗어나므로 이에 대한 자세한 내용은 다음 글에서 스프링 시큐리티 로그인을 직접 구현해보면서 다루기로 하자.


추가로 왜 AbstractAuthenticationProcessingFilter, UsernamePasswordAuthenticationFilter에는 템플릿 메소드 패턴이 적용되었을까? 답변하기 전 일단 사용자 인증 처리의 대략적인 흐름은 다음과 같다.

인증이 필요한 요청인지 확인 → 인증 처리 후 Authentication 객체 반환 → 인증 성공/실패 핸들러 호출

 

username, password 데이터를 form, JSON 방식 등으로 보내도 전체적인 처리 흐름은 동일하며, 이러한 흐름의 골격을 정의한 클래스가 바로 AbstractAuthenticationProcessingFilter인 것이다. 그리고 추상화된 atteptAuthentication 메소드를 구현하여 username, password 데이터를 어떤 방식으로 보내야 할지, 어떤 식으로 인증을 처리할지 구체적인 방식을 정의한 클래스가 UsernamePasswordAuthenticationFilter인 것이다.
 
form 방식으로 받도록 구현된 UsernamePasswordAuthenticationFilter 이외에도 OAuth2 코드를 받도록 구현된 OAuth2LoginAuthenticationFilter도 존재하며, 만약 json 방식으로 받도록 구현하고자 한다면 UsernamePasswordAuthenticationFilter를 상속한 커스텀 필터를 정의하면 된다. 

 

 

Handling Logouts :: Spring Security

If you are using Java configuration, you can add clean up actions of your own by calling the addLogoutHandler method in the logout DSL, like so: Custom Logout Handler CookieClearingLogoutHandler cookies = new CookieClearingLogoutHandler("our-custom-cookie"

docs.spring.io

 

 

DefaultLoginPageGeneratingFilter

로그인 설정에 대해 GET /login 경로에 기본 로그인 페이지를 응답하는 필터이다. 여러 로그인 설정에 의존하며, 커스텀 로그인 페이지를 사용할 경우에는 자동 제외된다. 예를 들어 여러 로그인 설정 중 formLogin 기본값 설정 시 해당 필터가 자동 활성화 되며, loginPage 메소드를 통해 커스텀 로그인 페이지를 설정하지 않는 이상 기본 동작한다.

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.formLogin(Customizer.withDefaults())
//    	.formLogin(config -> config.loginPage("/login")) //이 경우에는 DefaultLoginPageGeneratingFilter 비활성화
    return http.build();
}

 

DefaultLoginPageGeneratingFilter 코드는 아주 간단한데, 현재 요청이 GET /login 인지를 확인하고 html 코드를 생성하여 응답으로 반환한다. 참고로 generateLoginPageHtml 메소드에서는 현재 요청이 form, OAuth2, saml 방식인지에 따라 각기 다른 로그인 페이지를 생성한다. generateLoginPageHtml 메소드 구현 코드를 보면 참 재미있는데, html이 진짜 문자열로 하드 코딩되어 있다.

public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
    public static final String DEFAULT_LOGIN_PAGE_URL = "/login";
    public static final String ERROR_PARAMETER_NAME = "error";
    
    //...
    
    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        boolean loginError = this.isErrorPage(request);
        boolean logoutSuccess = this.isLogoutSuccess(request);
        if (!this.isLoginUrlRequest(request) && !loginError && !logoutSuccess) {
            chain.doFilter(request, response);
        } else {
            String loginPageHtml = this.generateLoginPageHtml(request, loginError, logoutSuccess); //로그인 페이지 html 코드 생성
            response.setContentType("text/html;charset=UTF-8");
            response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
            response.getWriter().write(loginPageHtml);
        }
    }
    
    //...
}

 
차라리 별도의 *.html 파일을 정의하고 컨트롤러 단에서 기본 로그인 페이지를 제공하면 이렇게 html을 하드 코딩하지 않아도 될 것이다. 그러나 만약 기본 로그인 페이지를 컨트롤러 단에서 제공할 경우 디폴트 컨트롤러(스프링 시큐리티가 기본 제공하는 로그인 페이지)와 커스텀 컨트롤러(개발자가 직접 정의)가 충돌할 수 있으며(둘다 GET /login으로 정의되는 경우) (내 피셜) 아직 이를 해결하지 못했기 때문에? 필터 단에서 html을 하드 코딩하여 제공할 수 밖에 없는 것 같다. 

 

DefaultLogoutPageGeneratingFilter

GET /logout 경로에 대해 기본 로그아웃 페이지를 응답하는 필터로, 마찬가지로 여러 로그인 설정에 의존한다. (자체적인 활성/비활성 설정 불가능) 코드를 간단하게 살펴보면 현재 요청이 로그아웃 요청인지 판단 후 로그아웃 페이지를 렌더링한다. (DefaultLoginPageGeneratingFilter와 동일하게 하드 코딩된 html을 response 객체에 write하여 응답한다.)

public class DefaultLogoutPageGeneratingFilter extends OncePerRequestFilter {

    private RequestMatcher matcher = new AntPathRequestMatcher("/logout", "GET");
    private Function<HttpServletRequest, Map<String, String>> resolveHiddenInputs = (request) -> {
        return Collections.emptyMap();
    };

    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        if (this.matcher.matches(request)) {
            this.renderLogout(request, response); //로그아웃 페이지 요청이라면 페이지 렌더링
        } else {
            filterChain.doFilter(request, response);
        }

    }
    
    //...
}


 

BasicAuthenticationFilter

말그대로 Basic 기반의 인증을 수행하는 필터로, 커스텀 SecurityFilterChain 생성 시 자동 등록되지 않으므로 다음과 같이 설정해주어야 한다. 

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

 
먼저 Basic 인증이란? 말 그대로 가장 기본적인 인증 방식으로, Base64로 인코딩한 "사용자 ID:비밀번호" 문자열을 Basic- prefix와 함께 인증 헤더에 입력하는 방식으로 인증을 진행한다. 서버는 요청에 대해 username/password만 일치하는지 확인 후 사용자를 기억하지 않기 때문에 매 요청마다 Authorization 헤더를 보내야 한다. 그러나 세션 기반 인증인 경우 스프링 시큐리티의 Basic 인증 로직은 매번 재인증을 요구하지 않고 세션에 값을 저장하여 유저를 기억한다. (이 부분이 어떻게 구현되어 있는지는 뒤에서 코드로 살펴보자.) 

Authorization: Basic base64({USERNAME}:{PASSWORD})

 
참고로 Base64 인코딩은 쉽게 복호화가 가능하기 때문에 단순 Base64 인코딩된 사용자 ID, 비밀번호를 HTTP로 전달하면 보안에 매우 취약하기 때문에 Basic 인증을 사용하는 요청은 HTTPS, SSL/TLS로 통신해야 한다. 참고
 

public class BasicAuthenticationFilter extends OncePerRequestFilter {

    private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
    private AuthenticationEntryPoint authenticationEntryPoint;
    private AuthenticationManager authenticationManager;
    private RememberMeServices rememberMeServices = new NullRememberMeServices();
    private boolean ignoreFailure = false;
    private String credentialsCharset = "UTF-8";
    private AuthenticationConverter authenticationConverter = new BasicAuthenticationConverter();
    private SecurityContextRepository securityContextRepository = new RequestAttributeSecurityContextRepository();
    
    //...

    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        try {
            Authentication authRequest = this.authenticationConverter.convert(request); //요청 Authorization 헤더에서 username, password 조회 후 인증 객체로 반환
            if (authRequest == null) {
                chain.doFilter(request, response);
                return;
            }

            String username = authRequest.getName();
            if (this.authenticationIsRequired(username)) { //SecurityContext에 해당 username이 이미 존재하는지 확인
                Authentication authResult = this.authenticationManager.authenticate(authRequest); //인증 진행
                SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
                context.setAuthentication(authResult);
                this.securityContextHolderStrategy.setContext(context);

                this.rememberMeServices.loginSuccess(request, response, authResult);
                this.securityContextRepository.saveContext(context, request, response);
                this.onSuccessfulAuthentication(request, response, authResult);
            }
        } catch (AuthenticationException var8) {
            AuthenticationException ex = var8;
            this.securityContextHolderStrategy.clearContext();
            this.rememberMeServices.loginFail(request, response);
            this.onUnsuccessfulAuthentication(request, response, ex);
            if (this.ignoreFailure) {
                chain.doFilter(request, response);
            } else {
                this.authenticationEntryPoint.commence(request, response, ex);
            }

            return;
        }

        chain.doFilter(request, response);
    }
    
    protected boolean authenticationIsRequired(String username) {
        Authentication existingAuth = this.securityContextHolderStrategy.getContext().getAuthentication();
        if (existingAuth == null || !existingAuth.getName().equals(username) || !existingAuth.isAuthenticated()) {
            return true;
        }
        return (existingAuth instanceof AnonymousAuthenticationToken);
    }
    
    //...
}

BasicAuthenticationFilter 코드를 살펴보면, 먼저 converter를 통해 요청 헤더로부터 미인증 객체를 반환받는다. 이후  authenticationIsRequired 메소드를 통해 인자로 전달된 username에 해당하는 인증 객체가 SecurityContext에 존재하는지 확인한다. 만약 해당 인증 객체가 존재한다면 Basic 재인증을 수행하지 않으며, 없다면 UsernamePasswordAuthenticationFilter와 비슷하게 AuthenticationManager에게 인증 처리를 위임하여, 성공 시에는 SecurityContext Authentication 객체를 저장하는 등의 후처리를 진행한다. 참고로 authenticationIsRequired 메소드에서 securityContextHolderStrategy.getContext()에서 조회되는 SecurityContext는, 앞선 필터인 SecurityContextHolderFilter에서 세션 기반 인증인 경우 HttpSessionSecurityContextRepository에 저장된 SecurityContext를 조회하여 미리 세팅해둔 것이다. 


HttpServletRequest 요청 Authorization 헤더로부터 username/password를 조회해 Authentication 객체로 변환해주는 BasicAuthenticationCoverter의 주요 코드는 다음과 같다. 코드 상으로 쉽게 확인할 수 있는데, Authorization 헤더에서 Basic  prefix를 제거한 후 해당 토큰을 Base64로 디코딩하고 ':' 이 문자를 기준으로 username, password를 분리하여 UsernamePasswordAuthenticationToken를 생성해 반환한다. 

public class BasicAuthenticationConverter implements AuthenticationConverter {

    public static final String AUTHENTICATION_SCHEME_BASIC = "Basic";
    private AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource;
    private Charset credentialsCharset;
    
    //...

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

 

 

Basic Authentication :: Spring Security

When the user submits their username and password, the BasicAuthenticationFilter creates a UsernamePasswordAuthenticationToken, which is a type of Authentication by extracting the username and password from the HttpServletRequest. By default, Spring Securi

docs.spring.io

 

 

RequestCacheAwareFilter

이전 HTTP 요청에서 처리할 작업이 있고 현재 요청에서 (아직 처리하지 못한) 이전 작업을 수행하기 위해 등록되는 필터로, 아래와 같이 비활성화 가능하다. (커스텀 SecurityFilterChain 생성 시 자동 등록)

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.requestCache(config -> config.disable());
        return http.build();
    }
}

 

예를 들어 '로그인하지 않은 사용자가 권한이 필요한 요청 → 권한 없음 예외 발생 → 핸들러에서 해당 요청 경로를 캐싱 → 스프링 시큐리티가 로그인 창을 띄우고 인증을 진행(세션 방식에서 디폴트 formLogin 설정 시) → 로그인 상태로 캐싱된 이전 요청 재실행' 이러한 실행 흐름을 구현하기 위해 RequestCacheAwareFilter가 필요한 것이다. 

 

위 실행 흐름 중 두번째 '권한 없음 예외 발생' 시 이러한 인증, 인가 예외는 뒤에서 살펴볼 ExceptionTranslationFilter에서 처리하는데, 이 중 호출되는 sendStartAuthentication 메소드에서 requestCache에 현재 권한이 필요한 요청 객체를 저장하고 있다.

protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {
    SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
    this.securityContextHolderStrategy.setContext(context);
    this.requestCache.saveRequest(request, response); //cookie, http session 등에 현재 요청을 저장
    this.authenticationEntryPoint.commence(request, response, reason);
}

 

그리고 사용자가 다시 정상 로그인 후 요청을 보내 RequestCacheAwareFilter를 거치게 되면, requestCache에서 로그인 전 인증 예외가 발생한 요청이 있는지 확인하고, 존재한다면 해당 값으로 request 객체를 대체한다. 

public class RequestCacheAwareFilter extends GenericFilterBean {

    private RequestCache requestCache;

    //...

    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        //requestCache에서 이전 요청에 캐싱해둔 데이터가 있는지 조회
        HttpServletRequest wrappedSavedRequest = this.requestCache.getMatchingRequest((HttpServletRequest)request, (HttpServletResponse)response);
        //캐싱된 요청이 존재할 경우 reqeust 객체 대체
        chain.doFilter((ServletRequest)(wrappedSavedRequest != null ? wrappedSavedRequest : request), response);
    }
}

 

예를 들어 유튜브의 A 영상을 보고 있다가 일시 정지 후 아주 오랜 기간동안 해당 페이지에 재접속하지 않으면 로그인이 풀리게 된다. 이때 새로고침 버튼을 누르면 로그인 페이지로 이동하는데, 로그인 성공 후 유튜브 홈이 아닌 기존에 보고 있던 A 영상으로 이동하게 되는 원리가 바로 RequestCacheAwareFilter인 것이다.

 

 

Architecture :: Spring Security

The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more. The filters are executed in a spec

docs.spring.io

 

 

SecurityContextHolderAwareRequestFilter

ServletRequest 요청에 스프링 시큐리티 API를 다룰 수 있는 메소드를 추가하기 위해 존재하는 필터이다. (커스텀 SecurityFilterChain 생성 시에도 기본 등록)

 

'ServletRequest 요청에 스프링 시큐리티 API를 다룰 수 있는 메소드를 추가'가 무슨 말인가 싶을 텐데, 일단 SecurityContextHolderAwareRequestFilter 코드를 먼저 살펴보자. 주요한 부분은 requestFactory create 메소드를 통해 현재 request 객체를 대체하고 있는 부분이다. 이때 반환되는 request 객체는 Servlet3SecurityContextHolderAwareRequestWrapper (HttpServlet3RequestFactory 클래스의 정적 내부 클래스)클래스 타입으로, authentication, login, logout 등의 시큐리티 API를 제공하고 있다. (약간 TMI이긴 한데 Servlet3SecurityContextHolderAwareRequestWrapper authentication, login, logout 메소드 구현을 살펴보면, 아래 createServlet3Factory 메소드에서 설정해주고 있는 AuthenticationManager, LogoutHandler 등에게 처리를 위임하는 것을 확인할 수 있다.)

public class SecurityContextHolderAwareRequestFilter extends GenericFilterBean {

    private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
    private String rolePrefix = "ROLE_";
    private HttpServletRequestFactory requestFactory;
    private AuthenticationEntryPoint authenticationEntryPoint;
    private AuthenticationManager authenticationManager;
    private List<LogoutHandler> logoutHandlers;
    private AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();
    private SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();

    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        chain.doFilter(this.requestFactory.create((HttpServletRequest)req, (HttpServletResponse)res), res);
    }

    private void updateFactory() {
        String rolePrefix = this.rolePrefix;
        this.requestFactory = this.createServlet3Factory(rolePrefix); //요청 팩토리 초기화
    }

    private HttpServletRequestFactory createServlet3Factory(String rolePrefix) {
        HttpServlet3RequestFactory factory = new HttpServlet3RequestFactory(rolePrefix, this.securityContextRepository);
        factory.setTrustResolver(this.trustResolver);
        factory.setAuthenticationEntryPoint(this.authenticationEntryPoint);
        factory.setAuthenticationManager(this.authenticationManager);
        factory.setLogoutHandlers(this.logoutHandlers);
        factory.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
        return factory;
    }
}

 
 
따라서 이 필터 덕분에 아래와 같은 구현이 가능하다. 

@RestController
public class SecurityContextHolderAwareRequestController {

    @GetMapping("/test")
    public void test(HttpServletRequest request, HttpServletResponse response) throws Exception {
        request.authenticate(response); //인증 여부 확인 
        request.login("username", "password"); //AuthenticationManager를 통해 인증 진행
        request.logout(); //로그아웃 핸들러 호출
        AsyncContext asyncContext = request.startAsync(); //Callable를 사용하여 비동기 처리 진행 시 기존의 SecurityContext를 복사
        asyncContext.start(() -> {
            SecurityContext securityContext = SecurityContextHolder.getContext(); //동일한 SecurityContext에 접근 가능
        });
    }
}

 
 

AnonymousAuthenticationFilter

최초 접속으로 기존의 인증 정보가 없고, 해당 필터까지 인증을 하지 않았을 경우 익명 사용자 데이터를 설정해주는 필터이다. 즉 여러 필터를 거치면서 해당 필터까지 SecurityContext 값이 null인 경우 anonymous 값을 넣어주는 역할을 하며, 커스텀 SecurityFilterChain 생성 시에도 기본 등록된다. 아래의 defaultWithAnonymous 메소드를 보면 현재 필터까지 SecurityContext Authentication 객체가 null인 경우 익명 인증 객체(AnonymousAuthenticationToken)를 생성하여 SecurityContext에 설정해주고 있다. 

public class AnonymousAuthenticationFilter extends GenericFilterBean implements InitializingBean {

    private SecurityContextHolderStrategy securityContextHolderStrategy;
    private AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource;
    private String key;
    private Object principal;
    private List<GrantedAuthority> authorities;

    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        Supplier<SecurityContext> deferredContext = this.securityContextHolderStrategy.getDeferredContext();
        this.securityContextHolderStrategy.setDeferredContext(this.defaultWithAnonymous((HttpServletRequest)req, deferredContext));
        chain.doFilter(req, res);
    }

    private Supplier<SecurityContext> defaultWithAnonymous(HttpServletRequest request, Supplier<SecurityContext> currentDeferredContext) {
        return SingletonSupplier.of(() -> {
            SecurityContext currentContext = (SecurityContext)currentDeferredContext.get();
            return this.defaultWithAnonymous(request, currentContext);
        });
    }

    private SecurityContext defaultWithAnonymous(HttpServletRequest request, SecurityContext currentContext) {
        Authentication currentAuthentication = currentContext.getAuthentication();
        //인증 객체가 null인 경우
        if (currentAuthentication == null) {
            Authentication anonymous = this.createAuthentication(request);
            SecurityContext anonymousContext = this.securityContextHolderStrategy.createEmptyContext();
            anonymousContext.setAuthentication(anonymous); //익명 인증 객체 설정
            return anonymousContext;
        } else {
            return currentContext;
        }
    }

    protected Authentication createAuthentication(HttpServletRequest request) {
        AnonymousAuthenticationToken token = new AnonymousAuthenticationToken(this.key, this.principal, this.authorities);
        token.setDetails(this.authenticationDetailsSource.buildDetails(request));
        return token;
    }
}

 

참고로 AnonymousAuthenticationToken 객체 생성 시 전달되는 값은 중단점을 찍어 확인할 수 있는데, 기본 username 값은 anonymousUser, 기본 role은 ROLE_ANONYMOUS이다.

AnonymousAuthenticationFilter 필드 기본값

  

 

SecurityContextHolderAwareRequestFilter (spring-security-docs 6.3.2 API)

setAuthenticationEntryPoint Sets the AuthenticationEntryPoint used when integrating HttpServletRequest with Servlet 3 APIs. Specifically, it will be used when HttpServletRequest.authenticate(HttpServletResponse) is called and the user is not authenticated.

docs.spring.io

 

 

ExceptionTranslationFilter

해당 필터 이후에 발생하는 인증, 인가 관련 예외를 핸들링하기 위해 사용되는 필터로, 커스텀 SecurityFilterChain 생성 시에도 기본 등록된다. 참고로 ExceptionTranslationFilter 필터 이전에 발생한 예외는 처리 불가능한데, 이는 자바의 예외 전파 방식을 생각해보면 당연하다. (a 메소드에서 b 메소드 호출 → b 메소드에서 예외 발생 → b 메소드 내부에서 예외 처리하지 않는다면, a 메소드로 예외 전파)

 

public class ExceptionTranslationFilter extends GenericFilterBean implements MessageSourceAware {

    private SecurityContextHolderStrategy securityContextHolderStrategy;
    private AccessDeniedHandler accessDeniedHandler;
    private AuthenticationEntryPoint authenticationEntryPoint;
    private AuthenticationTrustResolver authenticationTrustResolver;
    private ThrowableAnalyzer throwableAnalyzer;
    private final RequestCache requestCache;
    protected MessageSourceAccessor messages;

    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain);
    }

    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        try {
            chain.doFilter(request, response);
        } catch (IOException var7) {
            IOException ex = var7;
            throw ex;
        } catch (Exception var8) {
            Exception ex = var8;
            Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
            RuntimeException securityException = (AuthenticationException)this.throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain);
            if (securityException == null) {
                securityException = (AccessDeniedException)this.throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
            }

            if (securityException == null) {
                this.rethrow(ex);
            }

            if (response.isCommitted()) {
                throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
            }

            //실질적인 예외 처리
            this.handleSpringSecurityException(request, response, chain, (RuntimeException)securityException);
        }

    }

    private void rethrow(Exception ex) throws ServletException {
        if (ex instanceof ServletException) {
            throw (ServletException)ex;
        } else if (ex instanceof RuntimeException) {
            throw (RuntimeException)ex;
        } else {
            throw new RuntimeException(ex);
        }
    }

    private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException {
        if (exception instanceof AuthenticationException) { //인증 예외 처리 
            this.handleAuthenticationException(request, response, chain, (AuthenticationException)exception);
        } else if (exception instanceof AccessDeniedException) { //인가 예외 처리
            this.handleAccessDeniedException(request, response, chain, (AccessDeniedException)exception);
        }
    }

    private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException exception) throws ServletException, IOException {
        this.sendStartAuthentication(request, response, chain, exception); //재인증 요청 
    }

    private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
        Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
        boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
        if (!isAnonymous && !this.authenticationTrustResolver.isRememberMe(authentication)) {
            this.accessDeniedHandler.handle(request, response, exception);
        } else {
            this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource")));
        }

    }

    protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {
        SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
        this.securityContextHolderStrategy.setContext(context);
        this.requestCache.saveRequest(request, response);
        this.authenticationEntryPoint.commence(request, response, reason);
    }
}

handleSpringSecurityException 메소드에서 실질적인 인증, 인가 예외를 처리하며 다시 해당 메소드에서 인증 예외인지, 인가 예외인지에 따라 분기 처리를 한다. 앞선 RequestCacheAwareFilter에서 언급한 sendStartAuthentication 메소드를 인증 예외 발생 시 호출하고 있다. 

 

 

Architecture :: Spring Security

The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more. The filters are executed in a spec

docs.spring.io

 
 

AuthorizationFilter

SecurityFilterChain authorizeHttpRequests()를 통해 설정된 인가 값에 따라 최종적으로 인가를 수행하는 필터로, 커스텀 SecurityFilterChain 생성 시에도 기본적으로 등록되며, 다음과 같이 인가 값을 설정할 수 있다. 

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(registry -> registry
                .requestMatchers("/error", "/favicon.ico").permitAll() //해당 요청 인증, 인가 필요 없음 
                .requestMatchers(EMAIL_AUTH_API_PATH, SNS_SIGN_UP_API_PATH, LOGOUT_API_PATH).hasRole("GUEST") //해당 요청 ROLE_GUEST 이상인 유저만 요청 가능
                .anyRequest().hasRole("USER") //이외 요청 ROLE_USER 이상인 유저만 요청 가능
        )
        return http.build();
    }
}

 

doFilter의 앞부분은 부가적인 처리이고 중간의 authorizationManager에게 authentication, request 객체를 전달하여 인가 작업을 위임하고 있다. 만약 인가 권한이 없다면 AccessDeniedException 예외를 throw 하며(해당 예외는 ExceptionTranslationFilter에서 처리), 권한이 있다면 다음 필터를 호출하고 있다. 여기서 중요한건? 인가 작업을 위해 해당 필터에서만큼은 SecurityContext Authentication 객체가 null이 아닌 값으로 존재해야 한다는 것이다. 그래서 거의 직전인 AnonymousAuthenticationFilter에서 이를 설정해주는 작업을 하나보다. 

public class AuthorizationFilter extends GenericFilterBean {

    private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
    private final AuthorizationManager<HttpServletRequest> authorizationManager;
    private AuthorizationEventPublisher eventPublisher = AuthorizationFilter::noPublish;
    private boolean observeOncePerRequest = false;
    private boolean filterErrorDispatch = true;
    private boolean filterAsyncDispatch = true;

    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws ServletException, IOException {
        HttpServletRequest request = (HttpServletRequest)servletRequest;
        HttpServletResponse response = (HttpServletResponse)servletResponse;
        //특정 설정이 enable이고 이 필터가 이번 요청에서 이미 사용되었다면 건너뜀 
        if (this.observeOncePerRequest && this.isApplied(request)) {
            chain.doFilter(request, response);
        } else if (this.skipDispatch(request)) { //비동기 요청과 같은 특정 상황에서 인가 작업을 건너 뛸지 결정
            chain.doFilter(request, response);
        } else {
            //현재 필터가 이미 사용되었다면 값 추가
            String alreadyFilteredAttributeName = this.getAlreadyFilteredAttributeName();
            request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);

            //인가 작업 수행
            try {
                //인가 매니저에게 인가 처리 위임
                AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
                this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
                if (decision != null && !decision.isGranted()) {
                    throw new AccessDeniedException("Access Denied"); //인가 권한 없다면 예외 발생
                }

                chain.doFilter(request, response);
            } finally {
                request.removeAttribute(alreadyFilteredAttributeName); //최종적으로 모든 작업 처리 후 사용 기록 삭제
            }

        }
    }

    private boolean skipDispatch(HttpServletRequest request) {
        if (DispatcherType.ERROR.equals(request.getDispatcherType()) && !this.filterErrorDispatch) {
            return true;
        } else {
            return DispatcherType.ASYNC.equals(request.getDispatcherType()) && !this.filterAsyncDispatch;
        }
    }

    private boolean isApplied(HttpServletRequest request) {
        return request.getAttribute(this.getAlreadyFilteredAttributeName()) != null;
    }

    private String getAlreadyFilteredAttributeName() {
        String name = this.getFilterName();
        if (name == null) {
            name = this.getClass().getName();
        }

        return name + ".APPLIED";
    }

    private Authentication getAuthentication() {
        Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
        if (authentication == null) {
            throw new AuthenticationCredentialsNotFoundException("An Authentication object was not found in the SecurityContext");
        } else {
            return authentication;
        }
    }
}

 

 

Authorize HttpServletRequests :: Spring Security

While using a concrete AuthorizationManager is recommended, there are some cases where an expression is necessary, like with or with JSP Taglibs. For that reason, this section will focus on examples from those domains. Given that, let’s cover Spring Secu

docs.spring.io

 

 

 

커스텀 SecurityFilterChain 필터 생성 및 등록

스프링 시큐리티 필터 체인에 커스텀 필터를 추가하기 위해서는 먼저 GenericFilterBean 또는 OncePerRequestFilter 클래스를 구현한 커스텀 Filter 정의해야 한다. 앞서 말했듯 클라이언트 요청에 대해 필터를  여러번 통과하더라도 딱 한번만 동작하도록 하고 싶다면 OncePerRequestFilter를 구현하면 된다.

public class CustomFilter extends GenericFilterBean {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
      FilterChain chain) throws IOException, ServletException {
        chain.doFilter(request, response);
    }
}


이후 SecurityConfig 설정 클래스에 커스텀 필터를 다음과 같이 추가해준다. 

@Configuration
public class CustomWebSecurityConfigurerAdapter {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.addFilterAfter(new CustomFilter(), BasicAuthenticationFilter.class);
        return http.build();
    }
}

 
기존 필터에 커스텀 필터를 등록할 수 있는 api는 다음과 같다.
- addFilterBefore(filter, class): 기존 시큐리티 필터 전에 커스텀 필터 추가
addFilterAfter(filter, class): 기존 시큐리티 필터 후에 커스텀 필터 추가
addFilterAt(filter, class): 기존 시큐리티 필터의 위치에 커스텀 필터 추가 (기존 필터를 replace하지 않음)
addFilter(filter): 커스텀 필터는 기존 시큐리티 필터의 인스턴스이거나 이를 상속해야 하며, 기존 필터의 위치에 추가 (기존 필터를 replace하지 않음)

 

 

Architecture :: Spring Security

The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more. The filters are executed in a spec

docs.spring.io

 

 

특정 요청은 시큐리티 필터 제외

특정 요청은 시큐리티 필터 체인을 아예 거치지 않도록 설정해주고 싶다면, 다음과 같이 WebSecurityCustomizer 빈을 등록해주면 된다. 이 경우 해당 경로의 RequestMatcher를 가진 SecurityFilterChain이 인덱스 0번으로 자동 등록되며 이 필터 체인은 내부적으로 List<Filter>가 없다.(즉 빈 리스트) 이 설정은 SecurityFilterChain 빈 등록 시의 authorizeHttpRequestspermitAll 설정과 달리 시큐리티 필터 체인을 아예 타지 않으므로 인증이 필요 없는 요청을 더 빠르게 처리할 수 있다는 장점이 있다. 

@EnableWebSecurity
@Configuration
public class SecurityConfig {

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring().requestMatchers("/img/**");
    }
}

 

빈 리스트 filters를 가진 SecurityFilterChain 등록

 
 
 

이렇게 기본 등록되는 필터들 이외에도 SessionManagementFilter, RememberMeAuthenticationFilter 등 중요한 역할을 하는 필터들이 있다. 다 알아보면 좋겠지만 글이 너무 길어지기도 하고 각 필터들은 하나의 글로 정리하는게 더 좋을 것 같아 일단 여기까지 이번 글을 마무리하려고 한다.

 
글을 단순히 읽고 끝내기 보다는 실제 코딩을 하면서 기존 필터를 내 입맛대로 커스텀해보고 이해되지 않는 부분이 있다면 공식 문서를 읽거나 실제 코드를 까보는 것이 도움이 많이 된다. 이 글은 시큐리티 필터에 대한 대략적인 그림을 그리는 용도로 사용하고 본격적인 공부는 공식 문서를 활용하면 좋을 듯 하다. 공부하다보니 스프링 시큐리티 너어무 복잡하다...

 
 
끝.
 
 
 

 

Architecture :: Spring Security

The Security Filters are inserted into the FilterChainProxy with the SecurityFilterChain API. Those filters can be used for a number of different purposes, like authentication, authorization, exploit protection, and more. The filters are executed in a spec

docs.spring.io

 

스프링 시큐리티 내부 구조

스프링 시큐리티 내부 동작 원리와 구조를 학습하는 시리즈입니다.

www.youtube.com

 

 

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함