[Spring Security] 인증 흐름 및 절차

2023. 3. 7. 16:40Spring/Security

1. 들어가며

스프링 시큐리티는 여러 가지 인증을 처리할 수 있도록 확장성 있게 개발되었다. 그 중 이번에는 기본적으로 제공되는 username과 password를 이용한 form 기반의 인증을 다룰 것이다.

2. SecurityContextPersistenceFilter

인증 처리가 시작되기 전에 거치게 되는 필터이다. 이 필터는 SecurityContext가 현재 요청에 포함되어 있는지 확인하고 없다면 만들어주는 역할을 수행한다. 그리고 이 SecurityContext는 하나의 요청의 흐름이 사용자에게 응답될 때까지 유지된다. 그 기반은 ThreadLocal로 동작한다. 실제로 인증을 처리하는 필터는 이후에 등장하는 UsernamePasswordAuthenticationFilter이다.

3. Authentication Flow

스프링 시큐리티의 인증 처리 흐름은 다음 그림과 같다.

웹 요청이 오면 먼저 앞서 SecurityContextPersistenceFilter에서 SecurityContext를 만들어 놓는다. 그러다 UsernamePasswordAuthenticationFilter를 만나면 인증 처리를 수행한다. UsernamePasswordAuthenticationFilter는 AuthenticationFilter라는 기반 클래스를 상속받은 필터이며 이 말은 곧 우리가 원하는 방식으로 필터를 만들어서 끼워넣는다면 커스텀한 인증 방식을 사용할 수도 있다는 말이다.

3-1. AuthenticationFilter

위 그림에서 보듯이 AuthenticationFilter에 도달하여 내부 로직이 수행된다. 이 필터에서는 인증되지 않은 AuthenticationToken을 만들고 내부에 저장된 AuthenticationManager에게 토큰과 함께 이 요청이 인증된 사용자인지 판단을 요청하고 그 응답을 받는다.만약 Authentication 객체가 넘어온다면 인증에 성공한 것이고 null이 넘어온다면 인증에 실패한 것이다. doFilter()는 AbstractAuthenticationProcessingFilter에 있으며 attemptAuthentication는 이 클래스를 상속한 클래스가 구현하여 동작된다. 이 흐름에서 상속 클래스는 UsernamePasswordAuthenticationFilter가 된다.

 

AbstractAuthenticationProcessingFilter의 doFilter()

 

추상 메서드인 attempAuthentication()은 상속 클래스가 구현하여 사용한다.
UsernamePasswordAuthenticationFilter의 attemptAuthentication()

UsernamePasswordAuthenticationFilter는 이 추상 메서드를 구현하여 실제 인증을 수행하고 그 결과를 반환한다. 이때 요청에서 username과 password를 꺼내서 UsernamePasswordAuthenticationToken으로 만들어서 넘긴다. 이는 나중에 AuthenticationProvider에서 처리 여부를 판단하는 근거가 된다.

3-2. AuthenticationManager

AuthenticationManager는 Filter로부터 요청을 받아 내부의 AuthenticationProvider에게 인증 처리를 위임하고 그 결과를 다시 Filter에게 반환하는 역할을 수행한다. AuthenticationManager는 인터페이스이며 이 구현체로 기본 제공되는 것이 ProviderManager이다.

ProviderManager는 내부에 여러 AuthenticationProvider를 가지고 있으며 이 Provider 중 요청을 처리할 수 있는 Provider를 찾아낸 뒤 authenticate()를 진행한다.

핵심 메서드 - authenticate()

이 메서드는 Authentication을 인자로 받고 내부 인증 로직을 처리한 뒤 Authentication 객체를 반환한다. 이때 인증이 성공하면 인증이 되었는지를 판단하는 isAuthenticated()의 값이 true가 된다. 또한 반환값에 인증된 Authentication을 담아서 반환한다. 만약 이 반환값이 null이라면 인증에 실패한 것으로 간주한다.

 

반환할 Authentication 객체 변수는 result이며 처음에 null이 할당된 것을 볼 수 있다. 만약 provider.authenticate()가 성공적으로 수행되어 인증이 성공한다면 result 변수에 인증된 Authentication 객체가 할당된다. 만약 인증에 실패하면 그대로 null로 반환된다.

authenticate()는 내부에서 provider를 가져와서 해당 provider가 요청을 처리할 수 있는지 먼저 판단한 다음 try 문에서 provider.authenticate()로 처리하는 것을 볼 수 있다. 위에서는 DaoAuthenticationProvider가 적용되었고 유저가 입력한 username(principal)은 user, password(credentials)는 긴 문자열로 들어왔다. 그리고 맨 아래에 authenticated라는 인증 여부를 판단하는 변수도 담겨있다.

 

인증에 성공하면 authenticationResult에 Authentication 객체가 담기고 authenticated가 true로 바뀐 것을 볼 수 있다.

3-3. AuthenticationProvider

인증 방법을 제공하기 위한 인터페이스이다. 위에서 적용된 AuthenticationProvider는 DaoAuthenticationProvider였다.

핵심 메서드 - supports(), authenticate()

supports()는 필터에서 보내준 Authentication 객체를 현재 AuthenticationProvider가 처리할 수 있는지 확인하는 메서드이다.

 

DaoAuthenticationProvider가 상속하는 AbstractUserDetailsAuthenticationProvider에 구현된 supports()이다. 넘어온 클래스가 UsernamePasswordAuthenticationToken인지 확인한다.

authenticate() 는 인증 과정을 수행하는 메서드이다. 이 또한 AbstractUserDetailsAuthenticationProvider에 구현되어 있다.

 

user를 가져올 때 retrieveUser() 메서드를 사용하는데 이 메서드를 AbstractUserDetailsAuthenticationProvider를 상속하는 AuthenticationProvider에서 구현해서 처리한다. 이번에는 DaoAuthenticationProvider가 될 것이다.

 

DaoAuthenticationProvider에 구현된 retrieveUser() 메서드이다. UserDetailsService에서 username을 기반으로 유저 정보를 찾아온다.

반환 타입은 UserDetails인데 이는 사용자에 대한 정보가 담긴 객체라고 보면 된다. 그렇게 반환을 하게 되면 createSuccessAuthentication() 메서드에 Authentication 객체와 UserDetails 객체를 담아서 호출한다.

 

createSuccessAuthentication() 메서드를 보면 인증 토큰을 만들 때 authenticated() 메서드로 호출하고 있다. 이 메서드는 인증이 되었다는 의미이며 실제 코드를 따라 올라가보면 UsernamePasswordAuthenticationToken의 생성자에서 setAuthenticated()를 true로 설정하는 것을 볼 수 있다. 그렇게 인증 처리가 된 Authentication 객체가 AuthenticationProvider의 최종 반환값이 된다.

 

3-4. UserDetailsService

AuthenticationProvider에서 UserDetails를 가져오기 위해 사용하며 인터페이스이다. 비즈니스 로직에서 사용하는 Service와 같은 맥락이다.

핵심 메서드 - loadUserByUsername()

이 메서드를 구현하여 실제 유저에 대한 정보를 가져온다. 특별한 구현이 없다면 스프링 시큐리티에서는 InMemoryUserDetailsManager라는 클래스를 등록해 놓는다. 이 클래스는 내부에 Map을 통해 유저 정보를 등록하고 관리한다. 이 클래스는 UserDetailsManager를 구현하는데 이 인터페이스가 UserDetailsService를 상속하고 있어서 사용 가능하다. 일반적으로는 UserDetailsService를 구현하여 DB에서 유저 정보를 가져오도록 커스텀한다. 인메모리는 가급적 테스트용으로만 사용하는 것이 바람직하다.

 

Javadoc에 써 있듯이 UserDetailsService에 유저 처리를 제공하기 위한 확장용 인터페이스임을 알 수 있다.

4. 정리

위 과정을 글로 정리하면 다음과 같다.

 

  1. 유저가 서버에 요청을 보낸다.
  2. 요청이 DelegatingFilterProxy 필터를 만나고 FilterChainProxy에 위임되어 등록되어 있는 SecurityFilterChain들의 조건과 매칭한다. 이번 예제에서는 어떤 요청이든 인증을 받도록 했다.
  3. 매칭되는 SecurityFilterChain이 있는 경우 해당 필터 체인으로 흐름이 이어진다.
  4. 필터 체인을 돌면서 여러 처리를 하고 AuthenticationFilter를 만나 인증을 수행한다.
  5. AuthenticationFilter에서는 AuthenticationToken을 만들고 AuthenticationManager에게 인증 여부를 요청한다.
  6. AuthenticationManager는 내부의 AuthenticationProvider들 중 처리할 수 있는 AuthenticationProvider에게 인증 여부를 요청한다.
  7. AuthenticationProvider는 UserDetailsService를 통하여 유저 정보를 가져온다.
  8. UserDetailsService는 조회가 성공하면 UserDetails 객체를 반환하고, 실패하면 UsernameNotFoundException을 던진다.
  9. UserDetailsService의 조회가 성공하면 AuthenticationProvider는 Authentication 객체를 AuthenticationManager에게 반환하며 인증이 되었음을 알린다. 실패하면 구현 클래스는 UsernameNotFoundException을 던지고 AbstractUserDetailsAuthenticationProvider에서 이 예외를 잡은 뒤 BadCredentialsException을 다시 던진다.
  10. AuthenticationManager는 인증이 성공했음을 확인하고 이 Authentication 객체를 AuthenticationFilter에게 전달한다. 만약 예외가 넘어왔다면 다시 위로 던진다.
  11. AuthenticationFilter는 Authentication 객체가 온 것을 확인하고 인증에 성공했음을 확인하고 흐름이 다음 필터로 넘어간다. 만약 null이 넘어왔다면 구현 클래스는 이를 인증이 실패한 것으로 간주하고 예외를 던진다. AbstractAuthenticationProcessingFilter는 이 예외를 잡아서 다음과 같은 과정을 수행한다.
    1. SecurityContext를 비운다.
    2. 예외를 세션에 저장한다.
    3. 실패 시 추가적인 행동을 AuthenticationFailureHandler에게 위임한다.

예외를 외부로 던지지 않고 세션에 저장하는 이유는 나중에 ExceptionTranslationFilter에서 인증 예외를 처리하기 위해서이다.

마치며

스프링 시큐리티는 이처럼 복잡한 과정을 통해서 개발자에게 보안 처리를 위한 방법을 제시해준다. 다만 기본적으로 제공해주는 기능만으로는 실제 서비스에 적용하기 힘들며 제대로 사용하기 위해서는 커스텀이 필수이다. 따라서 내부적인 로직을 이해하고 어떤 클래스가 어떤 역할을 담당하고 있는지 파악하고 있어야 수월해진다. 이 글이 스프링 시큐리티의 인증 처리 흐름을 이해하는데 도움이 되었으면 좋겠다.