UsernamePasswordAuthenticationFilter와 전반적인 인증 흐름 이해하기

이수찬·2023년 5월 9일
0

1. UsernamePasswordAuthenticationFilter란?

  • 유저가 로그인 창에서 login을 시도할 때 보내지는 요청에서 아이디(username)와 패스워드(password) 데이터를 가져온 후 인증을 위한 토큰을 생성 후 인증을 다른 쪽에 위임하는 역할을 하는 필터이다.
  • form login 방식에 주로 사용하는데, json형태의 데이터를 받아 로그인을 수행하고 싶다면 해당 filter를 커스텀해야 한다.

2. 로그인 준비하기

2-1. SecurityConfig 설정하기

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http
            .httpBasic().disable()
            .csrf().disable()
            .authorizeHttpRequests().anyRequest().authenticated();

        http.formLogin()
            .permitAll();

        return http.build();

    }
}
  • security 설정에서 모든 요청에 인증요청을 하기 위해 authorizeHttpRequests().anyRequest().authenticated();를 사용했다.
  • formLogin방식을 사용하기 위해 formLogin을 작성하고, 로그인 페이지의 경우 인증이 필요로 되면 안되기에, permitAll을 사용했다.

2-2. 로그인을 호출할 간단한 controller 만들기

@RestController
@RequiredArgsConstructor
public class Controller {

    @GetMapping("/")
    public String index() {

        return "home";
    }
    

}
  • http://localhost:8080/ 로 이동을 하면 모든 요청 url에 인증 요청을 받게 만들었기 때문에 아래와 같은 페이지로 이동한다.

  • 스프링 시큐리티는 기본 로그인 페이지를 가지고 있는데, 따로 loginPage를 설정해주지 않으면, /login 페이지로 이동한다.

3. 실제 인증과정 debug 해보기

3-1. AbstractAuthenticationProcessingFilter

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		if (!requiresAuthentication(request, response)) {
			chain.doFilter(request, response);
			return;
		}
		try {
			Authentication authenticationResult = attemptAuthentication(request, response);
			if (authenticationResult == null) {
				// return immediately as subclass has indicated that it hasn't completed
				return;
			}
			...
		}
	}

private class PreAuthenticatedProcessingRequestMatcher implements RequestMatcher {

		@Override
		public boolean matches(HttpServletRequest request) {
			Authentication currentUser = AbstractPreAuthenticatedProcessingFilter.this.securityContextHolderStrategy
					.getContext().getAuthentication();
			if (currentUser == null) {
				return true;
			}
			if (!AbstractPreAuthenticatedProcessingFilter.this.checkForPrincipalChanges) {
				return false;
			}
			if (!principalChanged(request, currentUser)) {
				return false;
			}
			AbstractPreAuthenticatedProcessingFilter.this.logger
					.debug("Pre-authenticated principal has changed and will be reauthenticated");
			if (AbstractPreAuthenticatedProcessingFilter.this.invalidateSessionOnPrincipalChange) {
				AbstractPreAuthenticatedProcessingFilter.this.securityContextHolderStrategy.clearContext();
				HttpSession session = request.getSession(false);
				if (session != null) {
					AbstractPreAuthenticatedProcessingFilter.this.logger.debug("Invalidating existing session");
					session.invalidate();
					request.getSession();
				}
			}
			return true;
		}

	}

protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
		if (this.requiresAuthenticationRequestMatcher.matches(request)) {
			return true;
		}
		if (this.logger.isTraceEnabled()) {
			this.logger
					.trace(LogMessage.format("Did not match request to %s", this.requiresAuthenticationRequestMatcher));
		}
		return false;
	}
  • 위의 코드를 보면 requiresAuthenticationRequestMatcher.matches((HttpServletRequest) request))를 통해 SecurityContext에 Authentication객체가 존재하는지 확인할 수 있다.
  • 인증 객체가 없으면, attemptAuthentication(request, response)를 통해 정보를 처리한다.

3-2. UsernamePasswordAuthenticationFilter

@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에서는 authentication 객체를 만들고, AuthenticationManager에게 authentication 객체를 전달하는 것을 알 수 있다.

@Nullable
	protected String obtainUsername(HttpServletRequest request) {
		return request.getParameter(this.usernameParameter);
	}
  • obtainUsername 코드를 보면 request.getParameter 로 username을 가져오는데 이 때문에 json으로 데이터를 받아 인증을 처리할 수 없다.

public static UsernamePasswordAuthenticationToken authenticated(Object principal, Object credentials,
			Collection<? extends GrantedAuthority> authorities) {
		return new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
	}
  • unauthenticated 메소드에서 UsernamePasswordAuthenticationToken라는 authenticaton 객체를 생성하는 것을 알 수 있다.

  • 실제 return 할 때의 authRequest는 authenticated = false로 나와 있는데, 아직 인증이 완료되지 않았다는 것을 알 수 있다.

3-3. ProviderManager

  • AuthenticationManager는 인터페이스로 실제 authentication의 인증 작업을 수행하지 않고 ProviderManager에게 인증 작업을 위임한다.
@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		Class<? extends Authentication> toTest = authentication.getClass();
        
		...
        
		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;
			}
		}
		
        ...
        
	}
  • Provider객체는 여러 종류가 존재하는데, 해당 인증을 진행할 수 있는 Provider가 인증을 시도하는 것을 알 수 있다.

@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);
	}
  • 여러 Provider 중 해당 인증은 AbstractUserDetailsAuthenticationProvider가 진행한다.
  • UserCache에 user가 존재하지 않으면, retrieveUser와 additionalAuthenticationChecks를 통해 유저 인증이 진행되는데, form login 방식에서는 DaoAuthenticationProvider가 해당 메소드를 재정의하여 실행한다.
  • user정보가 올바르면, putUsedInCache를 통해 해당 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;
	}

  • createSuccessAuthentication 메소드를 실행해 authenticated = true 인 새로운 authentication 객체를 만든다.

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);
	}
  • 인증이 완료된 Authentication 객체가 return 되면, AbstractAuthenticationProcessingFilter 로 이동해 successfulAuthentication 메소드를 실행한다.

  • SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
    context.setAuthentication(authResult) 를 통해
    새로운 SecurityContext를 생성해 Authentication 객체를 넣어줬다.
    (SecurityContext와 SecurityContextHolder 설명 링크)
  • successHandler.onAuthenticationSuccess(...)를 통해 successHandler가 실행된다.

4. 정리

  1. requiresAuthenticationRequestMatcher.matches(request) 를 통해 필터 수행

  2. UsernamePasswordAuthenticationFilter를 통해 AuthenticationManager 에게 Authentication 객체를 생성하여 제공

  3. AuthenticationManager는 ProviderManager에게 인증 위임

  4. ProviderManager가 인증 처리

  5. 인증이 성공하면, 인증이 완료된 새로운 Authentication 객체를 만들어 반환한다.

  6. 새로운 SecurityContext를 생성하여 Authentication 객체를 저장한다. (+ Session 에 저장)

  7. SuccessHandler가 작동한다.

0개의 댓글