[Spring Security] Spring Security Filter Chain 에 대해

최동근·2023년 9월 29일
10

안녕하세요 이번 포스팅에서는 Spring Security 에서 인증,인가 과정을 담당하는 Security Filter Chain 에 대해 알아보겠습니다.
다양한 프로젝트를 진행하면서, 사용자의 인증/인가 시스템을 구축하는 개발을 많이 경험해보았는데요 👨‍💻
어떤 방식으로 인증/인가 시스템을 구축하던지 상관없이, Spring Security 을 사용해서 서비스의 보안 시스템을 구축하는 순간 Security Filter Chain 에 대한 선제적인 이해가 필수 불가결하다는 것을 느꼈습니다.

개인적으로 서비스 개발에 있어 인증/인가는 매우 중요한 부분이며, 서버 개발자로써 기본적인 역량을 가지고 있어야 되는 부분이라고 생각합니다.
이번 포스팅을 통해 Security Filter Chain 을 완벽하게 이해해보려고 합니다❗️

여기서 설명하는 인증 방식은 Spring Security의 세션 인증 기반 방식입니다 ❗️

[실습 정보]

  • Spring boot 2.7.15
  • Spring Security 5.7.10
  • Gradle

🌱 Security Filter Chain 개요

Security Filter Chain 이란 Spring Security 에서 제공하는 인증,인가를 위한 필터들의 모음입니다.
Spring Security 에서 가장 핵심이 되는 기능을 제공하며, 거의 대부분의 서비스는 Security Filter Chain 에서 실행된다고 이해하면 됩니다.
기본적으로 제공하는 필터들이 있으며, 사용자는 개발의 취지와 목적에 맞게 커스텀 필터 또한 필터 체인으로 포함시켜 사용할 수 있습니다.

[Filter Chain]

Http 요청 -> Web Application Server(Servlet Container) -> 필터1 -> 필터2 ..... -> 필터 n -> Servlet -> 컨트롤러

해당 이미지는 Security Filter Chain 을 도식화한 이미지입니다.
이 그림을 이해할 때 사용자의 인증 과정 뿐만 아니라, 인증된 사용자의 인가과정이 어떻게 진행되는지를 파악하는 것이 핵심입니다 ❗️

🌱 Application Context 초기화

Application Context 초기화 과정에서 사용자가 정의한 Security Filter Chain 이 생성됩니다.

Spring Security 에서는 인증,인가에 대한 처리를 여러개의 필터를 통해 연쇄적으로 실행하여 수행합니다.
이때, 상황에 따라서 필요한 필터가 있고 굳이 필요 없는 필터가 있을것입니다.
우리는 설정 클래스를 통해 Spring Security 에 대한 전반적인 관리 및 제어를 할 수 있습니다.

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

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring()
                .antMatchers("/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs");
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { //  초기화 단계에서 HttpSecurity 객체가 실제 설정한 필터를 생성
        http
                .cors()
                .and()
                .csrf().disable()
                .httpBasic().disable()
                .formLogin().disable()
                .headers().frameOptions().disable()
                .and()
                .authorizeRequests()
                .antMatchers("/login/callback/**",
                        "/user/reissue",
                        "/test",
                        "/login/kakao",
                        "/recommendation/get",
                        "/recommendation/save")
                .permitAll()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
                .and()
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

위에 보이는 이미지는 Spring Security 설정에 대한 설정 클래스 예시입니다.
여기서 Security Filter Chain 빈을 생성하는 filterChain 메소드가 Security Filter Chain 에 대한 전반적인 설정을 구행하며
해당 메소드에 매개변수인 HttpSecurity 가 설정을 기반으로 Security Filter Chain 을 생성합니다❗️

해당 이미지는 어떤 원리로 우리가 작성한 Spring Security 에 관련된 설정 클래스가 동작하는지를 간략하게 보여주는 이미지입니다.
존에는 Spring Security 을 위한 설정 클래스임을 정의하기 위해 WebSecurityConfigurerAdapter 을 상속했지만 현재는 SecurityFilterChain 빈을 직접 만들어야 합니다. 따라서 참고로만 부탁드립니다 🙏

정리하자면, SecurityFilterChain Bean 을 반환하는 fiterchain 메소드의 매개변수인 HttpSecurity 을 통해 사용할 필터와 사용자가 직접 정의한 필터를 정의할 수 있습니다.
해당 설정을 바탕으로, 애플리케이션 구동시 Security Filter Chain 이 구성되어 실행됩니다 ❗️

🌱 DelegatingFilterProxy

사용자의 요청이 Spring MVC 에 도달하기 전, 즉 Servlet Container 에서 Delegating Filter Proxy 가 요청을 받습니다.
DelegatingFilterProxy 는 Servlet Container 와 Spring 의 Spring Container 을 연결해주는 필터입니다.

DelegatingFilterProxy 는 Servlet 스펙에 있는 기술이기 때문에 Servlet Container 에서만 생성되고 실행됩니다.
Spring 의 Spring Container 와는 다르기 때문에 Spring Bean 으로 주입하거나 Spring 에서 사용되는 기술을 Servlet 에서 사용할 수 없습니다 🚫
DelegatingFilterProxy 는 실제 보안 처리를 하지 않고 위임만 하는 Servlet Container 에서 동작하는 Servlet Filter 라고 이해하면 될 것 같습니다❗️

동작 원리

1. DelegatingFilterProxy 가 Servlet Container 로 넘어온 사용자의 요청을 받습니다.
2. DelegatingFilterProxy 는 SpringSecurityFilterChain 이름으로 생성된 Bean 을 ApplicationContext 에서 찾습니다.
3. Bean 을 찾으면 SpringSecurityFilterChain 으로 요청을 위임합니다.

이렇게 DelegatingFilterProxy 로 부터 요청을 위임받은 SpringSecurityFilterChain 은 각각의 filter 들에게 순서대로 요청을 맡기며
각각의 필터들이 체인으로 연결되어 수행 -> 넘김 -> 수행 -> 넘김 으로 진행됩니다 ❗️

🌱 FilterChainProxy

DelegatingFilterProxy 으로 부터 요청을 넘겨받은 SpringSecurityFilterChain 빈은 FilterChain 의 역할을 하는 FilterChainProxy 입니다.

해당 이미지는 초기화 부터 시작해서 사용자의 인증 요청이 DelegatingFilterChain 을 거쳐 어떤 filter 을 거쳐 인증/인가가 성공하는지를 보여줍니다.
지금부터, Filter Chain 을 구성하는 대표적인 4가지 filter 을 하나씩 살펴보겠습니다 👨‍💻

1.SecurityContextPersistenceFilter

SecurityContextRepository 에서 SecurityContext 을 가져오거나 생성하는 역할

사용자의 요청이 DelegatingFilterProxy 을 거쳐, SecurityContextPersistenceFilter 을 만나게됩니다.
해당 필터는 SecurityContext 을 영속화 하고 해당 과정은 SecurityContextRepository 에서 이루어집니다. 별도 변경이 없다면 HttpSessionSecurityContextRepository 가 사용되며 HttpSession 의 Attribute 에 SecurityContext 가 저장됩니다 👨‍💻

SecurityContextRepository 는 인터페이스이며 이를 구현한 클래스가 HttpSessionSecurityContextRepository 입니다.
실제 SecurityContext 을 셍성해서 반환하는 SecurityContextRepository 와 이를 구현한HttpSessionSecurityContextRepository 의 코드를 살펴보겠습니다 ❗️

1. SecurityContextRepository 인터페이스

2. HttpSessionContextRepository 클래스

SecurityContext 는 인증 객체(Authentication) 이 저장되는 객체입니다.
해당 객체를 SecurityContextRepository 을 통해 가져오거나 생성할 수 있습니다.

여기서 잠깐 SecurityContextSecurityContextHolder 에 대해 더 구체적으로 알아보겠습니다.

SecurityContext 란?

Authentication(인증된 객체) 가 저장되는 저장소이며, 일반적으로 ThreadLocal 에 저장되며 덕분에 전역적으로 SecurityContext 접근 가능합니다.

SecurityContext 는 필터를 거쳐 인증 완료된 인증 객체를 저장하기 위한 객체입니다.
setAuthentication() 메소드를 통해 Authentication 을 설정할 수 있습니다 ❗️

SecurityContextHolder란?

SecurityContext 을 감싸는 객체이며 실제 SecurityContext 을 위한 ThreadLocal을 가지는 객체입니다.

getContext() 메소드를 통해 감싸고 있는 SecurityContext 을 가져올 수 있고 clearContext() 을 통해 초기화 할 수 있습니다❗️
해당 객체가 동작하는 모드는 3가지가 있는데, 기본적으로 MODE_THREADLOCAL 모드를 사용하기에 ThreadLocal 을 활용해서 SecurityContext 을 저장합니다 👨‍💻

SecurityContextPersistenceFilter 을 거치는 순간 SecuriryContextRepository(= HttpSessionSecurityContextRepository) 에서 SecurityContext 을 가져오는데, 여기서 2가지 경우로 나뉩니다 ❗️

✅ 처음 인증하거나 혹은 익명 사용자일 경우
세션에 저장된 것이 없을 테니 새로 SecurityContext 을 생성하고 SecurityContextHolder 안에 저장을 하고 다음 필터를 실행합니다.

✅ 인증 이력이 있는 경우
이미 있는 SecurityContext 을 가져와서 SecurityContextHolder 에 저장합니다.
-> 처음 인증 시, SecurityContextPersistenceFilter 는 이후의 모든 필터 동작들이 종료 된 후, 다시 자신의 실행흐름으로 돌아와, 인증 완료된 Authentication 객체가 존재할 경우, 이를 SecurityContextRepository 에 저장합니다.

이렇게 SecurityContextSecurityContextHolder 에 저장된 후, 다음 필터로 진행이 이어지며, 추후 인증이 안료되면
SecurityContextHolder 을 통해 Authentication 객체를 SecurityContext 에 저장합니다 ❗️


2. LogoutFilter

이름 그대로 로그아웃을 처리하는 필터입니다.

LogoutFilter 는 로그아웃에 대한 처리를 담당하는 필터로 사용자가 로그아웃 요청을 했을 경우에만 적용되는 필터입니다.
이 필터는 세션 무효화, 인증 토큰 삭제, SecurityContext에서 해당 토큰 삭제 등 로그아웃시 필요한 다양한 기능을 제공합니다 ❗️

해당 이미지는 LogoutFilter 동작 원리를 간단하게 보여주는 이미지입니다.
로그아웃 요청이 들어오면 다음과 같은 단계를 통해 로그아웃을 진행합니다.

  1. AntPathRequestMatcher 을 통해 Logout 을 처리하는 URI 가 들어왔는지 확인합니다. 만약 아닐 경우, 바로 다음 필터로 넘어갑니다.
  2. 만약 사용자의 요청 URI 가 Logout 을 처리하는 URI 라면 저장되어 있는 Authentication 객체를 Security Context 에서 찾고 해당 객체를 SecurityContextLogoutHandler 로 넘겨줍니다.
  3. 기본적인 LogoutHandler 는 4개가 존재하는데, 그 중 SecurityContextLogoutHandler 는 세션 무효화, 쿠키 삭제, SecurityContextHolder 에 담겨있는 SecurityContext 삭제 등의 로그아웃을 위한 작업을 수행합니다.
  4. LogoutFilter 동작이 끝나면 SimpleUrlLogoutSuccessHandler 가 동작을 합니다.

그렇다면 로그아웃을 위해 어떤 설정이 필요할까요? 🤔
바로 Spring Security Filter Chain 을 초기화하는 HttpSecurity 객체를 이용하면 됩니다.

// SecurityConfig.java 내부
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
               .logout() // 로그아웃 처리
               			.logloutUrl("/auth/logout/") // 로그아웃 처리 URL 설정
                        .logoutSuccessUrl("/login") // 로그아웃 성공 후 이동 페이지(단순 해당 페이지로 리다이렉트)
                        .deleteCookies("JSESSIONID", "remember - me") // 로그아웃 후 해당 쿠키 삭제
                        .addLogoutHandler(logoutHandler()) // 로그아웃 핸들러
                        .logoutSuccessHandler(logoutSuccessHandler()) // 로그아웃 성공 후 핸들러
}

@Bean
public LogoutHandler logoutHandler() {
		return new CustomLogoutHandler(); // LogoutHandler 인터페이스를 구현한 CustomLogoutHandler
}


@Bean
public LogoutSuccessHandler logoutSuccessHandler() {
		return new CustomLogoutSuccessHandler(); // LogoutSuccessHandler 인터페이스를 구현한 CustomSuccessLogoutHandler
}

해당 코드는 Security 에 전반적인 설정을 위한 설정 클래스인 SecurityConfig 을 통해 LogoutFilter 대한 설정을 하는 코드입니다.
여기서 조금 설명을 덧붙이자면, 만약 개발자가 로그아웃 후, 추가적으로 구현하고 싶은 내용이 있을때 LogoutHandler 을 구현한 커스텀 로그아웃 핸들러를 통해 구현 가능합니다.
LogoutSuccessHandler 는 특별히 로그아웃 성공 후 구현하고 싶은 내용이 있는 경우 사용합니다 👨‍💻

3. UsernamePasswordAuthenticationFilter

Form Based Authentication(폼 기반 인증) 을 위한 인증 필터입니다.

UsernamePasswordAuthenticationFilterForm Based Authentication 방식으로 인증을 진행할 때 아이디, 패스워드 데이터를 파싱하여 인증 요청을 위임하는 필터입니다.
여기서 폼 기반이란 사용자가 입력한 인증 정보인 usernamepassword 을 통해 인증을 하는 방식으로 요청의 Content-type 은 일반적으로 application/x-www-form-urlEncoded 입니다.
x-www-form-urlencoded와 application/json의 차이 (HTTP Content-Type) 을 참고해주세요 ❗️

즉, 유저가 로그인 창에서 로그인 시도를 할 때 보내지는 아이디와 패스워드 데이터를 가져온 후 인증을 위한 토큰을 생성 후 인증을 다른 쪽에 위임하는 역할을 하는 필터입니다.


해당 이미지는 UsernamePasswordAuthenticationFilter 을 거치는 경우 인증 로직을 보여주는 이미지입니다.
즉 폼 기반 인증인 경우, Spring Security 에 대한 기본 구조인 AuthenticationManager,AuthenticationProvider,UserDetailsService 등 Spring Security 에서 제공하는 클래스 및 인터페이스를 이용해서 인증을 시도합니다.
여기서, UsernamePasswordAuthenticationFilter 에서 처리할 수 있는 AuthenticationProviderDaoAuthenticationProvider 입니다.

Spring Boot 기반의 HttpSecurity 를 설정하는 코드에서 http.formLogin() 을 하는 경우 기본적으로 UsernamePasswordAuthenticationFilter 을 사용하게 됩니다 ❗️
이때는 Sprng Security 에서 제공하는 기본 로그인 페이지가 나옵니다.

@Override
protected void configure(HttpSecurity http) throws Exception {
	http.formLogin(); // UsernamePasswordAuthenticationFilter 을 거치게 된다.
}

하지만 서버의 인증 방식에는 폼 기반 인증 방식외에도 자주 사용되는 다른 방식이 존재합니다.
현재 가장 많이 쓰이는 API 기반 인증 방식(API-Based Authentication) 는 요청 본문의 Json 데이터를 통해 인증을 시도하는 방식입니다. [REST API로 토큰 기반 인증 사용]
API 기반 인증 방식은 현재 가장 많이 사용되는 인증 방식입니다. 이럴때에는 폼 기반 인증을 비활성화 해야겠죠?
이때 http.formLogin().disable() 을 하면 됩니다 ❗️
http.formLogin().disable() 은 폼 기반 로그인 방식을 비활성화 한다는 뜻으로, 다른 로그인 방식을 사용한다는 것을 의미합니다.
이런 경우, 사용자가 인증 방식을 구성해야 하며, 일반적으로 http.formLogin().disable() 을 한 후, 다른 인증 방식을 사용하는 것이 일반적입니다 👨‍💻

@Override
protected void configure(HttpSecurity http) throws Exception {
	http.formLogin().disable()
    		// Jwt 을 통한 인증방식을 사용하는 JwtAuthenticationFilter 을 사용한다.
    		.addFilterBefore(jwtAuthenticationFilter,new UsernamePasswordAuthenticationFilter(CustomAuthenticationFilter())
            ...
}

또한 폼 기반 인증 방식처럼 Spring Security 에서 기본적으로 제공하는 필터가 존재하지 않기에 개발자가 직접 인증 필터를 구현해야합니다 🤔

UsernamePasswordAuthenticationFilter 의 코드를 간단하게 살펴보겠습니다 ❗️

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 : "";
            username = username.trim();
            String password = this.obtainPassword(request);
            password = password != null ? password : "";
            UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
            this.setDetails(request, authRequest);
            return this.getAuthenticationManager().authenticate(authRequest);
        }
    }

해당 코드는 UsernamePasswordAuthenticationFilter 내부의 attemptAuthentication 메소드입니다.
request안에서 username, password 파라미터를 가져와서 UsernamePasswordAuthenticationToken 을 생성 후 AuthenticationManager을 구현한 객체에 인증을 위임합니다 ❗️

1. HttpServletRequest 객체에서 getParamter() 메소드를 통해 Username, Password 정보를 가져온다.
2. 해당 정보를 통해 Authentication 인터페이스를 구현한 인증 전 객체 UsernamePasswordAuthenticationToken 을 생성한다.
3. 인증을 위해 AuthenticationManager 의 authenticate 메소드를 호출하여 인증을 시도한다.

4. ExceptionTranslationFilter

FilterChain 을 거치면서 발생하는 예외를 처리하기 위한 용도의 필터입니다.

해당 이미지는 FilterChain 을 거치는 동안 발생하는 예외를 처리하는 ExceptionTranslationFilter 의 동작 과정을 보여주는 이미지입니다.
로그인시 발생할 수 있는 예외는 크게 두가지로 구분할 수 있습니다.

  1. AuthenticationException : 인증 예외 처리

    • AuthenticationEntryPoint 호출
      : 인증이 실패했을 때 어떻게 handling 할지를 결정합니다.
      로그인 페이지로 리다이랙트, 401(Unauthorized) 상태 코드 등의 작업을 수행할 수 있습니다.

    • RequestCache 와 SavedRequest
      : RequestCache 와 SavedRequest 는 이름에서 알 수 있듯이, 요청에 대한 정보를 기록하기 위한 용도로 사용합니다.
      로그인 전 어떤 URL 로 접속했는데 인증이 안되어서 로그인 페이지로 가는 경우, 사용자가 접근한 정보를 RequestCache 와 SavedRequest 에 담아두었다가, 사용자가 로그인하면 담아둔 정보를 바탕으로 사용자가 인증 안되었을 때 요청했던 자원을 전달해주는 방식입니다.
      RequestCache 에는 클라이언트의 요청 정보를 저장하며, SavedRequest 는 요청의 파라미터,헤더를 저장합니다.

  1. AccessDeniedException : 인가 예외 처리

    • AccessDeniedHandler 호출
      : 요청 자원에 사용자의 권한이 없는 경우 예외처리를 위한 Handler 입니다.

참고

Spring Security, 제대로 이해하기 - FilterChain
Spring Security - UsernamePasswordAuthenticationFilter 란
[Spring Security] Filter란?
Spring Security - LogoutFilter 란

profile
비즈니스가치를추구하는개발자

1개의 댓글

comment-user-thumbnail
2024년 10월 7일

좋은 정리글 감사합니다

답글 달기