본문 바로가기
공부방

[TIL] 스프링 시큐리티 인증 아키텍처와 프로세스

by hseong 2023. 8. 6.

1. 스프링 시큐리티 인증 아키텍처

SecurityContextHolder

SecurityContextHolder는 스프링 시큐리티 인증 모델의 핵심입니다.

이것은 인증된 사용자에 대한 세부 정보(details)를 저장하는 곳입니다. 어떤 값이 포함되어 있든 간에 값이 포함되어 있기만 하면 인증된 사용자로 간주됩니다.

SecurityContextHolder는 전략 패턴을 사용합니다. SecurityContextHolderStrategy를 클래스 변수로 가지며 ThreadLocal을 사용하여 세부 정보를 저장하는 ThreadLocalSecurityContextHolderStrategy을 기본 전략으로 사용합니다.

final class ThreadLocalSecurityContextHolderStrategy implements SecurityContextHolderStrategy {

    private static final ThreadLocal<Supplier<SecurityContext>> contextHolder = new ThreadLocal<>();
    ...
}

ThreadLocal을 사용함으로써 동일한 쓰레드에서는 항상 SecurityContext를 사용할 수 있습니다.

ThreadLocal은 의도하지 않은 값의 참조가 발생하지 않도록 쓰레드가 쓰레드풀에 반환되기 전 변수 값을 반드시 제거해야 합니다. 그리고 스프링 시큐리티의 FilterChainProxy는 이러한 SecurityContext가 지워지는 것을 보장합니다.

FilterChainProxydoFilter 메서드의 finally 부분에서 securityContextHolderStrategyclearContext를 호출하는 것을 확인할 수 있습니다.

public class FilterChainProxy extends GenericFilterBean {
    ...
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
        if (!clearContext) {
            doFilterInternal(request, response, chain);
            return;
        }
        try {
            request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
            doFilterInternal(request, response, chain);
        } catch (Exception ex) {
            Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
            Throwable requestRejectedException = this.throwableAnalyzer
                    .getFirstThrowableOfType(RequestRejectedException.class, causeChain);
            if (!(requestRejectedException instanceof RequestRejectedException)) {
                throw ex;
            }
            this.requestRejectedHandler.handle((HttpServletRequest) request, (HttpServletResponse) response,
                    (RequestRejectedException) requestRejectedException);
        } finally {
            this.securityContextHolderStrategy.clearContext();
            request.removeAttribute(FILTER_APPLIED);
        }
    }
    ...
}

SecurityContext

SecurityContextSecurityContextHolder로부터 얻을 수 있습니다.

public class SecurityContextHolder {
...
    public static SecurityContext getContext() {
        return strategy.getContext();
    }
...
}

SecurityContext 자체는 어떤 특별할 기능도 제공하지 않습니다. 단순히 Authentication 객체를 포함하고 있을 뿐입니다.

public interface SecurityContext extends Serializable {

    Authentication getAuthentication();

    void setAuthentication(Authentication authentication);
}

Authentication

Authentication 인터페이스는 다음 세 가지를 포함합니다.

  • principal: 사용자를 식별합니다.
  • credentials: 비밀번호입니다. 사용자가 인증된 후에는 유출되지 않도록 지워집니다.
  • authorities: GrantedAuthority 인스턴스로 사용자에게 부여되는 상위 수준의 권한입니다.

Authentication의 구현체는 Token이라는 접미사가 붙으며 대표적으로 다음과 같은 것들이 있습니다.

  • AnonymousAuthenticationToken: 익명 사용자를 표현하기 구현체입니다.
  • UsernamePasswordAuthenticationToken: 로그인 아이디/비밀번호를 표현하기 위한 구현체입니다.
  • RememberMeAuthenticationToken: remember-me 기반 구현체입니다.

2. 인증 프로세스

AbstractAuthenticationProcessingFilter

사용자 인증을 처리하기 위한 필터입니다. 구현체로 UsernamePasswordAuthenticationFilter가 있습니다.

사용자의 인증을 위한 정보(credentials)를 취합하고 Authentication 객체를 생성합니다. 구현체에서는 로그인 아이디/비밀번호를 취합하고, Authentication 인터페이스의 구현체 중 하나인 UsernamePasswordAuthenticationToken 객체를 생성합니다.

HTTP 요청으로부터 파라미터를 뽑아내어 만들어진 Token은 최초 인증되지 않은 상태이며, GrantedAuthority 목록도 비어있는 상태입니다.

이는 AuthenticationManager 객체로 전달되어 인증 프로세스를 수행합니다.

인증이 정상적으로 완료된다면 새롭게 만들어진 Authentication 객체를 반환합니다. 이는 인증이 완료된 상태로 표현되며 GrantedAuthority 목록을 포함하고 있습니다.

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

AuthenticationManager

AuthenticationManager는 스프링 시큐리티의 필터가 사용자 인증을 수행하기 위한 API를 제공합니다.

public interface AuthenticationManager {

    Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

일반적인 구현체로는 ProviderManager가 있습니다.

ProviderManager

ProviderManagerAuthenticationProvider 목록에 인증 처리를 위임합니다. 각각의 AuthenticationProvider는 인증이 성공, 실패, 결정할 수 없음을 알리고 다음에 오는 AuthenticationProvider가 결정할 기회를 넘겨줍니다.

1개 이상의 AuthenticationProvider 구현체 중 어떤 구현체가 실제 인증을 처리할지 결정할 수 있습니다. 예를 들어 supports 메서드가 true를 반환하는 AuthenticationProvider 객체가 인증을 처리하게 되는데 UsernamePasswordAuthenticationToken 의 경우 DaoAuthenticationProvider가 인증을 처리하게 됩니다.

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
    ...
    private List<AuthenticationProvider> providers = Collections.emptyList();
    ...

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        ...
        Authentication result = null;
        ...
        for (AuthenticationProvider provider : getProviders()) {
            if (!provider.supports(toTest)) {
                continue;
            }
            ...
            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;
            }
        }
        ...
    }
    ...
}
public interface AuthenticationProvider {

    Authentication authenticate(Authentication authentication) throws AuthenticationException;

    boolean supports(Class<?> authentication);
}

또한 ProviderManager에 주어진 Authentication 객체에 대한 인증을 수행할 수 있는AuthenticationProvider가 없는 경우, 참조하고 있는 부모 AuthenticationManager(일반적으로 ProviderManager 인스턴스)에게 인증 처리를 위임할 수 있습니다.

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {

    ...
    private AuthenticationManager parent;
    ...
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        ...
        Authentication result = null;
        Authentication parentResult = null;
        ...
        for (AuthenticationProvider provider : getProviders()) {
            ...
        }
        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;
            }
        }
        ...
    }
}

만일 자신이 가지고 있는 AuthenticationProvider 목록을 돌아 인증을 완료했거나, 참고하고 있는 부모의 AuthenticationProvider 목록까지 다 돌고나서 인증을 완료한 Authentication 객체를 가지고 있다면 credential 을 지우는 작업을 수행합니다. 이후 인증되었고, 권한 목록을 가지고 있는 Authentication 객체를 반환하게 됩니다.

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
    ...
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        ...
        for (AuthenticationProvider provider : getProviders()) {
            ...
        }
        if (result == null && this.parent != null) {
            // Allow the parent to try.
            ...
        }
        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;
        }
    }
}

만일 인증에 실패한다면 AuthenticationException을 던지는 것으로 해당 로직이 종료됩니다.

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
    ...
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        ...
        AuthenticationException lastException = null;
        AuthenticationException parentException = null;
        ...
        for (AuthenticationProvider provider : getProviders()) {
            ...
        }
        if (result != null) {
            ...
        }
        // 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;
    }
}

참조

프로그래머스 데브코스 백엔드

Spring Security Docs

'공부방' 카테고리의 다른 글

SOLID 원칙  (0) 2024.03.23
CORS는 왜 필요할까?  (0) 2024.03.17
[TIL] 스프링 시큐리티 아키텍처  (0) 2023.08.05
[TIL] 스프링 시큐리티 Quick Start  (0) 2023.08.05
[TIL 07/05] Spring MVC, REST API  (0) 2023.07.07