Spring Security 인증

김병수·2022년 11월 25일
1
post-thumbnail

Spring Security 인증 처리 흐름

  1. 사용자가 로그인 폼 등을 이용해 Username과 Password를 포함한 request를 Spring Security가 적용된 애플리케이션에 전송한다. 사용자의 요청이 Filter Chain까지 들어오면 필터들 중에서 UsernamePasswordAuthenticationFilter가 해당 요청 받는다.

  2. 요청을 전달 받은 필터는 Username과 Password를 이용해 UsernamePasswordAuthenticationToken을 생성한다.

    • Authentication 인터페이스를 구현한 구현 클래스이면 여기서 Authentication은 아직 인증이 되지 않는다.
  3. 아직 인증되지 않은 Authentication을 가지고 있는 UsernamePasswordAuthenticationFilter는 (3)과 같이 해당 Authentication을 AuthenticationManager에게 전달한다.

    • 인증 처리를 총괄하는 매니저 역할을 하는 인터페이스, AuthenticationManager를 구현한 구현 클래스가 ProviderManager 이다.

    • 즉, ProviderManager가 인증이라는 작업을 총괄하는 실질적인 매니저

  4. ProviderManager로부터 Authentication을 전달 받은 AuthenticationProvider는 (5)와 같이 UserDetailsService를 이용해 UserDetails를 조회한다.

    • UserDetails는 데이터베이스 등의 저장소에 저장된 사용자의 Username과 사용자의 자격을 증명해주는 크리덴셜인 Password, 사용자의 권한 정보를 포함하고 있는 컴포넌트
    • UserDetails를 제공하는 컴포넌트가 바로 UserDetailsService 이다.
  5. UserDetailsService에서 UserDetails를 조회한다.

  6. 크리덴셜을 포함한 사용자의 정보를 DB에서 조회한다.

  7. 저장소에서 조회한 사용자의 크리덴셜을 포함한 사용자의 정보를 기반으로 (7)과 같이 UserDetails를 생성한다.

  8. 생성된 것을 다시 AuthenticationProvider 에게 전달한다.

  9. UserDetails를 전달 받은 AuthenticationProvider 는 PasswordEncoder를 이용하여 UserDetails에 포함된 암호화 된 Password와 인증을 위한 Authentication 안에 포함된 Password가 일치하는지 검증한다.

    • 검증 성공하면 UserDetails를 이용하여 인증된 Authentication 생성
    • 검증에 성공하지 못하면 예외를 발생시키고 인증 처리 중단
  10. AuthenticationProvider는 인증된 Aithentication을 ProviderManager에게 전달한다.

    • (2)에서의 Authentication은 인증을 위해 필요한 사용자의 로그인 정보를 가지지만, 이 단계에서의 Authentication은 인증에 성공한 사용자의 정보 (Principal, Credential, GrantedAuthorities)를 가지고 있다.
  11. ProviderManager는 인증된 Authentication을 다시 UsernamePasswordAuthenticationFilter에게 전달한다.

  12. SecurityContextHolder를 이용해 SecurityContext에 인증된 Authentication을 저장한다.

    • 이후에 Spring Security의 세션 정책에 따라서 HttpSession에 저장되어 사용자의 인증 상태를 유지하기도 하고, 무상태를 유지하기도 한다.

Spring Security 인증 컴포넌트

UsernamePasswordAuthenticationFilter

//doFilter 메서드를 사용하기 위해 AbstractAuthenticationProcessingFilter클래스를 상속받음
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter { 

	public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username"; 

	public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password"; 
    //클라이언트의 Url에 매치되는 매처
	private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login","POST");

	public UsernamePasswordAuthenticationFilter(AuthenticationManager authenticationManager) {
    // AntPathRequestMatcher의 객체(DEFAULT_ANT_PATH_REQUEST_MATCHER)와 AuthenticationManager를 
    // 상위 클래스인 AbstractAuthenticationProcessingFilter에 전달
		super(DEFAULT_ANT_PATH_REQUEST_MATCHER, authenticationManager);
	}

	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException {
    // POST가 아니면 예외 발생
		if (this.postOnly && !request.getMethod().equals("POST")) {
			throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
		}

		String username = obtainUsername(request);

		String password = obtainPassword(request);
		
    // UsernamePasswordAuthenticationToken 생성
    UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
		...

		return this.getAuthenticationManager().authenticate(authRequest); // 인증 처리 위임
	}

}

로그인 폼에서 제출되는 Username과 Password를 통한 인증을 처리하는 필터. Spring Security가 인증 프로세스에서 이용할 수 있도록 UsernamePasswordAuthenticationToken 을 생성한다.

AbstractAuthenticationProcessingFilter

public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
		implements ApplicationEventPublisherAware, MessageSourceAware {

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
	}

	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;
			}
			this.sessionStrategy.onAuthentication(authenticationResult, request, response);
			// Authentication success
			if (this.continueChainBeforeSuccessfulAuthentication) {
				chain.doFilter(request, response);
			}
            //SecurityContextHolder를 통해 사용자의 인증 정보를 SecurityContext에 저장한 뒤, SecurityContext를 HttpSession에 저장
			successfulAuthentication(request, response, chain, authenticationResult); 
		}
		catch (InternalAuthenticationServiceException failed) {
			this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
			unsuccessfulAuthentication(request, response, failed); 
		}
		catch (AuthenticationException ex) {
			// Authentication failed
			unsuccessfulAuthentication(request, response, ex);
		}
	}
	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;
	}

	protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
			Authentication authResult) throws IOException, ServletException {
		SecurityContext context = SecurityContextHolder.createEmptyContext();
		context.setAuthentication(authResult);
		SecurityContextHolder.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);
	}

	protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException failed) throws IOException, ServletException {
		SecurityContextHolder.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);
	}

}

UsernamePasswordAuthenticationFilter가 상속하는 상위 클래스. HTTP 기반의 인증 요청을 처리하지만 인증 시도는 하위 클래스에 맡기고, 인증에 성공하면 인증된 사용자의 정보를 SecurityContext에 저장하는 역할을 한다.

UsernamePasswordAuthenticationToken

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {

	private final Object principal;

	private Object credentials;
	
  //  인증에 필요한 용도의 UsernamePasswordAuthenticationToken 객체를 생성
	public static UsernamePasswordAuthenticationToken unauthenticated(Object principal, Object credentials) {
		return new UsernamePasswordAuthenticationToken(principal, credentials);
	}

  //  인증에 성공한 이후 SecurityContext에 저장될 UsernamePasswordAuthenticationToken 객체를 생성
	public static UsernamePasswordAuthenticationToken authenticated(Object principal, Object credentials,
			Collection<? extends GrantedAuthority> authorities) {
		return new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
	}


}

Spring Security에서 Username/Password로 인증을 수행하기 위해 필요한 토큰. 인증 성공 후 인증에 성공한 사용자의 인증 정보가 UsernamePasswordAuthenticationToken에 포함되어 Authentication 객체 형태로 SecurityContext에 저장된다.

Authentication

public interface Authentication extends Principal, Serializable {
	Collection<? extends GrantedAuthority> getAuthorities();
	Object getCredentials(); 
	Object getDetails();
	Object getPrincipal();
	boolean isAuthenticated();
	void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}

인증 자체를 표현하는 인터페이스. 인증을 위해 생성되는 인증 토큰 또는 인증 성공 후 생성되는 토큰은 UsernamePasswordAuthenticationToken과 같은 하위 클래스의 형태로 생성되지만 생성된 토큰을 리턴 받거나 SecurityContext에 저장될 경우에 Authentication 형태로 리턴 받거나 저장된다. Authentication 인터페이스를 구현하는 클래스는 세 가지 정보를 가진다.

  • Principle
    사용자를 식별하는 고유 정보. Username/Password 기반 인증에서 Username이 Principal이 되며, 다른 인증 방식에서는 UserDetails 가 Principal이 된다.
  • Credentials
    사용자 인증에 필요한 Password. 인증이 이뤄지고 난 후, ProviderManager 가 해당 Credentials를 삭제한다.
  • Authorities
    AuthenticationProvider에 의해 부여된 사용자의 접근 권한 목록

AuthenticationManager

public interface AuthenticationManager {

	Authentication authenticate(Authentication authentication) throws AuthenticationException;

}

인증 처리를 총괄하는 매니저 역할을 하는 인터페이스. 인증을 위한 Filter는 AuthenticationManager를 통해 느슨한 결합을 유지하고 있으며, 인증을 위한 실질적인 관리는 AuthenticationManager를 구현하는 구현 클래스를 통해 이뤄진다.

ProviderManager

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {

  // ProviderManager 클래스가 Bean으로 등록 시, List<AuthenticationProvider> 객체를 DI 받음
	public ProviderManager(List<AuthenticationProvider> providers, AuthenticationManager parent) {
		Assert.notNull(providers, "providers list cannot be null");
		this.providers = providers;
		this.parent = parent;
		checkState();
	}

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

    // 적절한 AuthenticationProvider를 찾기
		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 {
            //AuthenticationProvider에게 인증 처리를 위임
				result = provider.authenticate(authentication);
				if (result != null) {
					copyDetails(authentication, result);
					break;
				}
			}
			catch (AccountStatusException | InternalAuthenticationServiceException ex) {
				prepareException(ex, authentication);
				throw ex;
			}
			catch (AuthenticationException ex) {
				lastException = ex;
			}
		}

		if (result != null) {
			if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
            // 정상적으로 인증 처리 후, 인증에 사용된 Credentials를 제거
				((CredentialsContainer) result).eraseCredentials(); 
			}

			if (parentResult == null) {
				this.eventPublisher.publishAuthenticationSuccess(result);
			}

			return result;
		}
	}
}

AuthenticationProvider를 관리하고, AuthenticationProvider에게 인증 처리를 위임하는 역할

AuthenticationProvider

// 실질적인 인증 처리를 위해 authenticate() 메서드를 사용하기 위한 상속
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {

	private PasswordEncoder passwordEncoder;


  // UserDetailsService로부터 UserDetails를 조회하는 역할
  // 조회된 UserDetails는 사용자를 인증하는데 사용될 뿐만 아니라 인증에 성공할 경우, 인증된 Authentication 객체를 생성하는데 사용
	@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);
		}
	}

  // PasswordEncoder를 이용해 사용자의 패스워드를 검증
	@Override
	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())) { // 클라이언트 정보와 DB정보가 일치하는지 검증
			this.logger.debug("Failed to authenticate since password does not match stored value");
			throw new BadCredentialsException(this.messages
					.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
		}
	}
}

AuthenticationManager로부터 인증 처리를 위임 받아 실질적인 인증 수행을 담당하는 컴포넌트. Username/Password 기반의 인증 처리는 DaoAuthenticationProvider가 담당하고 있으며, DaoAuthenticationProvider는 UserDetailsService로부터 전달 받은 UserDetails를 이용해 인증을 처리한다.

UserDetails

public interface UserDetails extends Serializable {

	Collection<? extends GrantedAuthority> getAuthorities(); 
	String getPassword(); 
	String getUsername(); 

	boolean isAccountNonExpired();  // 사용자 계정 만료 여부
	boolean isAccountNonLocked();   // 사용자 계정 lock 여부
	boolean isCredentialsNonExpired(); // 크리덴셜 만료 여부
	boolean isEnabled();               // 사용자의 활성화 여부
}

DB에 저장된 사용자의 Username과 사용자의 자격을 증명해주는 패스워드, 그리고 사용자의 권한 정보를 포함하는 컴포넌트

UserDetailsService

public interface UserDetailsService {
	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

UserDetails를 로드하는 핵심 인터페이스 loadUserByUsername(String username)을 통해 사용자의 정보를 로드한다.

SecurityContextHolder

public class SecurityContextHolder {


  private static SecurityContextHolderStrategy strategy;
  
  // 현재 실행 쓰레드에서 SecurityContext를 얻음
	public static SecurityContext getContext() {
		return strategy.getContext();
	}


  // 현재 실행 쓰레드에 SecurityContext를 연결
	public static void setContext(SecurityContext context) {
		strategy.setContext(context);
	}

}

인증된 Authentication 객체를 저장하는 컴포넌트인 SecurityContext를 관리하는 역할. SecurityContextHolder에 의해 SecurityContext에 값이 채워져 있다면 Spring Security는 인증된 사용자로 간주한다.

profile
BE 개발자를 꿈꾸는 대학생

0개의 댓글