Spring Security의 사용자 인증

하루히즘·2021년 7월 29일
0

Spring Framework

목록 보기
8/15
post-thumbnail

서론

이번에 SimpleBBS, SimpleTodoList 두 프로젝트에 모두 스프링 시큐리티를 적용하면서 MVC(정확히는 SSR이라고 하는듯)와 API 환경의 애플리케이션에서 스프링 시큐리티를 어떻게 적용할 수 있는지를 조금이나마 알게 되었다.

SimpleBBS에서는 폼 로그인, SimpleTodoList에서는 JWT를 이용하여 사용자를 인증(Authentication)할 수 있었는데 관련 스프링 시큐리티 설정의 의미와 인증이 어떻게 일어나는지 조금 알아보았다.

본론

사용자 정의

UserDetailsService

스프링 시큐리티가 보안 측면에서 많은 부분을 자동화해주지만 사용자를 인증할 때 필요한 사용자 정보들을 어떻게 가져올지는 사용자에게 맡기고 있다. 이는 "사용자 정보를 가져온다"라는 역할만 정의해두고 실제로 "어떻게 가져온다"는 구현체는 자율성에 맡기는 유연한 전략 패턴의 일종이라 할 수 있다.

아무튼 그렇기 때문에 스프링 시큐리티를 설정할 때는 우선 사용자 정보를 읽어올 수 있는 객체를 구현해서 등록해줘야 하며 이를 위해서 구현하게 되는 것이 이 UserDetailsService 인터페이스다.

이 인터페이스에는 loadUserByUsername이라는 단 하나의 메서드만 정의되어 있다. 이 메서드는 UserDetails라는 인터페이스의 구현체를 반환하게 되어 있는데 메서드의 이름에서도 알 수 있듯이 사용자 이름(username)으로 사용자(UserDetails의 구현체)를 조회하는 역할을 정의하고 있는 것이다. 이 메서드는 항상 사용자 정보를 반환하게 되어 있으며 사용자가 없다면 예외를 발생시킨다.

헷갈릴 수 있는 점은 이 인터페이스에서 요구하는 사용자의 이름(username)은 사용자를 구별하는 고유한 식별자라는 것이다. 대부분 웹 애플리케이션에서는 회원가입 시 고유한 사용자 아이디(ex: helloworld1234)와 중복될 수 있는 사용자 이름(ex: HELLO WORLD!)을 받지만 스프링 시큐리티에서 요구하는 속성은 후자가 아닌 전자, 즉 사용자 아이디다.

특히 사용자 아이디를 userId, 사용자 이름을 username 필드로 지정한 사용자 객체의 경우 Lombok 등으로 getter를 생성하면 getUsername 메서드가 생성되기 때문에 UserDetails 메서드의 getUsername 메서드와 중복되어 문제가 생길 수 있다. 이는 UserDetails에 대해서 더 알아보자.

스프링 시큐리티에서는 주로 WebSecurityConfigurerAdapter 클래스를 상속하는 설정 클래스에서 configure(AuthenticationManagerBuilder)메서드를 이용하여 해당 서비스를 등록할 수 있다.

UserDetails

UserDetailsService에서 조회하게 되는 사용자를 나타내는 클래스는 UserDetails 인터페이스를 구현해야 한다. 이 인터페이스는 사용자의 중요 정보(사용자 이름, 비밀번호, 권한 등)와 사용자의 상태(계정 잠금, 비밀번호 만료, 비활성화 등)를 조회할 수 있는 역할을 정의하고 있기 때문에 스프링 시큐리티에서 사용자를 조회하고 상태에 따라 접근을 차단하거나 권한에 따라 인가를 부여하는 등 여러 방면에서 사용된다.

User

스프링 시큐리티에서는 위의 UserDetails 인터페이스를 구현한 클래스인 User를 제공한다. 소스 코드를 보면 단순히 위의 UserDetails 인터페이스와 CredentialsContainer라는 인터페이스를 구현한 클래스인 것을 알 수 있다.

이 User 클래스도 어쨌든 UserDetails 인터페이스를 구현했기 때문에 이 클래스의 객체로 사용자를 나타내거나 상속받아서 기능을 구현할 수 있다. 즉 스프링 시큐리티에서 사용자의 편의를 위해 제공하는 미리 구현된 클래스인 것이다.

사용자 인증

위처럼 사용자 관련 인터페이스를 구현했다면 실제로 HTTP 요청이 들어왔을 때 누가 이 인터페이스들을 이용하여 사용자를 인증하는 것일까? JWT나 OAuth 등을 이용하지 않고 일반적인 스프링 시큐리티의 폼 로그인 기능을 활용하는 환경을 가정해 보겠다.

Authentication

먼저 스프링 시큐리티에서 사용자 인증을 위해 주고받는 매개체, 토큰은 Authentication이라는 인터페이스의 구현체로 나타낼 수 있다. 이 인터페이스는 인증 요청 자체를 나타내며 인증 서비스에 의해 정상적으로 인증된 경우 사용자 정보를 담는 역할을 정의하고 있다.

위의 UserDetails와 유사하게 인증된 사용자의 권한(Authorities), 비밀번호(Credentials), 세부 정보(Details), 인증 주체(Principal)를 조회하는 메서드를 정의하고 있는데 제일 중요한 것은 인증 여부를 확인할 수 있는 메서드인 isAuthenticated다. 이는 인증 서비스가 이미 인증된 토큰을 다시 인증하지 않도록 이 토큰이 인증됨, 신뢰할 수 있음을 나타내는 플래그라고 생각할 수 있다.

이후 스프링 시큐리티에서 정상적으로 인증이 끝나면 인증 메커니즘에 의해 자동으로 또는 수동으로 토큰이 스프링 시큐리티의 컨텍스트 영역에 저장하여 사용자의 인증 상태를 유지할 수 있다.

유의할 점은 isAuthenticated 메서드를 통해 인증 여부를 확인할 때 스프링 시큐리티 설정에서 anonymous, 즉 별도로 로그인하지 않은 사용자의 접근을 허가할 경우(기본적으로 허용) 실제로 아이디와 비밀번호를 이용하여 로그인하지 않았더라도 true, 즉 인증되었다고 나타난다.

그러므로 비로그인 사용자를 검증하려면 토큰이 AnonymousAuthenticationToken인지 instanceof로 확인하는 방법을 생각해 볼 수 있다.

AuthenticationManager

스프링 시큐리티에서 사용자 인증(Authentication)을 수행하는 가장 기본적인 인터페이스는 AuthenticationManager다. 이 인터페이스 역시 UserDetailsService처럼 단 하나의 메서드(authenticate)만 정의하고 있다.

이 메서드는 Authentication 토큰을 받아 사용자를 인증하고 사용자 정보, 권한 등이 포함된 완전한 토큰을 반환하거나 인증이 실패했다면 예외를 던지고 판단할 수 없는 경우 null을 반환하도록 정의하고 있다.

언급했듯이 UserDetails 인터페이스에는 사용자의 상태를 조회할 수 있는 메서드가 정의되어 있는데 이는 다음과 같다.

  • isAccountNonExpired: 계정 만료 상태
  • isAccountNonLocked: 계정 잠금 상태
  • isCredentialsNonExpired: 계정 비밀번호 만료 상태
  • isEnabled: 계정 활성화 상태

AuthenticationManager의 authenticate 메서드에서는 이 UserDetails를 활용하여 계정 비활성화(DisabledException), 계정 잠금(LockedException), 계정 비밀번호 만료(BadCredentialsException) 상태를 확인하고 각각 예외를 발생시킬 수 있어야 하며 인증이 실패하는 즉시 인증 과정을 중단해야 한다.

ProviderManager

위의 AuthenticationManager를 구현한 대표적인 구현체가 바로 ProviderManager다. 실제로 구현체를 주입받아 클래스 이름을 출력시켜보면 다음처럼 ProviderManager인 것을 확인할 수 있다.
이 클래스에서는 인증 토큰(Authentication)을 인증 서비스(AuthenticationProvider)에게 제공하여 실제로 인증 과정을 진행시키는(delegate) 책임을 수행하고 있다.

인증 서비스들이라고 표현한 이유는 실제로 소스 코드를 확인해보면 ProviderManager 클래스가 내부적으로 AuthenticationProvider의 리스트를 유지하고 있기 때문이다. ProviderManager는 authenticate 메서드를 호출할 때 이 인증 서비스들을 이용하여 토큰을 인증할 수 있으며 인증 서비스에서 null이 아닌 토큰을 반환하면 중단하고 반환한다.

언급했듯이 AuthenticationManager는 토큰을 인증하지 못하는 경우(인증 성공, 실패를 확인할 수 없는 경우) null을 반환하도록 정의되어 있다고 하였다. 이는 ProviderManager에서 현재 인증 서비스로 인증할 수 없으니 null이 아닐 때까지 다른 인증 서비스로 인증을 시도해 보라는 일종의 플래그 역할을 한다고 할 수 있다.

눈여겨 볼 점은 만약 한 인증 서비스에서 인증이 실패해서 예외(AuthenticationException)가 발생했다고 하더라도 다른 인증 서비스에서 정상적으로 인증됐다면 이전에 발생했던 예외들은 무시되고 정상적으로 인증된 토큰이 반환된다는 것이다. 그러나 예외가 발생해서 다른 인증 서비스로 인증하는데 다들 null을 반환한다면 가장 최근에 발생한 예외를 발생시키고 모든 인증 서비스가 토큰을 인증할 수 없다면(모두 null을 반환했다면) 부모 클래스의 AuthenticationManager로 시도(fallback)한다.

그럼에도 null을 반환한다면 이는 스프링 시큐리티에서 해당 토큰을 처리하기 위한 적절한 AuthenticationProvider가 주어지지 않은 것으로 판단하고 ProviderNotFoundException을 발생시킨다. 이 과정은 소스 코드의 이 부분을 보면 이해가 쉬울 것이다.

AuthenticationProvider

그럼 이 ProviderManager가 유지하고 있는 AuthenticationProvider 인터페이스는 무슨 역할을 정의하고 있을까? 이는 AuthenticationManager와 유사하게 토큰을 인증하는 authenticate 메서드 외에도 어떤 인증 토큰을 처리할 수 있는지를 나타내는 supports 메서드를 정의하고 있다.

이 메서드를 기반으로 이 AuthenticationProvider가 인증 토큰(Authentication)을 처리할 수 있는지 확인할 수 있는데 이는 AuthenticationManager가 어떤 인증 토큰이든 상관없이 처리한다는 역할을 정의했다면 실제로 인증 토큰을 처리하는 책임을 위임받는 AuthenticationProvider에서는 자신이 처리할 수 있는 인증 토큰을 이 supports 메서드로 명시하여 ProviderManager가 올바른 인증 서비스로 토큰을 인증하도록 지원하는 것이다.

위의 ProviderManager 문단에서 언급했듯이 AuthenticationProvider는 인증할 수 없으면 null을 반환하고 null이 아닐 때까지 계속 인증하게 된다. 그러면 ProviderManager에는 어떤 AuthenticationProvider가 등록되어 있을까?

DaoAuthenticationProvider

스프링 시큐리티 설정에서 UserDetailsService를 등록했다면 다음처럼 DaoAuthenticationProvider가 등록된 것을 볼 수 있다.

현재 애플리케이션에서는 UserDetailsService 외에 다른 방법으로 인증을 구현하고 있지 않기 때문에 이를 제외한 별다른 AuthenticationProvider는 등록되어 있지 않다.

이 DaoAuthenticationProvider는 위에서 언급한 UserDetailsService를 이용하여 토큰을 인증하는 클래스로 AuthenticationProvider 인터페이스를 구현한 AbstractUserDetailsAuthenticationProvider 클래스를 상속받는다. 부모 클래스가 추상 클래스기 때문에 직접 사용할 수 없으며 대신 이를 상속받은 DaoAuthenticationProvider를 사용하는 것이다.

대신 부모 클래스에서는 인증 토큰이 UsernamePasswordAuthenticationToken인지 확인한다. 이 토큰이어야 사용자 이름과 비밀번호를 기반으로 UserDetailsService에서 사용자를 인증할 수 있기 때문에 AuthenticatonProvider 인터페이스의 supports 메서드로 현재 인증 서비스가 인증할 수 있는 인증 토큰을 1차적으로 검증하는 것이다.

실제로 사용자 인증을 처리하는 자식 클래스인 DaoAuthenticationProvider에서는 사용자 이름과 비밀번호가 담긴 토큰을 내부적으로 UserDetailsService를 사용하여 UserDetails 구현체로 얻어오게 된다. 소스 코드를 보면 위에서 언급했던 UserDetailsService의 loadUserByUsername가 여기서 실제로 호출되는 것을 볼 수 있다.

인증 서비스 설정

그런데 이런 서비스적인 측면 말고 실제로 HTTP 요청이 들어왔을 때 이런 인증 과정을 수행하도록 설정하려면 어떻게 할 수 있을까? 스프링 시큐리티에서는 폼 로그인, 즉 흔히 볼 수 있는 아이디와 비밀번호를 입력해서 로그인하는 방식을 설정할 경우 자동으로 관련 설정이 진행된다.

FormLoginConfigurer

스프링 시큐리티를 설정할 때 configure(HttpSecurity) 메서드에서는 다음처럼 HttpSecurity의 formLogin 메서드를 이용하여 폼 로그인 기능을 활성화할 수 있다.

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .authorizeRequests()
        ...
        .and()
        .formLogin() // configure login form
        .loginPage("/account/login")
        .loginProcessingUrl("/account/login")
        .permitAll()
        ...
        .and()
        .logout() // configure logout form
        .logoutUrl("/account/logout")
        .permitAll()
        .logoutSuccessUrl("/board/list")
        .and()
        ...

formLogin 메서드로 얻을 수 있는 FormLoginConfigurer 객체는 애플리케이션의 로그인, 로그아웃 URL이나 파라미터 이름 등 폼 로그인과 관련된 다양한 속성을 설정할 수 있다. 중요한 것은 이 FormLoginConfigurer의 생성자에서 UsernamePasswordAuthenticationFilter라는 필터를 생성하여 필터 체인에 등록한다는 것이다.

스프링 시큐리티는 이후 이 로그인 URL에서 일어나는 로그인, 로그아웃 요청을 대신 받아서 위의 AuthenticationManager 등으로 인증하거나 로그아웃 시 세션을 파기하고 인증 정보를 제거하는 등 다양한 로직을 자체적으로 제공하기 때문에 사용자는 로그인, 로그아웃 로직을 별도로 작성할 필요가 없다.

UsernamePasswordAuthenticationFilter

formLogin 메서드로 필터 체인에 등록되는 UsernamePasswordAuthenticationFilter는 로그인 폼에서 전송된 요청을 기반으로 UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager에 전달한다. 스프링 시큐리티의 폼 로그인은 기본적으로 지정된 URL에 전송된 POST 요청의 username, password 필드에서 사용자 아이디, 비밀번호를 식별한다.

생성된 토큰은 위에서 언급했듯이 AuthenticationManager, 즉 ProviderManager가 내부적으로 유지하는 AuthenticationProvider, 현재 환경에서는 DaoAuthenticationProvider를 이용하여 검증하고 그 결과를 기반으로 시큐리티 컨텍스트에 사용자 인증 정보가 저장되는 것이다.

그런데 소스 코드를 살펴보면 파라미터에서 아이디와 비밀번호를 가져와서 인증한 후 토큰을 반환하는 코드밖에 작성되어 있지 않다. 그럼 나머지 기능은 어디서 구현되는 걸까? 이는 현재 필터가 상속받고 있는 AbstractAuthenticationProcessingFilter라는 추상 클래스를 살펴볼 필요가 있다.

AbstractAuthenticationProcessingFilter

추상 클래스 필터는 사용자의 브라우저에서 전송된 HTTP 요청에 인증 서비스를 제공한다. 실제로 토큰을 인증하는 메서드는 추상 메서드인 attemptAuthentication으로 정의되어 있으며 이를 구현한 자식 클래스(UsernamePasswordAuthenticationFilter 등)에서 인증 서비스를 수행하게 된다.

필터에서는 인증이 성공하면 스프링 시큐리티 컨텍스트에 토큰(Authentication 객체)을 저장하고 인증이 실패한다면 스프링 시큐리티 컨텍스트를 초기화한다. 그리고 각각 미리 등록된 핸들러를 통해 추가적인 작업(가장 단순하게는 로그인 후 메인 페이지로 이동)을 수행할 수 있다.

...
    .formLogin() // configure login form
        .loginPage("/account/login")
        .loginProcessingUrl("/account/login")
        .permitAll()
        .defaultSuccessUrl("/account/manage")
        .failureHandler((request, response, exception) -> {
            if(exception instanceof LockedException)
                response.sendRedirect("/account/login?locked");
            else
                response.sendRedirect("/account/login?failed");
        })
        .and()
...

시큐리티 설정에서는 위처럼 failureHandler나 successHandler 메서드를 통해 핸들러를 설정할 수 있다. 물론 따로 등록하지 않아도 스프링 시큐리티에서 기본적인 핸들러를 등록해둔다.

즉 핵심 기능인 인증하는 방법만 자식 클래스에서 정의하도록 추상 메서드로 남겨두고 공통 기능인 인증 성공 전, 후에 필요한 설정을 추상 클래스에 정의하여 필요한 부분만 구현하고 공통 기능을 재사용 한다는 점에서 인상깊은 클래스 구조다.

Stateless 환경의 사용자 인증

만약 스프링 시큐리티의 formLogin 메서드를 활용하지 않는 경우, 예를 들어 JWT를 활용하는 RESTful API 애플리케이션 서버에서 로그인 컨트롤러를 구현하여 진행한다면 이런 인증 과정은 어떻게 진행할 수 있을까?

이때는 스프링 시큐리티 설정에서 AuthenticationManager 객체를 스프링 컨테이너에 등록한 후 인증 서비스 클래스에서 주입받아 직접 인증하는 방법을 생각해 볼 수 있다. 전송된 HTTP 요청을 기반으로 직접 토큰(UsernamePasswordAuthenticationToken)을 생성하여 authenticate 메서드에 전달하는 식으로 위의 필터에서 수행됐던 코드를 직접 작성하는 것이다.

그 후 인증된 객체를 기반으로 JWT를 생성하거나 추가적인 작업을 진행할 수 있을 것이다.

결론

폼 로그인, 로그아웃 과정에 대해서 알아본 내용이지만 소스 코드를 들여다보는 계기가 되어서 흥미로웠다. 방대한 프레임워크의 모든 부분을 이해하는 건 힘들겠지만 최소한 어떤 방식으로 진행되는지는 인지하고 있는 것이 좋을 것 같다.

참고

Spring Security Architecture
when loadUserByUsername is invoked? (spring security)

profile
YUKI.N > READY?

0개의 댓글