Spring Security - Persistance

2024. 7. 13. 02:43스프링 (Spring)/스프링 보안 (Spring Security)

본 장은 아래 문서를 참고해서 작성했습니다.

 

Persisting Authentication :: Spring Security

The first time a user requests a protected resource, they are prompted for credentials. One of the most common ways to prompt for credentials is to redirect the user to a log in page. A summarized HTTP exchange for an unauthenticated user requesting a prot

docs.spring.io

 


Session

 

세션은 일정 기간 또는 어떤 일을 완료할 때까지 유지되는 사용자 행동 단위입니다. (출처 : wikipedia)

 

예를 들어 메일함에 있는 메일을 확인하기 위해서는 본인 인증 과정을 거쳐야 합니다. (통칭 로그인)

세션이 없다면 메일함에 있는 메일을 누를 때마다 매번 로그인을 해야겠죠.

만약 1시간 단위의 세션이 있다면, 처음 로그인 한 후 1시간 동안은 메일을 자유롭게 확인할 수 있습니다.

 

Spring Security에서는 기본적으로 이러한 세션을 다루고 있습니다.

상식적으로 생각하기로는 당연히 세션을 식별할 키(key)가 있어야 겠고

세션이 언제 끝나는 지, 그리고 로그아웃 같은 액션을 통해 세션을 닫아야 할 수도 있겠죠.

 

하지만 가장 중요한 건, 세션을 통해 어떤 사용자인지 알 수 있어야 한다는 것입니다.

내가 메일을 작성할 때 자동으로 '보낸 사람'에 본인의 이메일이 등장하는 것과 마찬가지죠.

즉, 세션의 생명주기를 관리할 뿐만 아니라 이에 수반되는 사용자 정보도 함께 저장하거나 읽을 수 있어야 합니다.

 

따라서 이 글의 주제를 바라볼 때, 세션에만 국한된 것이 아니라 요청에 담겨져 있는 정보로 확장해주면 편합니다.

 

 

 

 

 

 

 


Persistence

 

필자가 말하고자 하는 영속성(persistence)이 무엇인지 예시를 통해 살펴보죠.

(출처 : Spring Security - SAMPLES)

로그인에 성공한 후, 페이지를 옮겨도 인증 정보가 그대로 남아있음을 알 수 있습니다.

그리고 한 번 인증에 성공하면 보안 페이지를 계속 방문할 수 있죠.

 

따라서 로그인에 성공했을 때 사용자 인증 정보가 어딘가 저장되고, 매 페이지(요청)마다 재사용되고 있음이 틀림없습니다.

이 메커니즘은 이해하기 조금 까다로우므로 천천히 읽으시길 권장드립니다.

 

When is configured?

뭐가 되었든 Spring Security - Servlet 글에서 소개한 구조를 벗어나지 않습니다.

모든 건 FilterChain에서 등록되기 때문이죠.

따라서 예제에서 SecurityConfig.kt를 방문해보면 HttpSecurity라는 빌더가 보입니다.

package org.springframework.security.samples.config

import ...

class SecurityConfig {

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            authorizeRequests {
                authorize("/css/**", permitAll)
                authorize("/user/**", hasAuthority("ROLE_USER"))
            }
            formLogin {
                loginPage = "/log-in"
            }
        }
        return http.build()
    }
    
    ...

 

Bean 함수를 보면 아래와 같죠.

package org.springframework.security.config.annotation.web.configuration;

import ...

@Configuration(proxyBeanMethods = false)
class HttpSecurityConfiguration {
    ...
    
    @Bean(HTTPSECURITY_BEAN_NAME)
    @Scope("prototype")
    HttpSecurity httpSecurity() throws Exception {
        ...
        
        http
            .csrf(withDefaults())
            .addFilter(webAsyncManagerIntegrationFilter)
            .exceptionHandling(withDefaults())
            .headers(withDefaults())
            .sessionManagement(withDefaults())
            .securityContext(withDefaults())
            .requestCache(withDefaults())
            .anonymous(withDefaults())
            .servletApi(withDefaults())
            .apply(new DefaultLoginPageConfigurer<>());
        http.logout(withDefaults());
        applyCorsIfAvailable(http);
        applyDefaultConfigurers(http);
        return http;
    }
    
    ...

 

아직은 기본값들을 일일이 보지 않아도 됩니다.

결과론적으로, 빌드된 필터를 보기위해 다음과 같이 filterChain() 함수의 마지막 줄을 아래처럼 고치고 실행해봅니다.

package org.springframework.security.samples.config

import ...

class SecurityConfig {

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        ...

        val httpFilterChain: DefaultSecurityFilterChain = http.build()
        httpFilterChain.filters.map { filter ->
            println(filter)
        }
        return http.build()
    }
    
    ...

 

결과를 보면 아래처럼 필터들이 출력됩니다.

org.springframework.security.web.session.DisableEncodeUrlFilter@5b47731f
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@233db8e9
org.springframework.security.web.context.SecurityContextHolderFilter@68d6d775
org.springframework.security.web.header.HeaderWriterFilter@63300c4b
org.springframework.security.web.csrf.CsrfFilter@6ef60295
org.springframework.security.web.authentication.logout.LogoutFilter@d675f9f
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@7e4579c7
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@45c9b3
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@38b3f208
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@40c2ce52
org.springframework.security.web.access.ExceptionTranslationFilter@2577a95d
org.springframework.security.web.access.intercept.FilterSecurityInterceptor@66a5755

 

눈치챘겠지만, 굵은 글씨로 표현된 두 필터가 영속성의 핵심 역할입니다.

이 두 필터는 http.build() 함수가 실행될 때, 아래 doBuild() 함수 호출로 인해 등록됩니다.

 

package org.springframework.security.config.annotation;

import ...

public abstract class AbstractConfiguredSecurityBuilder<O, B extends SecurityBuilder<O>>
        extends AbstractSecurityBuilder<O> {
    ...
    
    @Override
    protected final O doBuild() throws Exception {
        synchronized (this.configurers) {
            this.buildState = BuildState.INITIALIZING;
            beforeInit();
            init();
            this.buildState = BuildState.CONFIGURING;
            beforeConfigure();
            configure();
            this.buildState = BuildState.BUILDING;
            O result = performBuild();
            this.buildState = BuildState.BUILT;
            return result;
        }
    }
    
    ...

 

 

 

How save the authentication?

인증 정보를 저장하기 위해서는, 일단 인증 정보라는게 있어야 겠죠?

그리고 이건 로그인에 성공할 때 생길 것이라는 건 상식적으로 알고 있습니다.

그래서 UsernamePasswordAuthenticationFilter를 봅니다.

 

부모 클래스인 AbstractAuthenticationProcessingFilter 추상 클래스의 doFilter() 함수를 보면,

인증 결과에 따라 successfulAuthentication() 또는 unsuccessfulAuthentication() 함수를 호출합니다.

package org.springframework.security.web.authentication;

import ...

public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
        implements ApplicationEventPublisherAware, MessageSourceAware {
    ...
        
    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);
        }
    }
    
    ...

 

그리고 타고 내려가다보면, 구현부를 볼 수 있습니다.

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

 

중요한 라인은 바로 this.securityContextRepository.saveContext(context, request, response); 입니다.

해당 SecurityContextRepository에 SecurityContext가 저장되기 때문이죠.

 

이 Repository 변수는 기본적으로 DelegatingSecurityContextRepository 입니다.

어렵게 생각할 필요 없이 2개 이상의 동일한 SecurityContextRepository를 리스트(List)로 가지고

saveContext, loadContext 등의 함수를 호출하면 for 반복문을 통해 리스트 함수를 똑같이 호출해주는 겁니다.

package org.springframework.security.web.context;

import ...

public final class DelegatingSecurityContextRepository implements SecurityContextRepository {
    ...
    
    @Override
    public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
        SecurityContext result = null;
        for (SecurityContextRepository delegate : this.delegates) {
            SecurityContext delegateResult = delegate.loadContext(requestResponseHolder);
            if (result == null || delegate.containsContext(requestResponseHolder.getRequest())) {
                result = delegateResult;
            }
        }
        return result;
    }
    
    @Override
    public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
        for (SecurityContextRepository delegate : this.delegates) {
            delegate.saveContext(context, request, response);
        }
    }
    
    ...

 

여튼 이 delegates는 HttpSessionSecurityContextRepository, RequestAttributeSecurityContextRepository 입니다.

중요한 건 HttpSessionSecurityContextRepository 클래스의 saveContext() 함수인데,

package org.springframework.security.web.context;

import ...

public class HttpSessionSecurityContextRepository implements SecurityContextRepository {
    ...
    
    @Override
    public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
        SaveContextOnUpdateOrErrorResponseWrapper responseWrapper = WebUtils.getNativeResponse(response,
                SaveContextOnUpdateOrErrorResponseWrapper.class);
        if (responseWrapper == null) {
            saveContextInHttpSession(context, request);
            return;
        }
        responseWrapper.saveContext(context);
    }

    private void saveContextInHttpSession(SecurityContext context, HttpServletRequest request) {
        if (isTransient(context) || isTransient(context.getAuthentication())) {
            return;
        }
        SecurityContext emptyContext = generateNewContext();
        if (emptyContext.equals(context)) {
            HttpSession session = request.getSession(false);
            removeContextFromSession(context, session);
        }
        else {
            boolean createSession = this.allowSessionCreation;
            HttpSession session = request.getSession(createSession);
            setContextInSession(context, session);
        }
    }
    
    ...

 

saveContextInHttpSession() 함수의 else 부분을 보면 session을 가져와 SecurityContext를 저장합니다.

 

한 가지 재밌는 점은, 여기 어디에도 캐싱이나 Map 같이 세션을 저장하는 코드가 없다는 것입니다.

(사실 재밌다는 건 거짓말이고... 필자가 가장 헷갈렸던 부분이었네요.)

 

그럼에도 불구하고 우리가 페이지를 옮겨다녀도 세션이 유지되는 이유는,

웹 서버가 요청의 HttpSession 객체를 재사용하기 때문입니다. (= 매번 생성하지 않습니다)

따라서 HttpSession 객체가 유지되는 동안에는 영속성을 가질 수 있었습니다.

 

 

How load the authentication?

HttpSessionSecurityContextRepository에서 SecurityContext를 저장했다면, 로드하는 것도 마찬가지겠죠.

package org.springframework.security.web.context;

import ...

public class HttpSessionSecurityContextRepository implements SecurityContextRepository {
    ...
    
    @Override
    public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
        HttpServletRequest request = requestResponseHolder.getRequest();
        HttpServletResponse response = requestResponseHolder.getResponse();
        HttpSession httpSession = request.getSession(false);
        SecurityContext context = readSecurityContextFromSession(httpSession);
        if (context == null) {
            context = generateNewContext();
            if (this.logger.isTraceEnabled()) {
                this.logger.trace(LogMessage.format("Created %s", context));
            }
        }
        if (response != null) {
            SaveToSessionResponseWrapper wrappedResponse = new SaveToSessionResponseWrapper(response, request,
                    httpSession != null, context);
            wrappedResponse.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
            requestResponseHolder.setResponse(wrappedResponse);
            requestResponseHolder.setRequest(new SaveToSessionRequestWrapper(request, wrappedResponse));
        }
        return context;
    }


request.getSession(false) 함수를 통해 현재 요청에서 세션을 불러오고,

readSecurityContextFromSession(httpSession)을 통해 SecurityContext를 불러옵니다.

 

그리고 이 로직은 SecurityContextHolderFilter에 의해 이루어집니다.

package org.springframework.security.web.context;

import ...

public class SecurityContextHolderFilter extends GenericFilterBean {
    ...
    
    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        if (request.getAttribute(FILTER_APPLIED) != null) {
            chain.doFilter(request, response);
            return;
        }
        request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
        Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request);
        try {
            this.securityContextHolderStrategy.setDeferredContext(deferredContext);
            chain.doFilter(request, response);
        }
        finally {
            this.securityContextHolderStrategy.clearContext();
            request.removeAttribute(FILTER_APPLIED);
        }
    }
    
    ...

 

 

이 과정을 쉽게 도식화하면 아래와 같습니다.

Spring Security / Servlet Applications / Authentication / Persistence - SecurityContextHolderFilter

 

 

 

 

 

 

 


Stateless Session

 

세션을 유지할 필요가 없을 때, 즉 세션에 상태값을 저장하지 않고 싶을 때

SessionCreationPolicy.STATELESS 를 적용합니다.

...
class SecurityConfig {

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain {
        http {
            ...
            sessionManagement {
                sessionCreationPolicy = SessionCreationPolicy.STATELESS
            }
        }
        return http.build()
    }

 

이렇게 만들면 사용자는 매요청마다 재인증을 해야됩니다.

 

이는 일전에 소개했던 HttpSessionSecurityContextRepository를 사용하지 않고,

NullSecurityContextRepository를 사용하기 때문인데

package org.springframework.security.web.context;

import

public final class NullSecurityContextRepository implements SecurityContextRepository {
    ...
    
    @Override
    public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
        return this.securityContextHolderStrategy.createEmptyContext();
    }

    @Override
    public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
    }
    
    ...

 

loadContext(), saveContext() 함수를 보면 아무것도 하지 않는 걸 알 수 있습니다.

 

 

 

 

 

 

 

 


Logout

 

우리가 익히 아는로그아웃이 맞습니다. 사용자 인증 정보를 깔끔히 지우는 일이죠.

이는 LogoutFilter에서 수행합니다.

doFilter() 내용을 보면, this.handler.logout(...) 을 통해 핵심 로직이 동작하는데,

package org.springframework.security.web.authentication.logout;

import ...

public class LogoutFilter extends GenericFilterBean {
    ...
    
    private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        if (requiresLogout(request, response)) {
            Authentication auth = this.securityContextHolderStrategy.getContext().getAuthentication();
            if (this.logger.isDebugEnabled()) {
                this.logger.debug(LogMessage.format("Logging out [%s]", auth));
            }
            this.handler.logout(request, response, auth);
            this.logoutSuccessHandler.onLogoutSuccess(request, response, auth);
            return;
        }
        chain.doFilter(request, response);
    }
    
    ...

 

여기 hander는 CompositeLogoutHandler로 일전의 DelegatingSecurityContextRepository와 마찬가지로

2개 이상의 LogoutHandler를 for 반복문을 통해 동작하도록 하는 것입니다.

 

모두 나열하기는 어렵고, 대표적으로 SecurityContextLogoutHandler를 보자면,

package org.springframework.security.web.authentication.logout;

import ...

public class SecurityContextLogoutHandler implements LogoutHandler {
    ...
    
    @Override
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        ...
        SecurityContext context = this.securityContextHolderStrategy.getContext();
        this.securityContextHolderStrategy.clearContext();
        if (this.clearAuthentication) {
            context.setAuthentication(null);
        }
        SecurityContext emptyContext = this.securityContextHolderStrategy.createEmptyContext();
        this.securityContextRepository.saveContext(emptyContext, request, response);
    }
    
    ...

 

이미 있던 SecurityContext를 가져와 깔끔히 지우는 걸 확인할 수 있습니다.