Spring Security 디버깅

mainimg.jpg

Spring Security Filters

a2.png

스프링 프레임워크는 Tomcat 서블릿 컨테이너 위에서 돌아가므로 Tomcat의 서블릿 필터 기능에 의존한다. 스프링 시큐리티를 사용하게 되면 이 Tomcat의 FilterChain에 스프링 시큐리티 전용 필터를 하나 끼워넣어 모든 요청을 가로채는데, 이것이 바로 DelegatingFilterProxy이다. DelegatingFilterProxy는 기타 로직이 하나도 없이 단지 스프링 프레임워크 내부의 FilterChainProxy빈으로 요청을 넘겨주는 프록시 역할만을 할 뿐이다.

a3.png FilterChainProxy는 엔드 유저의 요청에 따라 스프링 시큐리티가 관리하는 SecurityFilterChain을 호출하여 요청을 거기로 프록시한다. SecurityFilterChain는 인증 및 인가와 관련된 여러 사항을 검증하고 적절한 응답을 반환하는 것을 목표로 한다.

a4.png SecurityFilterChain는 위 그림과 같이 여러개로 구성될 수 있으며, 엔드 유저의 요청 URL에 따라 FilterChainProxy가 적절한 SecurityFilterChain에게 프록시 해준다.

Default Spring Security Filter Chain

a5.png FilterChainProxy.class를 열어보면 다음과 같은 getFilters메서드를 확인할 수 있다. 이 메서드는 FilterChainProxy가 적절한 SecurityFilterChain빈을 가져오기 위해 사용한다. (do-while문 참조) 해당 메서드에서 Breakpoint를 걸고 서버로 요청을 보내면, 우리는 기본으로 등록된 필터들을 살펴볼 수 있다.

Unwrapping Security Filters

a6.png 해당 Breakpoint에서 우리는 filterChains의 size가 1임을 확인할 수 있다. 이는 Spring Security 의존성을 추가하면 기본적으로 하나의 인증 인가 필터만 등록되기 때문이다.

a7.png DefaultSecurityFilterChain을 열어보면 총 0~15까지 총 16개의 필터로 구성된 ArrayList를 확인할 수 있다. 이제부터는 이 필터들의 역할에 대해 알아볼 것이다.

예시: 보안이 필요한 페이지에 접근되었을 때, 로그인 페이지로 리다이렉션

  1. 사용자가 보호된 URL에 접근한다.
  2. AuthorizationFilter에서 접근이 거부된다.
  3. ExceptionTranslationFilter가 AccessDeniedException을 처리하고, AuthenticationEntryPoint를 통해 로그인 페이지로 리다이렉션된다.
  4. 클라이언트가 로그인 페이지로 리다이렉션된 요청을 보낸다.
  5. FilterChain을 처음부터 다시 시작한다.
  6. DefaultLoginPageGeneratingFilter가 로그인 페이지를 생성하여 응답으로 반환한다. 이후의 필터는 작동하지 않는다.

💡한 필터가 응답을 반환하면, 다음 필터들은 스킵된다.

AuthorizationFilter

우리는 스프링 시큐리티 필터 체인에서 중요한 몇가지 필터들을 직접 디버깅해보며 확인해볼건데, 가장 먼저 확인할 필터는 AuthorizationFilter이다. 해당 피렅의 책임은 엔드 유저가 접근하고자 하는 URL에 접근을 제한하는 것이다.
우리는 doFilter메서드의 try 부분에서 해당 로직을 확인할 수 있다.

public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws ServletException, IOException {
    HttpServletRequest request = (HttpServletRequest)servletRequest;
    HttpServletResponse response = (HttpServletResponse)servletResponse;
    if (this.observeOncePerRequest && this.isApplied(request)) {
        chain.doFilter(request, response);
    } else if (this.skipDispatch(request)) {
        chain.doFilter(request, response);
    } else {
        String alreadyFilteredAttributeName = this.getAlreadyFilteredAttributeName();
        request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
    
        try {
            AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request);
            this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
            if (decision != null && !decision.isGranted()) {
                throw new AccessDeniedException("Access Denied");
            }
    
            chain.doFilter(request, response);
        } finally {
            request.removeAttribute(alreadyFilteredAttributeName);
        }
    
    }
}

DefaultLoginPageGenerateFilter

a8.png 우리가 보안 URL에 접근하려고 하면 로그인 페이지가 표시되는 것을 알 수 있는데 그 페이지는 이 필터에서 생성한다. 이 페이지에서 엔드유저가 본인의 username, password와 같은 자격 증명을 입력하고 나면 다음 등장할 필터는 UsernamePasswordAuthenticationFilter이다.

UsernamePasswordAuthenticationFilter

💡Authentication 객체를 갱신하는 필터가 바로 이 필터이다.

이 필터 안에는 attemptAuthentication이라는 메서드가 있다. 이 필터의 주된 책임은 수신하는 http 요청으로부터 username과 password를 추출하는 것이다.

attemptAuthentication

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());
    } else {
        String username = this.obtainUsername(request);
        username = username != null ? username.trim() : "";
        String password = this.obtainPassword(request);
        password = password != null ? password : "";
        UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
        this.setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }
}

여기서는 Authentication객체가 아닌 UsernamePasswordAuthenticationToken가 등장하는데, 이는

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken
public abstract class AbstractAuthenticationToken implements Authentication, CredentialsContainer

위와 같이 UsernamePasswordAuthenticationTokenAuthentication객체의 구현체이기 때문이다.

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

위처럼 Authentication 은 인터페이스이다!

또, attempAuthentication에서드에서

return this.getAuthenticationManager().authenticate(authRequest);

이 부분을 보면, AuthenticationManager의 authenticate메서드를 호출하는 것을 볼 수 있는데,

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

AutehnticationManager또한 인터페이스이다.

a9.png attempAuthentication에서드의 return 문에 Breakpoint를 걸어 확인해보면 AuthenticationManager의 구현체로 ProviderManager가 등록된 것을 볼 수 있다.

a10.png 또, 갱신된 Authentication 객체(여기서는 UsernamePasswordAuthenticationToken)의 정보도 확인할 수 있다. 나같은 경우는 일부러 틀린 비밀번호를 입력하여 authenticated가 false로 나온다.

ProviderManager implements AuthenticationManager

우리는 앞서 AuthenticationManager의 구현체로 등록된 ProviderManager가 있음을 확인했다. ProviderManagerauthenticate메서드에서는

 while(var9.hasNext()) {
    AuthenticationProvider provider = (AuthenticationProvider)var9.next();
    if (provider.supports(toTest)) {
        if (logger.isTraceEnabled()) {
            Log var10000 = logger;
            String var10002 = provider.getClass().getSimpleName();
            ++currentPosition;
            var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size));
        }

        try {
            result = provider.authenticate(authentication);
            if (result != null) {
                this.copyDetails(authentication, result);
                break;
            }
        } catch (InternalAuthenticationServiceException | AccountStatusException var14) {
            this.prepareException(var14, authentication);
            throw var14;
        } catch (AuthenticationException var15) {
            lastException = var15;
        }
    }
}

위 반복문을 통해 모든 AuthenticationProviders를 호출하여 인증절차를 진행한다. (인증이 되는 AuthenticationProvider가 있을 때까지!)

DaoAuthenticationProvider

우리의 스프링 시큐리티 기본 흐름에 있어서 앞선 ProviderManagerAuthenticationProviders 중 하나를 호출하는데, 기본흐름에서는 이 AuthenticationProvider가 바로 DaoAuthenticationProvider이다. DaoAuthenticationProvider는 추상클래스 AbstractUserDetailsAuthenticationProvider를 상속하며, AbstractUserDetailsAuthenticationProvider에는 모든 실제 인증 로직이 포함된 authenticate 메서드를 확인할 수 있다.

authenticate method

public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> {
        return this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported");
    });
    String username = this.determineUsername(authentication);
    boolean cacheWasUsed = true;
    UserDetails user = this.userCache.getUserFromCache(username);
    if (user == null) {
        cacheWasUsed = false;

        try {
            user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
        } catch (UsernameNotFoundException var6) {
            this.logger.debug("Failed to find user '" + username + "'");
            if (!this.hideUserNotFoundExceptions) {
                throw var6;
            }

            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);
        this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
    } catch (AuthenticationException var7) {
        if (!cacheWasUsed) {
            throw var7;
        }

        cacheWasUsed = false;
        user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);
        this.preAuthenticationChecks.check(user);
        this.additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken)authentication);
    }

    this.postAuthenticationChecks.check(user);
    if (!cacheWasUsed) {
        this.userCache.putUserInCache(user);
    }

    Object principalToReturn = user;
    if (this.forcePrincipalAsString) {
        principalToReturn = user.getUsername();
    }

    return this.createSuccessAuthentication(principalToReturn, authentication, user);
}

로직에서 retrieveUser메서드를 사용하는데, 이 메서드는 AbstractUserDetailsAuthenticationProvider가 아닌, DaoAuthenticationProvider에 있다.

retrieveUser method

protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
    this.prepareTimingAttackProtection();

    try {
        UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
        if (loadedUser == null) {
            throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
        } else {
            return loadedUser;
        }
    } catch (UsernameNotFoundException var4) {
        this.mitigateAgainstTimingAttack(authentication);
        throw var4;
    } catch (InternalAuthenticationServiceException var5) {
        throw var5;
    } catch (Exception var6) {
        throw new InternalAuthenticationServiceException(var6.getMessage(), var6);
    }
}

해당 메서드 내부를 살펴보면, UserDetailsServicePasswordEncoder의 도움을 받는 것을 확인할 수 있다.

이렇게 생성된 Authentication객체는 BasicAuthenticationFilter애서 SecurityContext에 등록된다.

Summary

이번 포스팅에서는 Spring Security의 기본 흐름을 살펴보고 디버깅하면서 각 클래스와 메서드들의 유기적인 관계에 대해 큰 틀을 잡았다.