Spring Security에서 기본으로 제공하는 DaoAuthenticationProvider 이외에 맞춤 AuthenticationProvider를 어떻게 정의할지에 대해 생각해보자.
현실에서 다양한 상황에서는 기본 AuthenticationProvider로는 충분하지 않을 때가 있다.
사용자 인증에 특별한 로직이 필요하거나, 클라이언트가 다양한 인증 방식을 사용하길 원할 때 맞춤 AuthenticationProvider를 정의하는 것이 필요하다.
현재 기본 DaoAuthenticationProvider를 사용하고 있다.
이는 데이터베이스에서 사용자 정보를 가져오고, 암호를 구성한 뒤, 비밀번호를 비교하며, 계정의 만료 여부나 신임장 만료 여부를 확인하는 등 다양한 기능을 제공한다.
기본 AuthenticationProvider의 기능이 충분한 경우, 개발자는 주로 데이터베이스와 사용자 정보에 집중할 뿐이다.
그러나 현실에서는 맞춤 요구사항이 발생할 수 있다.
예를 들어, 18세 이상의 사용자만 허용하거나, 특정 국가에서 접속하는 사용자만을 허용하는 등 다양한 요구사항이 생길 수 있다.
이런 경우에는 개발자가 직접 맞춤 AuthenticationProvider를 작성하여 맞춤 로직을 구현해야 한다.
어떤 클라이언트는 다양한 접근 방식을 허용하길 원할 수 있다.
사용자 이름과 비밀번호로 인증하는 방식 외에도 OAuth 2.0이나 OTP(일회용 비밀번호)를 통한 인증을 허용하고 싶을 수 있다.
이런 경우, 하나의 애플리케이션에서 여러 개의 AuthenticationProvider를 사용할 수 있다.
Spring Security의 핵심 컴포넌트 중 하나인 AuthenticationProvider에 대해 자세히 알아보자.
AuthenticationProvider는 사용자의 인증을 담당하는 인터페이스로, 실제 인증 로직을 구현해야 한다.
AuthenticationProvider에는 두 가지 중요한 메소드가 있다.
authenticate 메소드는 사용자의 인증을 시도하고, 성공 또는 실패 여부를 나타내는 Authentication 객체를 반환한다.
이 메소드 내에서 실제 인증 로직을 수행한다.
성공 시 적절한 Authentication 객체를 반환하고, 실패 시 예외를 던지거나 null을 반환한다.
supports 메소드는 특정 타입의 Authentication 객체를 지원하는지 확인하는 역할을 한다.
이 메소드를 통해 해당 AuthenticationProvider가 다룰 수 있는 타입의 Authentication을 명시한다.
ProviderManager는 authenticate() 메소드 내의 Spring Security 내부에 사용 가능한 AuthenticationProviders를 모두 시도해본다.
특정 상황에서 사용 가능한 10~20개의 AuthenticationProvider가 Spring Security 안에 있어도 추가적으로 맞춤(Custom) AuthenticationProvider를 작성해도 무방하다.
AuthenticationProvider 구현과 authenticate() 메소드를 오버라이드하여 작성한 논리를 호출했다면 불필요한 상황이 된다.
주어진 AuthenticationProvider가 주어진 인증 객체를 지원하는지 확인하는 일인 이유이다.
보다 구체적인 예시로 UsernamePasswordAuthenticationToken을 들어보자.
이 토큰은 대부분의 경우에 사용되는 토큰으로, 사용자 이름과 비밀번호를 기반으로 하는 인증에 활용된다.
보통의 엔드 유저들은 username과 password로 인증하고 싶기 때문에 supports 클래스에 들어있는 AuthenticationToken을 항상 고려해야 한다.
왜냐하면 인증 도중 제작할 OTP를 구현하고 싶다고 해도 우선적으로 username과 password로 사용자를 올바르게 인증해야 하기 때문이다.
그게 성공적으로 이루어졌다면 사용자를 OTP 페이지로 이동시켜야 한다.
OTP는 요구 사항에 기반하여 정의할 수 있는 개별 비즈니스 로직이다.
따라서 OTP 인증 진행 시에는 Spring Seucirty를 개입시킬 일은 없다.
=> 그러므로 username과 password의 도움을 받는 인증 초기 단계에서 Spring Security를 가볍게 연관시킬 수 있다.
이와 유사하게 TestingAuthenticationToken도 있다.
이 토큰은 많은 보안을 할애하고 싶지 않은 unit Test에서 사용하고 싶을 때 언제든 사용 가능
이 토큰은 TestingAuthenticationProvider라는 AuthenticationProviders를 구현하여 사용하고 있다.
TestingAuthenticationProvider은 authenticate() 메소드에 인증 로직이 없다.
이 AuthenticationProviders는 unit testing이나 자동화 스크립트를 시험해 운행하고 있을 때 사용하는 것이 목적이므로 단순히 받는 인증 객체를 출력한다.
불필요하게 보안을 할애하기 싫기 때문에 다음과 같은 AuthenticationProviders를 그 상황에서 사용한다.
이 AuthenticationProviders에 대해 supports() 메소드를 바로 오버라이드 하여 사용하고 있다. 인증객체를 TestoingAuthenticationToken으로 가지고 잇는 인증 방식을 지원할 것이다.
기본 AuthenticationProvider를 확장하여 사용자 정의 AuthenticationProvider를 구현해보자.
config 패키지 내에 EasyBankUsernamePasswordAuthenticationProvider 클래스를 생성한다.
AuthenticationProvider 인터페이스를 구현하고, 필요한 메소드들을 오버라이드한다.
package com.eazybytes.springsecsection2.config;
import com.eazybytes.springsecsection2.model.Customer;
import com.eazybytes.springsecsection2.repository.CustomerRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
@RequiredArgsConstructor
public class EazyBankUsernamePwdAuthenticationProvider implements AuthenticationProvider {
private final CustomerRepository customerRepository;
private final PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
//1. 데이터 베이스에서부터 UserDetails 사항을 불러와야 함
//2. 비밀번호 비교
//username 불러오기 (여기선 email)
String username = authentication.getName();
//password 불러오기
String pwd = authentication.getCredentials().toString();
//db에서 username(->email) 문자열을 가진 Customer있다면 customer로 반환
List<Customer> customer = customerRepository.findByEmail(username);
//customer가 존재한다면
if (customer.size() > 0) {
//비밀번호가 일치하다면
if (passwordEncoder.matches(pwd, customer.get(0).getPwd())) {
//엔드 유저의 authorities 세부 사항을 덧붙임
List<GrantedAuthority> authorities = new ArrayList<>();
//customer 테이블 내의 role 컬럼에 존재, Role 문자열 값을 SimpleGrantedAuthority 클래스로 변환
authorities.add(new SimpleGrantedAuthority(customer.get(0).getRole()));
//UsernamePasswordAuthenticationToken 대상을 새롭게 생성
return new UsernamePasswordAuthenticationToken(username, pwd, authorities);
}
//비밀번호가 일치하지 않다면
else {
throw new BadCredentialsException("Invalid password!");
}
}
//customer가 존재하지 않다면
else {
throw new BadCredentialsException("No user registered with this details!");
}
}
@Override
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
}
<UsernamePasswordAuthenticationToken 생성자>
->매개변수로 username, credentials, authories 받음
->동시에 authentication이 성공적이라고 명시되어 있음
->이는 ProviderManager에게 authentication이 성공적으로 이루어졌음을 알림
EasyBankUsernamePasswordAuthenticationProvider 클래스에서 사용한 CustomUserDetailsService와 PasswordEncoder는 Bean으로 등록되어 있어야 한다.
Spring Security에서 제공하는 기본 AuthenticationProvider를 확장하여 만든 사용자 정의 AuthenticationProvider를 실행해보자.
또한, 기존의 UserDetailsService를 사용하지 않고 독자적인 인증 로직을 구현함으로써 더 많은 유연성을 얻을 수 있다.
기존 프로젝트에서 사용자 정의 AuthenticationProvider를 구현하였으므로, 기존의 CustomUserDetailsService 클래스를 삭제한다.
이전에 작성했던 유저 세부 정보 로딩에 관한 클래스를 삭제함으로써 DaoAuthenticationProvider에 영향을 주지 않고 우리만의 Authentication 로직을 유지할 수 있다
로그인 기능을 테스트한다.
유저 이름과 비밀번호를 입력하여 로그인이 정상적으로 이루어지
사용자 정의 AuthenticationProvider가 적용되어 정상적으로 동작하는지 확인한다.
디버깅을 통해 ProviderManager가 AuthenticationProvider 내부에서 어떻게 작동하는지 이해한다.
각 메소드에서 중단점을 설정하여, 유저 세부 정보 로딩, 비밀번호 확인 등의 과정을 디버깅하여 확인한다.
Spring Security에서 사용자 정의 AuthenticationProvider를 실행할 때의 시퀀스 흐름을 살펴보자.
Spring Security의 다양한 컴포넌트들이 각자의 역할을 수행하며, 우리가 직접 구현한 EasyBankUsernamePasswordAuthenticationProvider를 실행하는 과정을 살펴보기로 하자.
Spring Security의 여러 컴포넌트들이 각자의 역할을 수행하는 시퀀스 흐름은 다음과 같다.
Spring Security의 Filters는 다양한 보안 작업을 처리한다.
하지만, 우리는 이 Filters에 간섭하지 않는다.
왜냐하면 이들은 Spring Security의 핵심 컴포넌트이기 때문이다.
AuthenticationManager는 다양한 AuthenticationProvider를 관리하고, 실제 인증을 수행한다.
여기서 우리는 AuthenticationManager이기 때문에 자체적으로 AuthenticationProvider을 실행하게 되었다.
이는 EasyBankUsernamePasswordAuthenticationProvider 이다.
따라서 ProviderManager는 이 authenticate() 메소드를 AuthenticationProvider 내로 주입시키려고 한다.
AuthenticationProvider 내에서 사용자의 세부 정보를 불러오고, PasswordEncoder를 사용하여 비밀번호 일치 여부를 확인한다.
비즈니스 로직이 완벽하게 처리되었다면, AuthenticationProvider는 Authentication 객체를 생성하여 ProviderManager에게 되돌려 준다.
이번 시퀀스 흐름에서 주목해야 할 점은 다음과 같다.
유저 세부 정보 검색 로직: 이전 시퀀스 흐름(순서 흐름)과 비교했을 때 기존에는 UserDetailsService나 UserDetailsManager의 구현 클래스 등이 유저 세부 정보를 검색하는데 사용되었지만, 이번에는 유저 상세 정보 검색 로직도 authenticate() 메소드 내에서 직접 구현하였다.
다른 컴포넌트들에 미치는 영향 최소화: Filters, AuthenticationManager 등의 Spring Security 핵심 컴포넌트들에는 간섭하지 않는다.
우리의 AuthenticationProvider만 수정하고 사용한다.
이 시퀀스 흐름을 더 간소화한 버전도 존재한다.
단순해 보이는 이유는 UserDetailsService나 UserDetailsManager에 더 이상 영향을 미치지 않기 때문이다.