[Spring Security] 4. UsernamePasswordAuthenticationFilter

전유림·2024년 2월 16일
0

Spring Security

목록 보기
5/8

Authentication Flow


1. 폼 로그인 화면을 통해 아이디와 비밀번호를 입력한다.
2. AbstractAuthenticationProcessingFilter의 doFilter 메서드가 호출된다. 이 메서드는 실제 인증을 수행하는 필터인 UsernamePasswordAuthenticationFilter의 attemptAuthentication 메서드를 호출한다.
3. attemptAuthentication 메서드에서는 입력받은 아이디와 비밀번호를 사용하여 Authentication 객체를 생성하고 AuthenticationManager를 통해 실제 인증을 시도한다.
4. AuthenticationManager는 여러 AuthenticationProvider를 가지고 있으며, 각 AuthenticationProvider는 특정한 방식으로 인증을 수행한다.
( 아이디, 패스워드 인증 방식에선 DaoAuthenticationProvider를 사용한다. UserDetailsService를 이용하여 인증을 수행한다. )
5. 이후 AbstractAuthenticationProcessingFilter로 돌아가서 인증이 성공했을 시 AuthenticationSuccessHandler를 호출한다. 반대로 인증이 실패했을 시 AuthenticationFailureHandler를 호출한다.

1. UsernamePasswordAuthenticationFilter

UsernamePasswordAuthenticationFilter는 Spring Security에서 제공되는 보안 필터 중 하나이다. 폼 기반의 로그인을 처리하며, HTTP POST 요청을 통해 전송된 사용자의 아이디와 비밀번호를 기반으로 인증을 수행한다. 즉, 사용자가 로그인 폼에 자격 증명을 제출하면, 해당 요청을 가로채서 인증 작업을 수행한다.

정확히는 AbstractAuthenticationProcessingFilter로 먼저 들어오게 되고 doFilter 메서드에서 attemptAuthentication 메서드를 호출하게 되는데, 이 때 이를 상속하는 UsernamePasswordAuthenticationFilter가 사용된다.

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);
	}
    
    // ...
    
 }

attemptAuthentication 메서드는 obtainUsername 메서드와 obtainPassword 메서드를 통해 request로 부터 username과 password를 추출하고 UsernamePasswordAuthenticationToken이란 Authentication 객체를 생성하여 AuthenticationManager의 authenticate 메서드로 전달한다.

AuthenticationManager

AuthenticationManager 인터페이스는 authenticate 메서드를 정의하고 있다.

public interface AuthenticationManager {
	Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

Spring Security에서 해당 AuthenticationManager 인터페이스를 구현한 구현체는 ProviderManager로 AuthenticationProvider와 함께 실제 인증을 수행한다.

ProviderManager는 특정 AuthenticationProvider에게 인증을 위임하고, AuthenticationProvider가 실제 인증 로직을 수행한다. 이렇게 함으로써 AuthenticationManager는 다양한 인증 방식을 지원하고 관리할 수 있다.

AuthenticationManager의 구현체인 ProviderManager는 여러 AuthenticationProvider를 가진다. 인증 방식에 따라 AuthenticationProvider가 선택되어 사용되며, 이를 결정하기 위해 AuthenticationManager의 authenticate 메서드를 호출할 때 전달되는 Authentication 객체의 구현체를 기반으로 선택된다.

아이디와 패스워드 기반의 인증을 수행할 때 UsernamePasswordAuthenticationFilter는 UsernamePasswordAuthenticationToken를 전달하고, ProviderManager는 이를 처리할 수 있는 AuthenticationProvider를 찾아서 인증을 위임한다. 대표적으로 DaoAuthenticationProvider가 이 역할을 수행한다.

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
   
   // ...
    
	@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;
	}
    
    // ...
    
}

authenticate 메서드는 AuthenticationProvider 목록을 반복문으로 돌면서 인증 처리를 위임한다.

  • 각각의 AuthenticationProvider는 전달 받은 Authentication 객체를 처리하고, 인증에 성공하면 새로운 Authentication 객체를 생성하여 반환한다.
  • 만약 지원하지 않는 Authentication 객체 타입을 받았거나 인증에 실패했다면, ProviderManager는 다음 등록된 AuthenticationProvider로 처리를 넘기면서 탐색을 계속한다.

이렇게 등록된 모든 AuthenticationProvider로 시도한 후에도 인증에 실패하면 AuthenticationException이 발생한다.

AuthenticationProvider

AuthenticationProvider 인터페이스는 아래와 같이 두 가지 메서드를 제공한다.

public interface AuthenticationProvider {
	Authentication authenticate(Authentication authentication) throws AuthenticationException;
	boolean supports(Class<?> authentication);
}

Spring Security에서는 AuthenticationProvider 인터페이스를 통해 다양한 인증 방식을 지원할 수 있도록 여러 AuthenticationProvider 구현체를 제공한다.

각각의 구현체는 supports 메서드를 사용하여 자신이 처리할 수 있는 Authentication 유형을 확인한 후 authenticate 메서드를 호출하여 실제 인증을 수행한다.

여러 AuthenticationProvider 중 아이디 패스워드 방식의 인증 방식을 처리하는 AbstractUserDetailsAuthenticationProvider와 이를 확장한 DaoAuthenticationProvider를 살펴보자.

public abstract class AbstractUserDetailsAuthenticationProvider
		implements AuthenticationProvider, InitializingBean, MessageSourceAware {

    // ...
    
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
				() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
						"Only UsernamePasswordAuthenticationToken is supported"));
		String username = determineUsername(authentication);
		boolean cacheWasUsed = true;
		UserDetails user = this.userCache.getUserFromCache(username);
		if (user == null) {
			cacheWasUsed = false;
			try {
				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 {
			this.preAuthenticationChecks.check(user);
			additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
		}
		catch (AuthenticationException ex) {
			if (!cacheWasUsed) {
				throw ex;
			}
			// There was a problem, so try again after checking
			// we're using latest data (i.e. not from the cache)
			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;
		if (this.forcePrincipalAsString) {
			principalToReturn = user.getUsername();
		}
		return createSuccessAuthentication(principalToReturn, authentication, user);
	}
    
    //...
    
    @Override
	public boolean supports(Class<?> authentication) {
		return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication));
	}
    
    // ...
    
}

supports 메서드는 UsernamePasswordAuthenticationToken 객체의 인증을 지원하도록 되어있는것을 확인 할 수 있다.

authenticate 메서드는 UsernamePasswordAuthenticationToken 객체로 인증을 요청받았으니 해당 객체의 아이디와 패스워드 정보와 데이터베이스에서 갖고있는 유저의 아이디, 패스워드 정보를 비교하여 인증 성공 여부를 판단한다.

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

	/**
	 * The plaintext password used to perform
	 * {@link PasswordEncoder#matches(CharSequence, String)} on when the user is not found
	 * to avoid SEC-2056.
	 */
	private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword";

	private PasswordEncoder passwordEncoder;

	/**
	 * The password used to perform {@link PasswordEncoder#matches(CharSequence, String)}
	 * on when the user is not found to avoid SEC-2056. This is necessary, because some
	 * {@link PasswordEncoder} implementations will short circuit if the password is not
	 * in a valid format.
	 */
	private volatile String userNotFoundEncodedPassword;

	private UserDetailsService userDetailsService;

	private UserDetailsPasswordService userDetailsPasswordService;

	public DaoAuthenticationProvider() {
		this(PasswordEncoderFactories.createDelegatingPasswordEncoder());
	}

	/**
	 * Creates a new instance using the provided {@link PasswordEncoder}
	 * @param passwordEncoder the {@link PasswordEncoder} to use. Cannot be null.
	 * @since 6.0.3
	 */
	public DaoAuthenticationProvider(PasswordEncoder passwordEncoder) {
		setPasswordEncoder(passwordEncoder);
	}

	@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 void doAfterPropertiesSet() {
		Assert.notNull(this.userDetailsService, "A UserDetailsService must be set");
	}

	@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);
		}
	}

	@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);
	}

	private void prepareTimingAttackProtection() {
		if (this.userNotFoundEncodedPassword == null) {
			this.userNotFoundEncodedPassword = this.passwordEncoder.encode(USER_NOT_FOUND_PASSWORD);
		}
	}

	private void mitigateAgainstTimingAttack(UsernamePasswordAuthenticationToken authentication) {
		if (authentication.getCredentials() != null) {
			String presentedPassword = authentication.getCredentials().toString();
			this.passwordEncoder.matches(presentedPassword, this.userNotFoundEncodedPassword);
		}
	}

	/**
	 * Sets the PasswordEncoder instance to be used to encode and validate passwords. If
	 * not set, the password will be compared using
	 * {@link PasswordEncoderFactories#createDelegatingPasswordEncoder()}
	 * @param passwordEncoder must be an instance of one of the {@code PasswordEncoder}
	 * types.
	 */
	public void setPasswordEncoder(PasswordEncoder passwordEncoder) {
		Assert.notNull(passwordEncoder, "passwordEncoder cannot be null");
		this.passwordEncoder = passwordEncoder;
		this.userNotFoundEncodedPassword = null;
	}

	protected PasswordEncoder getPasswordEncoder() {
		return this.passwordEncoder;
	}

	public void setUserDetailsService(UserDetailsService userDetailsService) {
		this.userDetailsService = userDetailsService;
	}

	protected UserDetailsService getUserDetailsService() {
		return this.userDetailsService;
	}

	public void setUserDetailsPasswordService(UserDetailsPasswordService userDetailsPasswordService) {
		this.userDetailsPasswordService = userDetailsPasswordService;
	}

}

retrieveUser 메서드는 요청으로 전달 받은 아이디를 이용하여 UserDetailsService의 loadUserByUsername 메서드에서 UserDetails 객체를 받아온다. 아이디를 이용하여 데이터베이스에서 사용자 정보를 가져오는 과정에서 존재하지 않으면 UsernameNotFoundException을 발생시킨다.

public interface UserDetailsService {
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
ublic interface UserDetails extends Serializable {
	String getPassword();
	String getUsername();
	boolean isAccountNonExpired();
	boolean isAccountNonLocked();
	boolean isCredentialsNonExpired();
	boolean isEnabled();
}

additionalAuthenticationChecks 메서드는 요청으로 전달 받은 패스워드와 UserDetails 객체의 패스워드가 일치하는지 확인한다. 일치하지 않으면 BadCredentialsException을 발생시킨다.

인증 과정이 성공하면 UsernamePasswordAuthenticationToken 객체를 생성하여 사용자의 인증 정보와 함께 반환한다.

AbstractAuthenticationProcessingFilter

protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
        Authentication authResult) throws IOException, ServletException {
    SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
    context.setAuthentication(authResult);
    this.securityContextHolderStrategy.setContext(context);
    this.securityContextRepository.saveContext(context, request, response);    
    if (this.logger.isDebugEnabled()) {
        this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
    }    
    this.rememberMeServices.loginSuccess(request, response, authResult);
    if (this.eventPublisher != null) {
        this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
    }
    this.successHandler.onAuthenticationSuccess(request, response, authResult);
}

인증이 성공하면 successfulAuthentication 메서드가 호출된다.

SecurityContextHolder를 사용하여 빈 SecurityContext를 생성하고, Authentication 객체를 이 SecurityContext에 설정한다.

이후 SecurityContextRepository를 사용하여 SecurityContext를 저장한다. 이렇게 함으로써 현재 사용자의 인증 정보가 세션에 저장된다.

마지막으로 AuthenticationSuccessHandler를 호출하여 사용자를 인증된 상태로 리디렉션하거나 다른 작업을 수행한다.

protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException failed) throws IOException, ServletException {
    this.securityContextHolderStrategy.clearContext();
    this.logger.trace("Failed to process authentication request", failed);
    this.logger.trace("Cleared SecurityContextHolder");
    this.logger.trace("Handling authentication failure");
    this.rememberMeServices.loginFail(request, response);
    this.failureHandler.onAuthenticationFailure(request, response, failed);
}

반면에 인증이 실패하면 unsuccessfulAuthentication 메서드가 호출되고, 이 메서드에서는 AuthenticationFailureHandler가 실행된다.

0개의 댓글