Spring Security 의 인증 알아보기

madcoder
NAVER Pay Dev Blog
Published in
25 min readJun 18, 2024

--

안녕하세요. 네이버페이 회원&인증BE 의 최용화입니다.

Spring Security는 강력한 보안 프레임워크로서, 애플리케이션의 인증과 인가 과정을 효율적으로 관리합니다. 저희 팀에서는 다음과 같은 기능을 구현하는 데에 Spring Security 를 사용하고 있습니다.

인증 여부 확인 (인증이 안된 사용자일 경우 로그인 및 가입 유도 / 인증이 완료된 사용자일 경우 적절한 권한 부여), 권한을 이용한 접근 제어(권한이 없는 사용자일 경우 권한 획득을 위한 절차 수행), 보안 공격으로부터 보호(CSRF 공격 방지), PC / MOBILE 최적화 페이지 제공(사용자가 접근한 환경을 파악하여 최적화된 페이지 제공)

이 글에서는 Spring Security의 인증(Authentication) 과정 전반을 살펴보고, 각 단계의 역할과 작동 방식을 자세히 알아보겠습니다.

이 글은 Spring Security 6.3.0 공식문서를 기반으로 작성되었습니다.

Spring Security 의 Filter 기반 동작 방식 이해

Spring Security의 인증 수행을 이해하려면 Spring Security 의 구조에 대한 이해가 선행되어야 합니다. Spring Security는 Servlet Filter 기반으로 동작합니다. 여기서, 중요한 개념인 FilterChainProxy, SecurityFilterChain, 보안 필터(Security Filter)에 대해 알아봅니다.

FilterChainProxy의 개념

FilterChainProxy는 Spring Security에서 제공하는 특수한 Filter로 SecurityFilterChain을 사용하여 다양한 보안 필터가 동작하게 합니다.

사실, Servlet Container의 라이프사이클과 Spring의 ApplicationContext 사이를 연결하는 DelegatingFilterProxy 라는 필터 구현체도 중요한 상위 개념이나 이 글에서는 설명을 생략합니다. 자세한 설명은 이 페이지를 참고하세요.

SecurityFilterChain의 개념

SecurityFilterChain은 Spring Security에서 보안 필터(Security Filter)의 체인을 정의하는 데 사용됩니다. 요청이 애플리케이션의 Servlet에 도달하기 전에 다양한 보안 검사를 수행하는 필터들이 있으며, 이를 보안 필터라고 부릅니다. SecurityFilterChain은 각 보안 필터가 순차적으로 실행되도록 하여 애플리케이션의 보안 설정을 체계적으로 관리할 수 있게 합니다.

보안 필터(Security Filter)의 주요 기능

SecurityFilterChain 에 선언된 다양한 보안 필터를 통해 아래의 기능을 수행하게 됩니다.

  • 인증(Authentication): 사용자의 신원을 확인합니다. 예를 들어, 사용자가 로그인 폼을 제출하면, 이를 처리하는 필터가 실행됩니다.
  • 인가(Authorization): 사용자가 요청한 리소스에 접근할 권한이 있는지 확인합니다.
  • 각종 보안 공격으로부터 보호(Protection Against Exploits): CSRF 공격, Session Fixation 공격, sniffing 공격, Clickjacking 등의 보안 공격으로부터 보호합니다.
  • 세션 관리: 사용자의 세션을 생성, 관리, 종료하는 과정입니다.
  • 기타 기능: HTTP 응답 헤더를 설정하여 보안을 강화하는 기능, Remember Me 기능 등을 지원합니다.

보안 필터(Security Filter) 소개

여러 개의 보안 필터가 있지만, 이 글에서 자주 보게 될 몇 가지 보안 필터만 가볍게 소개 드리려고 합니다. 나열된 순서대로 실행됩니다.

  • UsernamePasswordAuthenticationFilter: 폼 기반 로그인 처리를 수행합니다.
  • DefaultLoginPageGeneratingFilter: 기본 로그인 페이지를 생성합니다.
  • ExceptionTranslationFilter: ExceptionTranslationFilter 의 다음 Filter 에서 발생한 Exception을 처리하고 이에 대한 적절한 응답을 반환합니다.
  • AuthorizationFilter: 사용자가 요청한 리소스에 대해 접근 권한이 있는지 확인합니다. 권한이 없는 경우 접근을 거부하고, 적절한 에러 페이지를 반환하거나 예외를 발생시킵니다.

Spring Security의 Form 기반 인증

인증(Authentication)은 특정 리소스에 액세스하려는 주체(Principal)의 신원을 확인하는 과정입니다. Spring Security는 다양한 인증 방법을 지원하며, 이 글에서는 주로 폼 기반 인증(Form-Based Authentication)을 예로 들어 설명하겠습니다.

인증 관련 주요 용어

Spring Security 인증과 관련하여 자주 사용되는 용어에 대해 설명합니다.

  • SecurityContextHolder: Spring Security가 인증된 사용자의 정보를 저장하는 곳입니다.
  • SecurityContext: SecurityContextHolder에서 가져오며 현재 인증된 사용자의 인증정보(Authentication)를 포함합니다.
  • Authentication: 사용자가 입력한 자격 증명(Pricipal과 Credentials)을 AuthenticationManager에 전달하는 용도로 사용되거나 SecurityContext에서 현재 사용자를 나타내는 용도로 사용되는 객체입니다.
  • GrantedAuthority(Authorities): 인증된 사용자에게 부여된 권한을 나타내며, 역할(role)이나 범위(scope) 등을 포함합니다.
  • AuthenticationManager: Spring Security의 필터가 인증을 수행하는 방법을 정의한 API(인터페이스)입니다.
  • ProviderManager: AuthenticationManager 의 구현체입니다.
  • AuthenticationProvider: ProviderManager가 여러 종류의 인증(Basic 인증, Form 인증 등)을 지원 및 수행하기 위해 사용하는 인터페이스입니다. 하나의 ProviderManager에 여러 개의 AuthenticationProvider를 등록하여 사용할 수 있습니다. 가장 흔히 사용되는 구현체는 DaoAuthenticationProvider입니다.

Form 기반 인증 수행 과정 — UsernamePasswordAuthenticationFilter

아래는 UsernamePasswordAuthenticationFilter 에서 수행하는 인증 과정에 대한 도식입니다.

  1. Form 기반 인증 요청에서 username과 password를 추출하여 UsernamePasswordAuthenticationToke 객체를 ProviderManager에 전달합니다. (여기서, UsernamePasswordAuthenticationToken은 위에서 설명한 Authentication 인터페이스의 구현체이고, ProviderManager는 위에서 설명한 AuthenticationManager 인터페이스의 구현체입니다.)
  2. ProviderManager는 DaoAuthenticationProvider 를 이용하여 인증을 수행합니다. (여기서, DaoAuthenticationProvider 는 위에서 설명한 AuthenticationProvider 인터페이스의 구현체입니다.)
  3. DaoAuthenticationProvider는 UserDetailsService를 이용해 전달받은 username과 일치하는 UserDetails(저장된 사용자 정보)를 조회합니다.
  4. DaoAuthenticationProvider는 PasswordEncoder를 이용해 전달받은 password와 3번 과정에서 조회한 UserDetails의 비밀번호가 일치하는지 검증합니다.
  5. 4번 과정에서 비밀번호 검증까지 성공하면 사용자 인증은 성공한 것입니다. 이 때, 인증이 완료된 UsernamePasswordAuthenticationToken 을 반환하게 되며 이 구현체의 principal 값은 UserDetailsService에서 조회해온 UserDetails로 설정됩니다.
  6. 최종적으로, 반환된 UsernamePasswordAuthenticationToken은 SecurityContextHolder에 설정됩니다.

Form 기반 인증 수행 예시 — 예제 코드

인증 수행 과정을 Spring Security 예제 코드와 TRACE 로깅을 통해 확인해봅니다.

먼저, Gradle 의존성부터 설정합니다.

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
}

리소스를 관리하는 ResourceController.java 입니다.

@Controller
public class ResourceController {
@GetMapping("/private")
@ResponseBody
public String loginSuccess() {
return "Private Resource";
}
}

우리가 보호하려는 리소스에 대한 핸들러를 간단하게 명시하였습니다.

다음으로 Spring Security 에서 제공하는 Form 기반 인증을 구성한 SecurityConfig.java 입니다.

@EnableWebSecurity
@Configuration
public class SecurityConfig {

@Bean
public UserDetailsService userDetailsService() {
InMemoryUserDetailsManager inMemoryUserDetailsManager = new InMemoryUserDetailsManager();
UserDetails user = User.withDefaultPasswordEncoder()
.username("user")
.password("12345")
.authorities("READ")
.build();
inMemoryUserDetailsManager.createUser(user);
return inMemoryUserDetailsManager;
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(authorize -> {
authorize
.requestMatchers("/private").hasAuthority("READ")
.anyRequest().authenticated();
});
http.formLogin(Customizer.withDefaults());
return http.build();
}
}

이 구성에서 설정한 내용은 아래와 같습니다.

  1. 사용자 정보 저장을 위해 메모리 저장소를 이용하며 사용자 정보를 추가해두었습니다. 사용자의 정보는 다음과 같습니다.
  • username: user
  • password: 12345
  • 권한: READ

2. 보호할 리소스인 http://localhost:8080/private 리소스에 접근하기 위해서는 인증된 사용자여야 하며, 인증된 사용자가 가지는 권한 중 READ 권한이 있어야 함을 명시하였습니다.

3. Spring Security 에서 기본적으로 제공하는 Form 기반 인증을 수행합니다.

다음으로 Spring Security 에서 제공하는 로깅 기능을 명시한 application.properties 파일입니다.

logging.level.org.springframework.security=TRACE

Security Filter가 수행되는 순서와 사용자 요청이 어떻게 처리되는지 로그를 통해 확인할 수 있습니다.

Form 기반 인증 수행 예시 — 인증되지 않은 사용자가 리소스를 요청할 때

흐름도

  1. 인증되지 않은 사용자가 http://localhost:8080/private 리소스에 접근을 시도합니다.
  2. AuthorizationFilter에서 AccessDeniedException을 발생시켜 인증되지 않은 요청이 거부되었음을 알립니다.
  3. ExceptionTranslationFilter는 AuthorizationFilter 에서 발생한 AccessDeniedException 에 대한 처리로 아래의 과정을 수행합니다.
  • SecurityContextHolder 에 저장된 Authentication 데이터가 지워집니다.
  • 추후에 인증 과정이 성공할 때, 현재 실패한 http://localhost:8080/private 요청을 바로 수행할 수 있도록 현재의 요청 정보가 담긴 HttpServletRequest 객체를 RequestCache에 저장해둡니다.
  • AuthenticationEntryPoint에 구현된 인증되지 않은 사용자에게 자격 증명을 요청하는 기능을 수행합니다. 여기서 구현된 AuthenticationEntryPoint 객체는 LoginUrlAuthenticationEntryPoint 이므로 로그인 페이지(기본 설정 값은 http://localhost:8080/login)로 redirect 하는 작업을 수행하게 됩니다.

4. 사용자의 브라우저는 redirect된 로그인 페이지 (기본 설정 값은 http://localhost:8080/login) 를 요청하게 됩니다.

5. LoginController 에서 로그인 페이지(login.html)를 렌더링하여 응답합니다.

로그 확인

위 로그는 흐름도 상 1 ~ 2번까지의 과정에 대한 로그입니다. 인증되지 않은 사용자가 http://localhost:8080/private 요청을 호출할 때 발생하는 로그입니다. 로그를 통해 아래와 같은 사실을 알 수 있습니다.

FilterChainProxy를 통해 15개의 보안 필터(Security Filter)가 수행됩니다.

8번째 보안 필터로 UsernamePasswordAuthenticationFilter가 수행되지만, 로그인 요청(POST /login)이 아니기 때문에 실질적인 인증 과정은 수행되지 않습니다. Spring Security 내부 구현은 아래와 같습니다.

UsernamePasswordAuthenticationFilter — 로그인 요청(POST /login) 이 아닐 경우, 인증 로직을 수행하지 않고 다음 보안 필터 호출
UsernamePasswordAuthenticationFilter — 로그인 요청(POST /login) 인지 확인

15번째 보안 필터로 AuthorizationFilter가 수행되지만, /private 리소스에 접근하려는 사용자가 인증되지 않은 사용자이기 때문에 AccessDeniedException 을 던집니다.

14번째 보안 필터인 ExceptionTranslationFilter 는 15번째 보안 필터인 AuthorizationFilter에서 던지는 AcessDeniedException 을 잡아 이전 절에서 설명한 예외 처리 로직을 수행합니다. Spring Security 내부 구현은 아래와 같습니다.

ExceptionTranslationFilter — 다음 보안 필터 수행 중 예외 발생 시, handleSpringSecurityException 메소드 호출
handleSpringSecurityException 메소드 — 발생한 예외가 AccessDeniedException 일 경우, handleAceessDeniedException 메소드 호출
handleAceessDeniedException 메소드 — 인증 정보가 AnonymousAuthentication이므로 sendStartAuthentication 메소드 호출
SecurityContext 제거 / 기존 요청 저장 / 로그인 진입점으로 이동

위 로그는 흐름도 상 3 ~ 5번까지의 과정에 대한 로그입니다.

로그를 통해 아래와 같은 사실을 알 수 있습니다.

14번째 보안 필터인 ExceptionTranslationFilter 에 의해 아래 작업이 수행됩니다.

  • 현재의 요청 정보가 담긴 HttpServletRequest 객체를 HttpSessionRequestCache에 저장해둡니다.
  • AuthenticationEntryPoint 에 의해 로그인 페이지(http://localhost:8080/login)로 redirect 하게 됩니다.

사용자가 인증을 위해 로그인 페이지(http://localhost:8080/login)를 요청하면, 9번째 보안 필터인 DefaultLoginPageGeneratingFilter 에 의해 기본 설정된 로그인 페이지를 응답합니다. Spring Security 내부 구현은 아래와 같습니다.

DefaultLoginPageGeneratingFilter — 기본 로그인 페이지 생성 및 응답

Form 기반 인증 수행 예시 — 인증 과정에서 실패할 때

흐름도

  1. 사용자가 username과 password를 제출하면 UsernamePasswordAuthenticationFilter는 HttpServletRequest 객체에서 username과 password를 추출하여 Authentication의 구현체인 UsernamePasswordAuthenticationToken을 생성합니다.
  2. 다음으로 UsernamePasswordAuthenticationToken이 인증을 위해 AuthenticationManager 인스턴스로 전달됩니다.
  3. 인증이 실패하면 아래 과정을 수행합니다.
  • SecurityContextHolder 에 저장된 Authentication 데이터가 지워집니다.
  • RememberMeServices.loginFail() 메소드가 호출됩니다. RememberMeService 를 설정하지 않은 경우, 어떤 작업도 수행되지 않습니다. 이 예제에서는 RememberMeService 기능을 별도로 설정하지 않았기 때문에 어떤 작업도 수행되지 않습니다.
  • AuthenticationFailureHandler 에 구현된 onAuthenticationFailure() 메소드를 수행합니다. 기본적으로 설정되어 있는 AuthenticationFailureHandler의 구현체는 SimpleUrlAuthenticationFailureHandler 입니다. 인증이 실패하면 /login?error URL로 redirect합니다. 로그인 페이지에서는 error 파라미터의 값을 사용하여 인증 실패 메시지를 사용자에게 표시할 수 있습니다.

로그 확인

위 로그는 흐름도 상 1 ~ 2번까지의 과정에 대한 로그입니다.

인증되지 않은 사용자가 POST http://localhost:8080/login 요청을 잘못된 인증 정보와 함께 전송 시, 발생하는 로그입니다. 로그를 통해 아래와 같은 사실을 알 수 있습니다.

8번째 보안 필터로 UsernamePasswordAuthenticationFilter가 수행되고 ProviderManager 와 DaoAuthenticationProvider 가 순차적으로 수행되며 인증이 수행됩니다. Spring Security 내부 구현은 아래와 같습니다.

UsernamePasswordAuthenticationFilter — AuthenticationManager 의 authenticate 메소드 호출
AuthenticationManager 의 authenticate 메소드 — AuthenticationProvider의 authenticate 메소드 호출
AuthenticationProvider의 authenticate 메소드 — 제출된 username과 일치하는 사용자 정보가 없어 BadCredentialException 메소드 호출

위 로그는 흐름도 상 3번 과정에 대한 로그입니다. 로그를 통해 아래와 같은 사실을 알 수 있습니다.

인증 실패 시, 아래 작업을 수행합니다.

SecurityContextHolder 에 저장된 Authentication 데이터가 지워집니다.

SimpleUrlAuthenticationFailureHandler 에 구현된 onAuthenticationFailure() 메소드를 수행합니다. /login?error URL로 redirect합니다. Spring Security 내부 구현은 아래와 같습니다.

AuthenticationFailureHandler 의 onAuthenticationFailure 메소드 — 인증 실패 시, 로그인 페이지로 redirect

Form 기반 인증 수행 예시 — 인증 과정에서 성공할 때

흐름도

  1. 사용자가 username과 password를 제출하면 UsernamePasswordAuthenticationFilter는 HttpServletRequest 객체에서 username과 password를 추출하여 Authentication의 구현체인 UsernamePasswordAuthenticationToken을 생성합니다.
  2. 다음으로 UsernamePasswordAuthenticationToken이 인증을 위해 AuthenticationManager 인스턴스로 전달됩니다.
  3. 인증이 성공하면 아래 과정을 수행합니다.
  • 새로운 로그인이 발생한 것을 SessionAuthenticationStrategy에 통지합니다.
  • SessionAuthenticationStrategy는 새로운 로그인이 발생할 때, 세션 관련 작업을 수행하는 전략입니다. 이전 세션을 무효화하거나, 동시 로그인 방지 정책을 적용할 수 있습니다.
  • SecurityContextHolder를 새롭게 인증이 완료된 Authentication 데이터로 설정합니다.
  • RememberMeServices.loginSuccess() 메소드가 호출됩니다. RememberMeService 를 설정하지 않은 경우, 어떤 작업도 수행되지 않습니다. 이 예제에서는 RememberMeService 기능을 별도로 설정하지 않았기 때문에 어떤 작업도 수행되지 않습니다.
  • ApplicationEventPublisher가 InteractiveAuthenticationSuccessEvent를 발행합니다.
  • AuthenticationSuccessHandler의 onAuthenticationSuccess() 메소드가 호출됩니다. 기본적으로 설정되어 있는 AuthenticationSuccessHandler 의 구현체는 SavedRequestAwareAuthenticationSuccessHandler 입니다. 로그인 페이지로 redirect되기 전, ExceptionTranslationFilter에서 RequestCache에 저장해둔 원래의 요청을 꺼내와 해당 요청으로 redirect 합니다.

로그 확인

위 로그는 흐름도 상 1 ~ 3번 과정에 대한 로그입니다.

인증되지 않은 사용자가 POST http://localhost:8080/login 요청을 정상적인 인증 정보와 함께 전송 시, 발생하는 로그입니다. 로그를 통해 아래와 같은 사실을 알 수 있습니다.

8번째 보안 필터로 UsernamePasswordAuthenticationFilter가 수행되고 ProviderManager 와 DaoAuthenticationProvider 가 순차적으로 수행되며 정상적으로 인증이 수행됩니다.

인증 성공 시, 아래 작업을 수행합니다.

CompositeSessionAuthenticationStrategy 에서 2가지 세션 관련 작업을 수행합니다.

  • ChangeSessionIdAuthenticationStrategy 를 이용하여 Session Fixation 공격을 방지하기 위한 세션 ID 변경 작업을 수행합니다.
  • CsrfAuthenticationStrategy 를 이용하여 세션에 연결된 CSRF 토큰을 교체합니다.

SecurityContextHolder를 새롭게 인증이 완료된 Authentication 데이터로 설정합니다.

SavedRequestAwareAuthenticationSuccessHandler 에 구현된 onAuthenticationSuccess() 메소드를 수행합니다. 인증 이전에 요청했던 리소스인 http://localhost:8080/private?continue URL로 redirect 를 수행합니다. Spring Security 내부 구현은 아래와 같습니다.

기존 요청을 RequestCache에서 꺼내와서 해당 요청을 다시 수행하도록 redirect

위 로그는 인증이 완료된 사용자가 GET http://localhost:8080/private로 redirect 될 때, 발생하는 로그입니다. 로그를 통해 아래와 같은 사실을 알 수 있습니다.

15번째로 수행되는 보안 필터인 AuthorizationFilter 에서 사용자의 권한을 확인하는 과정을 수행합니다. 사용자의 권한이 모두 확인되면 FilterChainProxy는 모든 보안 필터가 수행되었으므로 보안적인 절차를 수행되었다는 로그를 출력합니다. 최종적으로 인증을 수행하기 전, 처음 요청했던 리소스에 접근할 수 있게 됩니다.

글을 마치며

Spring Security의 인증 과정은 체계적이고 확장 가능하게 설계되어 있습니다. 각 구성 요소는 명확한 책임을 가지고 있으며 이를 통해 다양한 인증 요구 사항을 유연하게 처리할 수 있습니다. 이번에는 기본적인 폼 기반 인증 과정을 설명했지만, Spring Security는 OAuth2, JWT, LDAP 등 다양한 인증 방식을 지원하므로 애플리케이션의 보안 요구 사항에 맞춰 적절한 인증 방식을 선택할 수도 있습니다.

이 글이 Spring Security의 전반적인 인증 과정과 인증에 필요한 구성 요소에 대한 이해에 도움이 되기를 바랍니다.

참고자료

--

--