AuthenticationManager
가 어디에서 호출되는가? 🤔
이 질문은 추후에 작성된 나의 글을 참고 하라. 하지만 이 글을 먼저 읽는 것도 좋은 방법이다.
아래의 코드를 작성하다가 AuthenticationManager
가 뭔지 파헤쳐 보았다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
// /login 요청을 하면 로그인 시도를 위해서 실행되는 함수
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
try {
//Request 에서 JSON으로 보내준 정보를 User객체에 저장하는 코드
ObjectMapper om = new ObjectMapper();
User user = om.readValue(request.getInputStream(),User.class);
//여기 부분에서 의문이 생겼다.
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// ...
} catch (IOException e) {
throw new RuntimeException(e);
}
UsernamePasswordAuthenticationFilter
를 구현해서 SecurityConfig
에 직접 추가해주었다.
여기서 핵심인 부분은 아무래도 이 인증을 하는 부분
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
Authentication authentication = authenticationManager.authenticate(authenticationToken);
이 부분의 작동을 파해쳐 보았다.
자 시작부분의 코드에서 우리는 authentication.authenticate(authenticationToken)
을 통해 인증을 시도했다.
그렇다면 AuthenticationManager
란 무엇일까?
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}
아주 간단한 인터페이스 이다. authenticate 라는 메서드 하나만 담고 있다.
즉 이 인터페이스의 구현체는 Authentication
요청을 처리한다.
ProviderManager
은 가장 흔한 AuthenticationManager
의 구현체이다.
이 구현체의 역할은 아주 명확하다.
일명 적합한 인증 제공자 찾아주기
ProviderManager
은 AuthenticationProvider
을 여러개를 가지고 있다.AuthenticationProvider
은 곧 나오겠지만, 각 타입별로 인증을 제공하는 객체이다.)ProviderManager
은 어떤 AuthenticationProvider
가null
이 아닌 return 을 제공 할 때까지 목록을 차례대로 반복한다.AuthenticationProvider
가 null
이 아닌값을 반환했다는 것은 인증 요청을 결정할 수 있는 권한이 있으며 더이상 다음 AuthenticationProvider
를 시도하지 않아도 됨을 의미합니다.AuthenticationProvider
가 요청을 성공적으로 인증하면, 이전 AuthenticationException
이 무시되고 성공적인 인증이 사용된다.간단하게 말하면 " 주어진 Authentication
을 잘 처리할 수 있는 Provider
가 나타날 때까지 쭉 훑어본다. " 라고 이해하면 된다.
참고로 이렇게 두개의 ProviderManager
을 설정할 수도 있는데, 이는 SecurityFilterChanin
을 두개 생성할 때 각 FilterChain에 서로 다른 ProviderManager
을 적용하고 싶을 때 유용하다.
코드에서는 AuthenticationManager
인터페이스로 받고 각각 구현된 ProviderManager
를 배정하면 되기 때문이다.
타입별 인증제공자
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
boolean supports(Class<?> authentication);
}
ProviderManager
는 특정 타입에 대한 인증을 진행하는 역할을 하고, 두가지 메서드가 정의 되어있다. 위에서 말했듯 AuthenticationProvider
는 루프를 돌며 자기가 갖고 있는 AuthenticationProvider
들 하나씩 적합한지 체크한다.
authentication
메서드는 말그대로 Authentication을 실행하는 로직이 담겨있다.
supports
는 이 Provider가 해당 타입에 대한 인증을 진행하는지 boolean으로 알려준다.
우리가 이번에 예시로 볼 AuthenticationProvider
는 대표적인 DaoAuthenticationProvider
이다.
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
Authentication authentication = authenticationManager.authenticate(authenticationToken);
간단하게 설명하자면 Authentication
에는 두가지 목적이 있다.
AuthenticationManager
에 전달하기 위해 (이 경우 isAuthenticated()
는 false
를 반환한다.Authentication
은 다음 세가지를 포함한다.
principal
: 유저 식별/구별하는 요소 , username/password에서는 UserDetail
이 사용됨credentials
: 주로 비밀번호, 보통 인증이 완료되면 지워진다.authorities
: 권한UsernamePasswordAuthenticationToken
은 Authentication
의 자손이다.
Authentication
-> AbstractAuthenticationToken
-> UsernamePasswordAuthenticationToken
public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {
// UsernamePasswordAuthenticationToken Constructor
// 인증 안됬을 때 생성자
// AuthenticationManager 로 전달할때 사용
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
super(null);
this.principal = principal;
this.credentials = credentials;
setAuthenticated(false);
}
// 인증 됬을 때 생성자
// 인증 다 되면 만들어서 리턴해줌
public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true); // must use super, as we override
}
이렇게 만들어진 전달용(아직 인증안된) Authentication
을 authenticationManager.authenticate()
의 인자로 넘겨준다.
ProviderManager
은 AuthenticationProvider
의 대표적인 구현체 이다.
그 코드를 살펴보겠다.
1 번의 코드에서 생성된 Authentication
(isAuthentication = false
)을 이 클래스의 .authenticate()
메서드의 인자로 넘겨주었다.
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
public ProviderManager(AuthenticationProvider... providers) {
this(Arrays.asList(providers), null);
}
public ProviderManager(List<AuthenticationProvider> providers) {
this(providers, null);
}
public ProviderManager(List<AuthenticationProvider> providers, AuthenticationManager parent) {
Assert.notNull(providers, "providers list cannot be null");
this.providers = providers;
this.parent = parent;
checkState();
}
인증을 진행하는 AuthenticationProvider
을 받는 생성자들이고 특이하게 한개의 생성자는 부모 AuthenticationManager
을 인자로 받는 생성자도 있다.
아래 로직에서 만약 이 ProviderManager
에서 해결을 못했을 때, 부모 AuthenticationManager
가 있다면 부모 AuthenticationManager
을 한번 뒤져서 루프를 찾아보는 로직이 있다.
핵심 메서드이다. AuthenticationManager
인터페이스에 명시되어있는 하나의 메서드 이기도 하다.
일단 전체코드를 보고 단계별로 설명을 하겠다.
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = null;
AuthenticationException parentException = null;
Authentication result = null;
Authentication parentResult = null;
int currentPosition = 0;
int size = this.providers.size();
for (AuthenticationProvider provider : getProviders()) {
if (!provider.supports(toTest)) {
continue;
}
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
provider.getClass().getSimpleName(), ++currentPosition, size));
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException ex) {
prepareException(ex, authentication);
// SEC-546: Avoid polling additional providers if auth failure is due to
// invalid account status
throw ex;
}
catch (AuthenticationException ex) {
lastException = ex;
}
}
if (result == null && this.parent != null) {
// Allow the parent to try.
try {
parentResult = this.parent.authenticate(authentication);
result = parentResult;
}
catch (ProviderNotFoundException ex) {
// ignore as we will throw below if no other exception occurred prior to
// calling parent and the parent
// may throw ProviderNotFound even though a provider in the child already
// handled the request
}
catch (AuthenticationException ex) {
parentException = ex;
lastException = ex;
}
}
if (result != null) {
if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
// Authentication is complete. Remove credentials and other secret data
// from authentication
((CredentialsContainer) result).eraseCredentials();
}
// If the parent AuthenticationManager was attempted and successful then it
// will publish an AuthenticationSuccessEvent
// This check prevents a duplicate AuthenticationSuccessEvent if the parent
// AuthenticationManager already published it
if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}
// Parent was null, or didn't authenticate (or throw an exception).
if (lastException == null) {
lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound",
new Object[] { toTest.getName() }, "No AuthenticationProvider found for {0}"));
}
// If the parent AuthenticationManager was attempted and failed then it will
// publish an AbstractAuthenticationFailureEvent
// This check prevents a duplicate AbstractAuthenticationFailureEvent if the
// parent AuthenticationManager already published it
if (parentException == null) {
prepareException(lastException, authentication);
}
throw lastException;
}
사전에 변수를 지정해준다.
앞에서 설명했듯, 로직상 일단 Exception이나 null 값은 일단 보류하고 하나씩 시도를 해본다.
그러다가 null 이 아닌 값이 나오면 그 값을 반환하는 로직이기 때문에 아래와 같은 변수를 만든다.
부모와 관련된 변수들은 조금 이따 나오겠지만, 만약 알맞은 provider을 찾지 못했을 때 부모 AuthenticationProvider 가 있으면 한번 훑을 때 필요하다.
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
//넘겨받은 인자의 클래스
Class<? extends Authentication> toTest = authentication.getClass();
//마지막 예외, 부모 예외 keep 하기 위해
AuthenticationException lastException = null;
AuthenticationException parentException = null;
//결과, 부모의 결과 keep 하기 위해
Authentication result = null;
Authentication parentResult = null;
//나중에 로그 남길 때 씀
int currentPosition = 0;
int size = this.providers.size();
아래는 갖고 있는 AuthenticationProvider
을 하나씩 반복하며 진행한다.
InternalAuthenticationServiceException
AccountStatusException
for (AuthenticationProvider provider : getProviders()) {
//supports() 메서드는 이 provider가 해당 타입을 지원하는지 boolean 값으로 알려준다.
if (!provider.supports(toTest)) {
continue; //지원하지 않으면 바로 다음 provider로
}
//로그
if (logger.isTraceEnabled()) {
logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
provider.getClass().getSimpleName(), ++currentPosition, size));
}
try {
//지원하므로 AuthenticationProvider의 authenticate 메서드를 호출한다.
result = provider.authenticate(authentication); //Authentication result
if (result != null) { // null 이 아니면 (인증되면)
//복사해준다 : 주어진 Authentication detail -> 만들어진 Authentication detail
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException | InternalAuthenticationServiceException ex) {
//AccountStatusException : 계정이 잠기거나 비활성화 같이 계정의 상태에 따라 발생
//InternalAuthenticationServiceException : 내부적으로 발생한 시스템 문제로 인해 인증 요청을 처리하지 못한 경우 발생, 위 링크 참조
prepareException(ex, authentication);
// -> this.eventPublisher.publishAuthenticationFailure(ex, auth);
// SEC-546: Avoid polling additional providers if auth failure is due to
// invalid account status
throw ex;
}
catch (AuthenticationException ex) {
//AuthenticationException 발생하면 일단 변수에 저장하고 보류
lastException = ex;
}
}
parent가 있으면 parent도 동일한 과정을 시도
만약 null 이 아닌 값이 찾아졌으면, credential 지우기
if (result == null && this.parent != null) {
// Allow the parent to try. 부모가 있으면 시도
try {
parentResult = this.parent.authenticate(authentication);
result = parentResult;
}
catch (ProviderNotFoundException ex) {
// 맨마지막에 안찾아지면 던질거 이므로 일단 부모에서 발생한 이 예외는 무시
}
catch (AuthenticationException ex) {
// 일단 보류
parentException = ex;
lastException = ex;
}
}
if (result != null) {
if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
// Authentication 이 완료됬으므로 Authentication에서 credentials를 삭제
((CredentialsContainer) result).eraseCredentials();
}
// parent 에서 시도 하고 성공했으면 이미 AuthenticationSuccess()가 발생했으므로
// 중복으로 발생시키는 것을 방지한다.
if (parentResult == null) {
this.eventPublisher.publishAuthenticationSuccess(result);
}
// result가 있으면 리턴해준다.
// 여기서 만들어진 Authentication result는 똑같은 Authentication 오브젝트 이지만,
// 역할이 다르다.
return result;
}
// Parent was null, or didn't authenticate (or throw an exception).
if (lastException == null) {
lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound",
new Object[] { toTest.getName() }, "No AuthenticationProvider found for {0}"));
}
// If the parent AuthenticationManager was attempted and failed then it will
// publish an AbstractAuthenticationFailureEvent
// This check prevents a duplicate AbstractAuthenticationFailureEvent if the
// parent AuthenticationManager already published it
if (parentException == null) {
prepareException(lastException, authentication);
}
throw lastException;
}
AuthenticationProvider
은 인터페이스다. 이를 구현해서 각 Authentication 에 대한 적절한 처리를 한다.
public interface AuthenticationProvider {
// Authentication 에 대해 인증을 진행한다.
// ProviderManager(AuthenticationManager) 에서 루프를 돌면서 호출함
Authentication authenticate(Authentication authentication) throws AuthenticationException;
// 이 Authentication 이 처리 가능한지 알려주는 메서드
// 이 또한 ProviderManager 에서 호출한다.
boolean supports(Class<?> authentication);
}
여기서 예를 들 것은 가장 기본적인 username/password 인증을 하는 DaoAuthenticationProvider
로 예시를 들 것이다.
AbstractUserDetailsAuthenticationProvider
를 extend 하는데 이는 추상메서드이다.
AbstractUserDetailsAuthenticationProvider
에서 구현되어서 상속된다.
이 authentication 타입을 지원하는지 체크한다
@Override
public boolean supports(Class<?> authentication) {
return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
}
AbstractUserDetailsAuthenticationProvider
에서 구현되어서 상속된다.
인증을 진행하는 핵심 메서드이다.
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
//타입 체크
Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
"Only UsernamePasswordAuthenticationToken is supported"));
// 대충 설명하면 determineUsername() -> authentication.getName으로 가져오기
String username = determineUsername(authentication);
// 캐시를 사용했으면, 캐시 관련 동작을 한다.
boolean cacheWasUsed = true;
UserDetails user = this.userCache.getUserFromCache(username);
if (user == null) {
cacheWasUsed = false;
try {
//핵심 메서드, User정보를 가져오는 부분
//retrieveUser는 직접 UserDetails을 추출하는 메서드로
// 이곳에서 UserDetailsService를 호출해서 UserDetails를 만든다
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Failed to find user '" + username + "'");
if (!this.hideUserNotFoundExceptions) {
throw ex;
}
throw new BadCredentialsException(this.messages
.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
}
try {
//핵심 메서드, 받은 UserDetail을 체크한다.
//잠겼는지,비활성화 됬는지, 만료됬는지 체크
this.preAuthenticationChecks.check(user);
//추상메서드 이므로 DaoAuthenticationProvider 에서 구현해주어야함
//인증이 끝난 후 추가적으로 체크해야할 것들이 들어감
//DaoAuthenticationProvider에서는 비밀번호가 있는지, 그리고 비밀번호가 맞는지 체크함
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
catch (AuthenticationException ex) {
if (!cacheWasUsed) {
throw ex;
}
// 캐시에서 사용된 데이터를 업데이트 한다.
cacheWasUsed = false;
user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
// 그리고 다시 체크
this.preAuthenticationChecks.check(user);
additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
}
this.postAuthenticationChecks.check(user);
if (!cacheWasUsed) { //캐시에 저장
this.userCache.putUserInCache(user);
}
Object principalToReturn = user;
//this.forcePrincipalAsString 은 맨 마지막에 리턴되는 Principal 값을 String으로 내보낼지 정의한다.
//보통 일반적인 경우라면 UserDetail을 사용하여 구별하며 UserDetail은 추가적인 정보를 제공하므로 유용하다.
//https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/authentication/dao/AbstractUserDetailsAuthenticationProvider.html#isForcePrincipalAsString()
if (this.forcePrincipalAsString) {
principalToReturn = user.getUsername();
}
//리턴하는 Authentication 만들어서 리턴한다.
return createSuccessAuthentication(principalToReturn, authentication, user);
}
핵심 메서드 를 아래서 한번 살펴보자.
이 메서드들은 모두 DaoAuthenticationProvider
에 구현되어있다.
UserDetailsService로 부터 UserDetails를 생성하는 역할을 맡고 있다.
DaoAuthenticationProvider
에 구현되어있다.
@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;
}
catch (UsernameNotFoundException ex) {
mitigateAgainstTimingAttack(authentication);
throw ex;
}
catch (InternalAuthenticationServiceException ex) {
throw ex;
}
catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
}
}
DaoAuthenticationProvider
에 구현되어있다.
일반적으로 Authentication.getCredentials()
와 UserDetails.getPassword()
를 비교하는 로직이 들어간다.
@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"));
}
}
DaoAuthenticationProvider
에 구현되어있다.
Authentication
을 만들어서 리턴해준다.
@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);
}
upgradeEncoding이 의미하는 것은 이제 이중으로 비밀번호를 인코딩할 것인지를 의미한다.
우리가 BCryptEncoder? 같은거로 해싱을 하는데 이를 두번 돌려서 암호화 하는 옵션도 존재한다.
이는 공식 문서와 api에 자세히 나와있다.
아래는 super.createSuccessAuthentication(principal, authentication, user);
protected Authentication createSuccessAuthentication(Object principal, Authentication authentication,
UserDetails user) {
// Ensure we return the original credentials the user supplied,
// so subsequent attempts are successful even with encoded passwords.
// Also ensure we return the original getDetails(), so that future
// authentication events after cache expiry contain the details
UsernamePasswordAuthenticationToken result = UsernamePasswordAuthenticationToken.authenticated(principal,
authentication.getCredentials(), this.authoritiesMapper.mapAuthorities(user.getAuthorities()));
result.setDetails(authentication.getDetails());
this.logger.debug("Authenticated user");
return result;
}
여기서는 맨 처음과 똑같이 UsernamePasswordAuthenticationToken
을 만들어서 리턴하는데 잘 봐야할 곳은 UsernamePasswordAuthenticationToken.authenticated()
메서드 이다.
이는 static method로 UsernamePasswordAuthenticationToken
에 .authenticated()
, .unauthenticated
두가지 static 메서드가 정의 되어있다.
이는 생성자와 비슷하게 인스턴스를 생성해서 리턴해주고, isAuthenticated
가 true 이냐 false냐 이다.
이 의미는 처음 authenticationManager.authenticate(Authenticate authenticate)
에 넣어주는, 즉 유저가 입력한 정보를 전달하는 역할의 Authentication
은 unauthenticated 로
이렇게 인증이 완료된 유저를 createSuccessAuthentication
을 통해서 리턴해줄때는 authenticated 된 유저를 리턴해주게 된다.
authenticationManager.authencation()
을 통해 인증을 시도하고, 위의 일련의 과정을 거친 후 인증이 성공하면 Authentication
객체를 받게 된다. 이를 ContextHolder에 등록하던, 어떻게 하던 처리를 하면 된다.
나의 경우 이 글의 맨 처음 시작 코드로 돌아가면 JwtFilter가 받아서 적절한 처리를 하게 된다.
쓰는데 너무 오래걸렸다. 2일 넘게...
사실 전에 SpringSecurity api와 doc을 영어로 읽고 있었는데, 되게 완벽하게 이해되기 힘들었다.
하지만 이렇게 코드를 하나씩 따라 올라가면서 동작 코드를 분석해보니, 정말 직관적으로 이해되기 시작했다.
이 코드 역시 하나의 소통수단이자 언어라는 생각이 들었고, 이 SpringSecurity 코드를 작성한 사람들의 코드가 마치 하나의 언어처럼 쭉 읽으며 이해가 된다는 것이 참 배우고 싶다는 생각이 많이 들었다.
몇몇 개념은 검색과 공식문서를 봐야했지만, 사실 대부분의 흐름과 관련된 내용은 코드를 '읽으면서' 이해할 수 있었다.
앞으로도 계속해서 SpringSecurity를 학습하고 미래의 나를 위해 글을 남기 예정이다.
AuthenticationManager의 implements가 5개 정도 있는걸로 아는디 어떻게 providerManager가 구현체로 동작하신다는 걸 아셨나요?