지금까지 인증과 관련해서 커스텀 설정을 하는 방법을 시작해서 세부적으로 Spring Security에서 어떻게 인증 정보를 다루는지(`Authentication` 인터페이스), 이 인증 정보는 어디에 저장(`SecurityContext`)하는지, 인증 객체 저장소는 어떻게 초기화 되는지 등의 기본 아키텍처들을 모두 배웠다.
여기서는 Spring Security에서 어떻게 인증을 수행해서 `Authentication` 객체를 만들어 내는지에 대해 구조와 함께 알아보고, 전체적으로 인증 과정들을 정리해보고자 한다.
인증 관리 - AuthenticationManager
`UsernamePasswordAuthenticationFilter` 클래스의 `attemptAuthentication()` 메서드 부터 살펴보자. (이 메서드가 호출되는 시점은 이전 게시글에서 수 많이 다뤘다)
package org.springframework.security.web.authentication;
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
...
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username,
password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
...
}
요청으로부터 `username`과 `password`를 추출한 후, `UsernamePasswordAuthenticationToken`을 생성한다. 이 객체는 `Authentication`의 구현체로, 인증되기 전에는 `username`과 `password` 만을 가지는 인증 객체가 되겠다. (이전 게시글에서 다뤘다)
그리고 `this`.`getAuthenticationManager()`.`authenticate()`을 호출함으로써 실질적인 인증이 진행되게 된다.
`getAuthenticationManager()`는 부모인 `AbstractAuthenticationProcessingFilter`에 구현되어 있는 메서드로, `AuthenticationManager` 객체를 반환해준다.
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}
`AuthenticationManager` 인터페이스는 간단하다. 인증을 수행하는 `authenticate()` 메서드만 정의되어 있다. 이를 구현하는 클래스는 `ProviderManager`가 있는데 실제 `getAuthenticationManager()`를 호출하면 이 클래스의 인스턴스가 반환된다. 한번 살펴보자.
ProviderManager
package org.springframework.security.authentication;
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
...
private List<AuthenticationProvider> providers = Collections.emptyList();
...
private AuthenticationManager parent;
...
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
...
Authentication result = null;
Authentication parentResult = null;
int currentPosition = 0;
int size = this.providers.size();
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
...
}
if (result == null && this.parent != null) {
// Allow the parent to try.
try {
parentResult = this.parent.authenticate(authentication);
result = parentResult;
}
...
}
if (result != null) {
...
if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}
...
}
...
}
중요하게 볼 것은 `providers`와 `parent`이다. 우선 `ProviderManager` 클래스의 역할은 `AuthenticationProvider`를 관리하는 역할이다. 관리한다는 것은 `AuthenticationProvider` 객체 리스트들을 멤버로 가지며, 실제 인증 처리가 가능한 `AuthenticationProvider`를 찾아 호출해주는 역할을 한다.
보면 `providers`를 순회하며 인자로 넘어온 `Authentication` 객체를 각 요소들이 지원하는 지(처리할 수 있는 지) 확인 한다. 그리고 지원한다면 해당 요소의 `authenticate()`를 를 호출하여 실제 인증 과정을 수행하게 된다.
따라서 인증에 성공했다면 `result` 지역 변수에는 인증 로직을 수행한 `Authentication` 객체가 초기화 되어 있게 되는데, 만약 인증 과정 중 문제가 발생했거나(예외가 발생할 확률이 크지만), 지원하는 `AuthenticationProvider` 객체가 요소에 없는 경우에는 null이 되어버린다.
null일 경우에는 `parent`.`authenticate()`를 통해서 다른 `AuthenticationManager`의` authenticate()`를 호출해 버린다. 따라서 또 동일하게 `providers`를 순회하며 인증 과정을 처리할 수 있는 `AuthenticationProvider`를 찾아서 호출해주게 된다.
결국 이렇게 반복하여 결국 메서드가 리턴이 되는 경우는 다음 2가지다.
첫 번째로 `parent`가 null이고, result가 null일 경우에는 인증 로직을 타지 않고 null이 반환되겠지만, 이는 다른 필터에서 null일 경우 예외를 처리할 것이다.
두 번째로 적절한 `AuthenticationProvider`를 찾아서 인증 과정을 끝 마쳤다면 `Authentication` 객체를 반환하는 경우에는 호출부인 `UsernamePasswordAuthenticationFilter`.`attemptAuthentication()`에서 이후 로직을 수행하게 된다.
여기까지 `AuthenticationProvider`의 역할을 알아보았고, 이제 요청을 위임받아 실제 인증 과정을 수행하는 역할을 가진 `AuthenticationProvider`에 대해서 알아보자.
인증 수행 - AuthenticationProvider
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
boolean supports(Class<?> authentication);
}
`AuthenticationProvider` 인터페이스에는 두 개의 메서드가 정의되어 있다. 실제 인증을 수행하여 `Authentication` 객체를 반환하는 `authenticate()` 메서드와, 각 구현체가 처리할 수 있는 `Authentication` 객체를 확인하는 `supports()` 메서드가 있다.
Form Login 활성화 시에는 `providers`에 `AnonymousAuthenticationProvider`가 요소로 들어있는 리스트와, `parent`에는 `DaoAuthenticationProvider`가 `providers`의 요소로 초기화 되어 있는 `ProviderManager` 객체가 초기화 된다.
따라서 익명 사용자인 경우에는 `AnonymousAuthenticationToken`을 통해 인증을 수행하기 때문에 초기화된 `ProviderManager`의 `authenticate()`에서 `AnonymousAuthenticationProvider` 객체의 `supports()`를 호출할 때 true가 나오기 때문에 이 객체의 `authenticate()` 로직을 타게 된다.
만약 사용자가 로그인 과정 중이라면 `UsernamePasswordAuthenticationToken`을 통해 인증을 수행한다. 하지만 초기화 된 `ProviderManager`의 `providers` 요소 중에는 이 `Authentication` 객체를 지원하는 `AuthenticateProvider`가 없기에, `parent`의 `authenticate()`를 호출하게 되고 결국 `DaoAuthenticationProvider` 객체의 `supports()`를 호출할 때 true가 나와 이 객체의 `authenticate()` 로직을 타게 된다.
따라서 한번 `DaoAuthenticationProvider`의 코드를 살펴보며, 인증이 어떻게 진행되는지 확인해보자.
AbstractUserDetailsAuthenticationProvider
`DaoAuthenticationProvider`는 `AbstractUserDetailsAuthenticationProvider` 추상 클래스를 구현하는 구현체이다. `AbstractUserDetailsAuthenticationProvider`는 `AuthenticateionProvider`를 구현하는 추상 클래스이다. `UsernamePasswordAuthenticationToken`으로 인증을 진행하는 경우의 공통 로직들은 이 클래스에 구현이 되어 있고, 유저를 검색하고 검증하는 등의 세부 과정들은 하위 클래스가 구현하도록 설계가 되어 있다. 따라서 `DaoAuthenticationProvider`를 알아보기 전에 `AbstractUserDetailsAuthenticationProvider`를 간단하게 설명하고 넘어가겠다.
`AbstractUserDetailsAuthenticationProvider`는 추상 메서드로 유저를 검색할 때 호출하는 `retrieveUser()`, 검색 후 거쳐야 할 추가적인 인증 로직을 수행할 때 호출하는 `additionalAuthenticationChecks()`, 최종적으로 인증에 성공하여 `Authentication` 객체를 만들 때 추가적으로 수행해야 할 로직을 담당하는 `createSuccessAuthentication()` 외에 2개의 메서드가 정의되어 있다. 따라서 기본 설정으로는 이를 구현하는 `DaoAuthenticationProvider`가 초기화 되어 실질적인 인증 로직들이 완성되게 된다.
간략하게 흐름을 파악하자면 이러하다.
1. `AbstractUserDetailsAuthenticationProvider`는 `retrieveUser()`를 호출하기 전에 이미 서버 메모리에 캐싱된 유저가 있는 경우나 자격 만료 여부 등을 파악하여 예외를 던져 인증 로직을 수행하지 않도록 한다.
2. `retrieveUser()` 호출 후 반환 받은 `UserDetails` 객체에 추가적인 인증 체크를 위해 `additionalAuthenticationChecks()`를 호출하여 회원의 비밀번호가 요청된 비밀번호와 일치하는 지 검증하는 등의 작업을 수행한다.
3. 인증 후에 만료된 경우나 이용하지 못할 경우 예외를 던져 최종적으로 `Authentication` 객체를 만들지 못하게 한다.
4. 최종적으로 인증에 성공한 경우 `createSuccessAuthentication()`을 호출하여 `Authentication` 객체를 생성하여 반환한다.
따라서 `DaoAuthenticationProvider`의 핵심 메서드들만 한번 살펴 보기로 하고, 이외 자세한 로직은 `AbstractUserDetailsAuthenticationProvider` 클래스를 직접 참고해보길 바란다.
DaoAuthenticationProvider
package org.springframework.security.authentication.dao;
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
...
private UserDetailsService userDetailsService;
...
@Override
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
String presentedPassword = authentication.getCredentials().toString();
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
@Override
protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication)
throws AuthenticationException {
prepareTimingAttackProtection();
try {
UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
if (loadedUser == null) {
throw new InternalAuthenticationServiceException(
"UserDetailsService returned null, which is an interface contract violation");
}
return loadedUser;
}
...
}
@Override
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
UserDetails user) {
boolean upgradeEncoding = this.userDetailsPasswordService != null
&& this.passwordEncoder.upgradeEncoding(user.getPassword());
if (upgradeEncoding) {
String presentedPassword = authentication.getCredentials().toString();
String newPassword = this.passwordEncoder.encode(presentedPassword);
user = this.userDetailsPasswordService.updatePassword(user, newPassword);
}
return super.createSuccessAuthentication(principal, authentication, user);
}
...
}
우선 `retrieveUser()`를 보면 `UserDetailsService` 클래스의 `loadUserByUsername()` 메서드를 통해 `UserDetails` 객체를 가져온 다음, 반환해주는 것 밖에 없다.
즉, `UserDetailsService`의 구현체에서 구현된 로직에 따라 유저를 찾고(DB, 인메모리 등 유저를 다루는 방식에 따라 구현체가 달라짐)핵심 유저 정보를 통해 인증 과정들을 공통으로 처리할 수 있도록 `UserDetails`로 반환해 주는 것이다.
`additionalAuthenticationChecks()`는 조회한 유저의 `password`와, `Authentication` 객체에서 제공된 `password`가 일치하는지 확인하여 일치하지 않는다면 예외를 발생시키도록 구현되어 있다.
UserDetails
public interface UserDetails extends Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
String getPassword();
String getUsername();
boolean isAccountNonExpired();
boolean isAccountNonLocked();
boolean isCredentialsNonExpired();
boolean isEnabled();
}
`UserDetails` 인터페이스는 유저의 핵심 정보들을 반환하도록 메서드가 정의되어 있다. 구현체는 위 메서드들을 재정의 하게 되고, 이 구현체와 `AuthenticationProvider` 구현체를 통해 인증 로직이 수행되는 것이다.
자세한 로직들은 `UserDetailsService` 구현체들과, `AuthenticationProvider` 구현체들, `UserDetails` 구현체들 코드를 직접 살펴보며 흐름을 파악하길 바란다.
인증 흐름 정리
지금까지 `AuthenticationManager`, `AuthenticationProvider`, `UserDetailsService`, `UserDetails`를 해당 구현체와 함께 살펴보았다.
한번 `UsernameAuthenticationFilter`의 `attemptAuthenticate()` 부터 시작해서 인증 흐름을 정리해보자.
1. UsernamePasswordAuthenticationFilter의 attemptAuthentication()이 호출된다.
- username + password 를 가지는 Authentication 객체(UsernamePasswordAuthenticationToken 구현체)를 생성한다.
- AuthenticationManager 객체를 얻은 후 위에서 만든 Authentication 객체를 인자로 넣어 authenticate() 메서드를 호출해 인증을 요청한다.
2. AuthenticationManager의 authenticate()가 호출된다.
- AuthenticationProvider 리스트를 순회하여 해당 Authentication 객체를 처리할 수 있는지 확인한다.
- 처리가 가능한 경우 authenticate() 메서드를 호출하여 인증에 성공한 Authentication 객체를 얻는다.
- 처리가 불가능 한 경우 parent(AuthenticationManager)의 authenticate()를 호출하여 인증을 지속적으로 시도한다.
3. AuthenticationProvider의 authenticate()가 호출된다.
- UserDetailsService를 통해 주어진 username으로 유저를 찾는다.
- UserDetailsService를 통해 주어진 password로 유저 검증을 수행한다.
- 최종적으로 UserDetailsService가 반환한 UserDetails 객체를 통해 Authentication 객체를 생성하여 반환한다.
결론적으로 이 흐름들을 배운 이유는 인증 로직을 Spring Security가 기본 제공하는 로직들을 이용하는 것이 아니라, 개발자가 커스텀하게 가져가야 할 경우를 알아보기 위해서였다.
이제 인증 흐름을 알았으니 직접 `Authenticateion`, `AuthenticationManager`, `AuthenticationProvider`, `UserDetailsService`, `UserDetails`를 구현한 후 해당 구현체를 이용하도록 설정을 바꿔주면 되는 것이다. 추후에 예제 프로젝트를 할 때 자세히 알아보도록 하자.