Skill/Spring Framework

[Spring Security] Authentication Flow - 인증 프로세스 (UsernamePassword Form-Login)

sjiwon 2023. 9. 29. 22:18
본 블로그의 모든 Spring Security 포스팅은 Spring Boot 3 이상 버전을 기준으로 작성됩니다

 
이 포스팅은 기본적인 Form-Login Based 인증 프로세스를 디버깅을 통해서 알아보는 포스팅으로써 기본적인 Security 설정은  SpringBootWebSecurityConfiguration의 자동 설정을 그대로 따른다
 

0. Security 기본 제공 계정

  • 이처럼 Spring Security에서는 기본적인 인증 계정을 제공해주고 현재 글의 목적인 인증 프로세스간 디버깅간에도 해당 계정을 사용할 예정

 

  • 현재 Spring Security의 기본 Config를 따르고 있기 때문에 모든 Request에 대해서는 authenticated → 즉, 인증이 필요하다
  • 따라서 DefaultLoginPageGeneratingFilter에 의해 만들어진 기본 로그인 페이지로 이동하게 되는 것이다

 

1. AbstractAuthenticationProcessingFilter - doFilter

먼저 requiresAuthentication을 통해서 현재 요청이 인증 프로세스가 필요한 요청인지를 파악한다

  • 쉽게 말해서 로그인을 위한 요청인지 확인한다는 것이다

  • 현재 요청은 POST /login이기 때문에 로그인 요청이 맞고 그에 따라서 attemptAuthentication을 통해서 인증을 시도하게 된다

 

2. UsernamePasswordAuthenticationFilter - attemptAuthentication

  1. Form-Login Based이므로 HttpServletRequest의 getParameter를 통해서 서버로 전달된 Username & Password를 추출한다
  2. 추출한 Username & Password를 통해서 UsernamePasswordAuthenticationToken을 생성한다
  3. 그리고 HttpServletRequest의 메타정보 개념인 AuthenticationDetailsSource를 설정한다

 

UsernamePasswordAuthenticationToken

UsernamePasswordAuthenticationToken에는 2가지 타입의 생성자가 존재한다

  • 위 → 인증이 되지 않은 Unauthenticated Token
  • 아래 → 인증이 완료된 Authenticated Token

따라서 위에서 보이는 UsernamePasswordAuthenticationFilter에서의 UsernamePasswordAuthenticationToken은 아직 실질적인 인증 프로세스로 들어가기 전에 생성된 Token이므로 Unauthenticated Token이다
 

AuthenticationDetailsSource

AuthenticationDetailsSource는 인증 요청으로 들어온 정보인 HttpServletRequest로부터 부가적으로 필요한 메타정보를 별도로 추출해서 캐싱하는 개념이다

  • 기본으로 적용되는 WebAuthenticationDetails에는 IP 주소 + 세션 ID(Optional)을 별도로 추출해서 캐싱한다

 

AuthenticationManager에게 인증 위임

  1. 요청으로부터 Username & Password 추출
  2. Unauthenticated Token 생성
  3. HttpServletRequest로부터 메타정보 캐싱

의 과정을 거친 후 AuthenticationManager에게 Unauthenticated Token을 넘김으로써 인증을 위임하게 된다

따라서 Spring Security에 대한 Form-Login Based 인증 프로세스를 말할 때 "UsernamePasswordAuthenticationFilter가 인증을 실질적으로 처리해요"라는 문맥은 100% 맞지는 않은 설명이다

 

3. AuthenticationManager(ProviderManager) - authenticate

그러면 과연 AuthenticationManager에서 모든 인증을 처리하는 로직을 가지고 있을까?

  • 그것또한 아니다
  • 결론적으로 말하면 AuthenticationManager는 현재 자신이 받은 Token을 처리할 수 있는 AuthenticationProvider를 찾아서 해당 AuthenticationProvider에게 인증 로직 처리를 맡기게 된다

이처럼 For Loop를 통해서 넘어온 Unauthenticated Token에 대해서 처리할 수 있는 AuthenticationProvider를 찾은 후 해당 AuthenticationProvider의 authenticate를 통해서 실제 인증 처리를 진행하게 된다

  • 위에서 선택된 AuthenticationProvider는 DaoAuthenticationProvider

 

4. AbstractUserDetailsAuthenticationProvider - authenticate

  1. UserCache를 통해서 Username Based로 캐싱된 User가 존재하는지 확인
  2. Cache-Miss면 실제로 저장소에서 username에 해당하는 User 가져오기
일반적인 Web Application에서는 DB에서 사용자 정보를 관리한다
Spring Security의 인증 프로세스에서 UserCache를 커스터마이징해서 캐싱해놓는다면 인증 프로세스마다 DB I/O를 거칠 필요가 없어지고 성능을 향상시킬 수 있다

 

retrieveUser (DaoAuthenticationProvider)

UserCache에 없는 경우 실제 저장소에서 username에 해당하는 User를 가져와야 한다

  • 저장소에서 User를 찾아오는 과정에서 UserDetailsService를 활용하게 된다

 

UserDetailsService

  • 저장소에서 username에 해당하는 User를 가져오기 위한 Interface
  • 저장소의 형태에 따라 다양하게 커스터마이징할 수 있다
    • In-Memory
    • RDB
    • Local Map
    • ...

 
현재 디버깅 프로세스간에 UserDetailsService는 기본적으로 제공되는 InMemoryUserDetailsService를 활용하고 있고 만약 RDB로 사용자를 관리하고 있는 경우 다음과 같이 커스텀하게 UserDetailsService를 정의할 수 있다

@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(final String username) throws UsernameNotFoundException {
        // 1. username을 통해서 User 찾기 (username -> email, nickname, ...)
        final User user = userRepository.findByEmail(username);
        
        // 2. User -> UserDetails로 변환
        return new CustomUserPrincipal(~~);
    }
}

 

UserDetails with JPA Entity

 

[Spring Security] Spring Security + JWT

관련된 코드는 깃허브에서 확인하실 수 있습니다 버전 업데이트로 인해 본 포스팅과 깃허브 코드가 일치하지 않을 수 있습니다 깃허브 코드 부트 버전 = 2.7.x 세션 vs Token Client - Server간에 통신을

sjiwon-dev.tistory.com

해당 포스팅에서도 간략하게 설명했듯이 JPA Entity에 UserDetails를 implements하는것은 좋지 않은 선택이라고 생각한다

 

추가적인 Authentication

UserCache 또는 UserDetailsService를 통해서 username에 해당하는 User를 가져온 후 Application 정책에 따른 추가적인 인증을 진행할 수 있다

  • 비밀번호가 맞는지 확인
  • Secret Key가 적절하게 들어왔는지 확인
  • ...

 

DaoAuthenticationProvider에서는 비밀번호가 맞는지 확인하는 추가적인 인증 절차를 거치고 있다

  • 이 또한 AuthenticationProvider를 커스텀하게 구현해서 추가적인 커스텀 인증을 유도할 수 있다
  • AuthenticationProvider 역시 Interface이기 때문에

 

추가적인 인증 프로세스까지 완료되었다면

  • Cache를 사용하면 UserCache에 Retrieve한 User를 캐싱
  • SuccessAuthentication 생성

드디어 인증처리된 AuthenticatedToken을 생성한다
 
 

5. AbstractAuthenticationProcessingFilter - 인증 처리 후 성공/실패 Handling

위와 같은 일련의 과정을 거친 후 인증 처리에 대한 성공/실패 Handling이 진행된다

 

인증 성공 Handling → successfulAuthentication

  1. 인증처리된 AuthenticationToken을 SecurityContext로 감싸고 SecurityContextHolder에 보관
    • Session Based
    • Null (STATELESS)
  2. RememberMeService가 적용되어있다면 RememberMe 처리
  3. 인증 성공 관련 Event 발행
  4. call AuthenticationSuccessHandler

 

인증 실패 Handling → unsuccessfulAuthentication

  1. SecurityContextHolder 비우기
  2. RememberMeService가 적용되어있다면 RememberMe AutoLogin 제거
  3. call AuthenticationFailureHandler

 

Security FormLogin 관련 설정

FormLogin 관련 설정은 AbstractAuthenticationFilterConfigurer & FormLoginConfigurer의 여러 API들을 통해서 간편하게 설정할 수 있다
 

@Bean
public SecurityFilterChain securityFilterChain(final HttpSecurity http) throws Exception {
    http.formLogin(login ->
            login
                    /**
                     * 로그인 페이지 경로
                     * -> default = /login
                     */
                    .loginPage("/login")

                    /**
                     * usernameParameter -> 인증 Form에서 아이디 <input name>에 해당하는 파라미터
                     * -> default = username
                     * passwordParameter -> 인증 Form에서 비밀번호 <input name>에 해당하는 파라미터
                     * -> default = password
                     */
                    .usernameParameter("username")
                    .passwordParameter("password")

                    /**
                     * 서버에서 로그인 처리를 진행하는 Endpoint
                     * -> default = POST /login
                     */
                    .loginProcessingUrl("/login")

                    /**
                     * defaultSuccessUrl -> 인증 성공 후 Redirect 시킬 Url
                     * failureUrl -> 인증 실패 시 사용자에게 전달할 Url
                     */
                    .defaultSuccessUrl("/")
                    .failureUrl("/error")

                    /**
                     * successForwardUrl -> 인증 성공 후 Forward 시킬 Url
                     * failureForwardUrl -> 인증 실패 시 Forward 시킬 Url
                     */
                    .successForwardUrl("/")
                    .failureForwardUrl("/error")

                    /**
                     * successHandler -> 인증 성공 후 call되는 Handler
                     * failureHandler -> 인증 실패 후 call되는 Handler
                     */
                    .successHandler(customAuthenticationSuccessHandler)
                    .failureHandler(customAuthenticationFailureHandler)

                    /**
                     * 인증 요청에 대한 메타 정보를 캐싱할 컴포넌트
                     */
                    .authenticationDetailsSource(customAuthenticationDetailsSource)
    );

    return http.build();
}