Spring Security 인증 동작 방식에 대해 알아보자

3

들어가며

안녕하세요. 오랜만에 글을 작성하는데요. 이번 포스팅도 또큐리티입니다.

현재 저는 저희 회사의 솔루션 API 관리기 배포판을 만들고 있습니다.

우선 이 애플리케이션은 각각의 고객사에 배포를 목적으로 만들고 있습니다.

버그 수정이나 패치는 버전 업데이트를 통해 이루어져야하므로 설치 및 실행이 간편해야 하다보니

스프링 부트 기반 모놀리식 아키텍처로 구성하여, SPA 프레임워크, 라이브러리들을 사용하지 않고, 템플릿 엔진인 타임리프로 UI를 구성했습니다.

배포판이기 때문에, 기본적으로 시스템 계정이 포함되어야 하는데요.

구성 파일을 관리하고 관리자 계정 관련 프로퍼티를 바인딩하여 시스템 계정을 생성하기로 했습니다.

이번에는 토큰 인증 방식이 아닌 세션 인증 방식으로 구현하게 되었습니다.

(사실 패스워드 만료 처리하는 과정 글을 쓰려고 준비하다가 딴길로 새서 노선을 바꿨습니다...)

진짜 시큐리티는 이제 좀 잘 알지 않나 싶다가도 알면 알수록 모르는게 산더미입니다.

인증이 어떻게 돌아가는지 다시 한번 정리해봤습니다.

Spring Security Authentication Architecture

https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html

우선 레퍼런스에 있는 Authentication Arcithecture 를 가져왔습니다. 다른 부분은 생략하고 AbstractAuthenticationProcessingFilter 부분만 알아보겠습니다.

시큐리티는 FilterOrderRegistration 에 의해 등록된 필터들이 순차적으로 돌아가게 되는데요. 기본적으로 14개의 필터가 등록되게 됩니다.

폼 로그인의 기본적인 동작 방식은 UsernamePasswordAuthenticationFilter가 담당하여 처리하게 됩니다.

이 클래스는 필터 기능을 담당하는 부분이 부모 클래스인 AbstractAuthenticationProcessingFilter를 상속하여 인증 처리 대상인지 판단하고, 인증을 시도합니다.

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);
			}
			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);
		}
	}
    
    public abstract Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException, IOException, ServletException;

    ...
}  

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 메서드에서 AuthenticationManager 를 실행시키게 되는데요.

Spring Boot의 강력한 autoconfiguration에 의해 AuthenticationManagerBuilder가 동작하며, concrete classProviderManager가 주입됩니다.

ProviderManagerAuthenticationProvider 를 레지스트리로 가지고 있고,
AuthenticationProvider의 concrete classDaoAuthenticationProvider 가 주입됩니다.

DaoAuthenticationProvider 가 무엇이냐면

많이들 아시는 UserDetailsService, UserDetails 에 대해 처리해주는 녀석입니다.

@Slf4j
@Service
@RequiredArgsConstructor
public class UserDetailsLoader implements UserDetailsService {

    private final UserJpaRepository userJpaRepository;

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("authentication request: {}", username);
        UserEntity found = userJpaRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("아이디 또는 비밀번호가 일치하지 않습니다."));
        return mapToUserDetails(found);
    }

    public static UserDetails mapToUserDetails(UserEntity userEntity) {
        return User.builder()
                .username(userEntity.getUsername())
                .password(userEntity.getPassword())
                .roles(userEntity.getAuthority().name())
                .accountExpired(userEntity.isAccountExpired())
                .accountLocked(userEntity.isAccountLocked())
                .credentialsExpired(userEntity.isCredentialsExpired())
                .disabled(userEntity.isDisabled())
                .build();
    }

}

UserDetailsService 를 정의해주면 DaoAuthenticationProviderretrieveUser 함수에 의해 인보크 되어 실제 저장된 사용자 정보를 가지고 검증을 하게 됩니다.

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider {
	private UserDetailsService userDetailsService;

	private UserDetailsPasswordService userDetailsPasswordService;

	private CompromisedPasswordChecker compromisedPasswordChecker;

	@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) {
		String presentedPassword = authentication.getCredentials().toString();
		boolean isPasswordCompromised = this.compromisedPasswordChecker != null
				&& this.compromisedPasswordChecker.check(presentedPassword).isCompromised();
		if (isPasswordCompromised) {
			throw new CompromisedPasswordException("The provided password is compromised, please change your password");
		}
		boolean upgradeEncoding = this.userDetailsPasswordService != null
				&& this.passwordEncoder.upgradeEncoding(user.getPassword());
		if (upgradeEncoding) {
			String newPassword = this.passwordEncoder.encode(presentedPassword);
			user = this.userDetailsPasswordService.updatePassword(user, newPassword);
		}
		return super.createSuccessAuthentication(principal, authentication, user);
	}
	...
}

ProviderManager의 일부 코드

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

특이한 점은 ProviderManager에서 인증되는 방식인데요.

현재 AuthenticationManager에서 Authentication 객체를 만들어내지 못하면 부모의 AuthenticationManager에게 인증을 위임합니다. 책임연쇄 패턴이 적용되어있는데요.
거슬러 올라가는 점이 신선했습니다.

첫 시도는 DaoAuthenticationProvider가 동작하지 않습니다.

기본적으로 시큐리티는 처음 서버에 접근하면 모든 요청은 익명 사용자로 판단해야되기 때문입니다.

기본적으로 시큐리티의 인증방식에 적용된 COR 패턴을 통과시켜야하기에 AnonymousAuthenticationProvider가 우선적으로 깔고 가야 됩니다.

UsernamePasswordAuthenticationToken을 처리할 수 있는 provider는 parent에 있는 ProviderManager Instance를 통해 인증을 진행하게 됩니다.

이렇듯 기본적으로 ProviderManager에 AuthenticationProvider를 등록하여 Default Authentication Provider를 만들어 놓거나, 책임 연쇄 패턴을 적용해서 레지스트리에 적용한다면
인증을 여러단계에 걸쳐서 할 수 있습니다.

세션 기반 인증은 서버 자체가 멀티 인스턴스로 넘어가면서 세션 클러스터링 비용보다, 인증 만료 처리라던지, 인증에 대한 부가적인 처리가 쉬운 토큰 인증방식으로 많이 넘어가 있는데요. 이번 기회에 조금 더 시큐리티와 가까워진 것 같습니다.

다음 글에서는 원래 하려고 했었던 인증 후 부가적인 처리에 대해 실제 코드를 가지고 정리해보겠습니다.

긴 글 읽어주셔서 감사합니다.

0개의 댓글