스프링 시큐리티 설정은 어떻게 작동하나

점돌이·2024년 3월 12일
0

서론

스프링 시큐리티는 보안 기능을 구현한 방대한 프레임워크이다.
처음 공부할 때 상당히 어렵게 느껴지는데 그 이유는 서비스에 맞게 커스텀이 가능하도록 추상화 수준이 상대적으로 낮다.
개인적으로 직접 디버그 포인트 찍어가며 구현을 살펴보지 않으면 정확히 알 수 없었다.
사용자 인증 흐름을 알더라도 자동으로 설정되는 부분이 많고 커스텀하려면 설정이 어떤걸 손대야하는지 막막한 상황이 생기기도 했다.
직접 나름대로 설정을 뜯어보며 공부해본바를 공유해보려한다.

기본설정

일단 스프링시큐리티6.2 버전임을 알린다.
스프링 시큐리티 의존성을 받기만 해도 기본적으로 폼로그인 방식으로 시큐리티가 작동하는걸 많이 봤을것이다.
이를 살펴보기위해 팁을 주자면 AbstractSecurityBuilder 클래스를 상속받아서 설정이 이루어진다.

AbstractSecurityBuilderbuild() 메소드에 디버그 포인트를 찍어 어떤 클래스에서 호출하는 보면서 파악하면 흐름을 추적할 수 있다.

SpringBootWebSecurityConfiguration 클래스를 확인해보자.

	@Configuration(proxyBeanMethods = false)
	@ConditionalOnDefaultWebSecurity
	static class SecurityFilterChainConfiguration {

		@Bean
		@Order(SecurityProperties.BASIC_AUTH_ORDER)
		SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
			http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated());
			http.formLogin(withDefaults());
			http.httpBasic(withDefaults());
			return http.build();
		}

	}

이너 클래스로 기본적인 동작방식을 정의해놓은것을 볼 수 있다.
HttpSecurity 객체를 빈으로 주입받고 있다.
그렇다면 시큐리티 설정을 담당하는 HttpSecurty 클래스는 어떻게 설정되는지를 확인해봐야한다.
HttpSecurityConfiguration 클래스를 보면 HttpSecurty객체를 빈으로 등록한걸 볼 수 있다.

	@Bean(HTTPSECURITY_BEAN_NAME)
	@Scope("prototype")
	HttpSecurity httpSecurity() throws Exception {
		LazyPasswordEncoder passwordEncoder = new LazyPasswordEncoder(this.context);
		AuthenticationManagerBuilder authenticationBuilder = new DefaultPasswordEncoderAuthenticationManagerBuilder(
				this.objectPostProcessor, passwordEncoder);
		authenticationBuilder.parentAuthenticationManager(authenticationManager());
		authenticationBuilder.authenticationEventPublisher(getAuthenticationEventPublisher());
		HttpSecurity http = new HttpSecurity(this.objectPostProcessor, authenticationBuilder, createSharedObjects());
		WebAsyncManagerIntegrationFilter webAsyncManagerIntegrationFilter = new WebAsyncManagerIntegrationFilter();
		webAsyncManagerIntegrationFilter.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
		// @formatter:off
		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());
		// @formatter:on
		applyCorsIfAvailable(http);
		applyDefaultConfigurers(http);
		return http;
	}

기본적인 설정객체들을 셋팅 하고있다.
또 빈스코프가 prototype으로 설정되어 있다. 빈으로 요청할 때 마다 기본설정이 된 객체를 반환한다.
메서드 체이닝을 이루어진걸 보면 각 메서드가 HttpSecurity를 반환한다는것을 알 수 있다.
그럼 HttpSecurity는 어떻게 생겼길래 이렇게 설정을 할 수 있는지 살펴보자.

public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<DefaultSecurityFilterChain, HttpSecurity>
		implements SecurityBuilder<DefaultSecurityFilterChain>, HttpSecurityBuilder<HttpSecurity> {

AbstractConfiguredSecurityBuilder클래스를 상속한걸 알 수 있다.
즉 설정부분을 책임지는 클래스인것을 알 수 있다.

public abstract class AbstractConfiguredSecurityBuilder<O, B extends SecurityBuilder<O>>
		extends AbstractSecurityBuilder<O> {

	private final LinkedHashMap<Class<? extends SecurityConfigurer<O, B>>, List<SecurityConfigurer<O, B>>> configurers = new LinkedHashMap<>();

	private final List<SecurityConfigurer<O, B>> configurersAddedInInitializing = new ArrayList<>();

	private final Map<Class<?>, Object> sharedObjects = new HashMap<>();

	private ObjectPostProcessor<Object> objectPostProcessor;
    }

변수들을 확인해보면 설정들을 저장하는 변수, 초기화된 설정들을 저장하는 변수, 설정내에서 공유되는 객체를 저장하는 변수, 설정이 된 후에 설정할 객체를 처리를 담당 변수가 있다.
그렇다면 sessionManagement() 메소드를 타고 세션 설정을 어떻게 하는지 들어가보자.

public final class SessionManagementConfigurer<H extends HttpSecurityBuilder<H>>
		extends AbstractHttpConfigurer<SessionManagementConfigurer<H>, H> {}

HttpSecurityBuilder 타입을 받고 AbstractHttpConfigurer클래스를 상속받고 있다.
해당 클래스를 확인해보면

public abstract class AbstractHttpConfigurer<T extends AbstractHttpConfigurer<T, B>, B extends HttpSecurityBuilder<B>>
		extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, B> {

	private SecurityContextHolderStrategy securityContextHolderStrategy;
    
}
    

인증객체 저장 전략을 변수로 가지고 있고 SecurityConfigurerAdapter를 상속 받고 있다.
마저 확인해보자.

public abstract class SecurityConfigurerAdapter<O, B extends SecurityBuilder<O>> implements SecurityConfigurer<O, B> {

	private B securityBuilder;

	private CompositeObjectPostProcessor objectPostProcessor = new CompositeObjectPostProcessor();
    
    @Override
	public void init(B builder) throws Exception {
	}

	@Override
	public void configure(B builder) throws Exception {
	}
    
    protected <T> T postProcess(T object) {
		return (T) this.objectPostProcessor.postProcess(object);
	}

}

시큐리티 빌더를 저장하는 변수, 여러 설정 된 후 객체를 저장하는 변수를 가지고 있고
SecurityConfigurer의 추상 메소드인 초기화를 담당하는 init과 설정을 담당하는 configure과 설정된 후 객체를 저장하는 postProcess를 가지고 있다.

요약하자면 모든 시큐리티 설정 객체들은 초기화는 init으로 설정은 configure에서 진행된다.

다이어그램으로 확인해보면 이런 모양새다.

SessionManagement 설정되는 과정

길었던 시큐리티 설정객체 생김새를 알아보았다.
어떤식으로 설정이 되는지 세션관리 부분만 확인해 보려한다.
모든 설정객체들은 init에는 초기화 configure에는 설정이 들어간다했다.
그럼 어떤 순서로 작동할까?
AbstractConfiguredSecurityBuilderadd()doBuild() 매소드를 보면 알 수 있다.

	private <C extends SecurityConfigurer<O, B>> void add(C configurer) {
		Assert.notNull(configurer, "configurer cannot be null");
		Class<? extends SecurityConfigurer<O, B>> clazz = (Class<? extends SecurityConfigurer<O, B>>) configurer
			.getClass();
		synchronized (this.configurers) {
			if (this.buildState.isConfigured()) {
				throw new IllegalStateException("Cannot apply " + configurer + " to already built object");
			}
			List<SecurityConfigurer<O, B>> configs = null;
			if (this.allowConfigurersOfSameType) {
				configs = this.configurers.get(clazz);
			}
			configs = (configs != null) ? configs : new ArrayList<>(1);
			configs.add(configurer);
			this.configurers.put(clazz, configs);
			if (this.buildState.isInitializing()) {
				this.configurersAddedInInitializing.add(configurer);
			}
		}
	}
	@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;
		}
	}

순서는 다음과 같다.

  1. HttpSecurty의 apply()를 통해 Configurer들을 AbstractConfiguredSecurityBuilder의 add()을 호출해 저장한다.
  2. HttpSecurity가 build() 호출할 때 Configurer를 순회하며 init()을 한다.
  3. 다시 또 순회하며 configure()가 실행된다.

그럼 세션관리 설정이 어떤식으로 구현되어 있는지먼저 초기화 코드를 보며 간략히 살펴보자.

	@Override
	public void init(H http) {
		SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class);
		boolean stateless = isStateless();
		if (securityContextRepository == null) {
			if (stateless) {
				http.setSharedObject(SecurityContextRepository.class, new RequestAttributeSecurityContextRepository());
				this.sessionManagementSecurityContextRepository = new NullSecurityContextRepository();
			}
			else {
				HttpSessionSecurityContextRepository httpSecurityRepository = new HttpSessionSecurityContextRepository();
				httpSecurityRepository.setDisableUrlRewriting(!this.enableSessionUrlRewriting);
				httpSecurityRepository.setAllowSessionCreation(isAllowSessionCreation());
				AuthenticationTrustResolver trustResolver = http.getSharedObject(AuthenticationTrustResolver.class);
				if (trustResolver != null) {
					httpSecurityRepository.setTrustResolver(trustResolver);
				}
				this.sessionManagementSecurityContextRepository = httpSecurityRepository;
				DelegatingSecurityContextRepository defaultRepository = new DelegatingSecurityContextRepository(
						httpSecurityRepository, new RequestAttributeSecurityContextRepository());
				http.setSharedObject(SecurityContextRepository.class, defaultRepository);
			}
		}
		else {
			this.sessionManagementSecurityContextRepository = securityContextRepository;
		}
		RequestCache requestCache = http.getSharedObject(RequestCache.class);
		if (requestCache == null) {
			if (stateless) {
				http.setSharedObject(RequestCache.class, new NullRequestCache());
			}
		}
		http.setSharedObject(SessionAuthenticationStrategy.class, getSessionAuthenticationStrategy(http));
		http.setSharedObject(InvalidSessionStrategy.class, getInvalidSessionStrategy());
	}

첫 줄부터 공유객체를 통해 인증컨텍스트 저장소를 받는코드가 나온다.
그 다음 설정된 저장소가 유무 분기로 들어간다.

  1. 저장소가 없는 경우
    1.1 statless상태 즉 세션방식을 쓰지않을 경우다.
    RequestAttributeSecurityContextRepository를 인증컨텍스트 저장소를 공유객체에 설정한다.
    구현을 보면 이름에서 알 수 있듯이 흔히 아는 ServletRequest객체 속성값으로 인증 컨텍스트를 저장한다.
    다음 세션관리를 하지않으므로 저장소를 아무동작을 하지않는 NullSecurityContextRepository로 설정한다.

    1.2 세션을 사용할 경우다.
    인증컨텍스트 저장소를 HttpSessionSecurityContextRepository로 설정한다.
    구현을 살펴보면 알겠지만 이름처럼 세션을 이용해 인증객체를 저장한다.
    그 다음 세션을 만들게 할 것인지 등 세션정책들을 설정한다.
    그리고 DelegatingSecurityContextRepository 컨텍스트 저장소를 생성해서 인자로 HttpSessionSecurityContextRepositoryRequestAttributeSecurityContextRepository를 전달하고 공유객체에 넣어준다.
    보통 Delegating 이 단어가 나오면 해당 클래스내에 List나 Map을 변수로 가지고 있고 인자로 넘겨주면 등록하는 방식을 쓴다.

  2. 저장소가 있는 경우
    첫 줄에 선언되어 있는 HttpSessionSecurityContextRepository를 주입받는다.
    공유객체에서 RequestCache를 가져오고 없다면 세션을 사용안하는 경우에만 NullRequestCache를 공유객체에 넣어준다.
    그리고 공유객체에 세션 인증 전략, 무효화 전략을 넣어준다.

다음은 설정 구현코드를 보자.

	@Override
	public void configure(H http) {
		SessionManagementFilter sessionManagementFilter = createSessionManagementFilter(http);
		if (sessionManagementFilter != null) {
			http.addFilter(sessionManagementFilter);
		}
		if (isConcurrentSessionControlEnabled()) {
			ConcurrentSessionFilter concurrentSessionFilter = createConcurrencyFilter(http);

			concurrentSessionFilter = postProcess(concurrentSessionFilter);
			http.addFilter(concurrentSessionFilter);
		}
		if (!this.enableSessionUrlRewriting) {
			http.addFilter(new DisableEncodeUrlFilter());
		}
		if (this.sessionPolicy == SessionCreationPolicy.ALWAYS) {
			http.addFilter(new ForceEagerSessionCreationFilter());
		}
	}

세션관리 필터를 설정값에 따라 추가하는 로직이 들어가있다.
둘 다 설정값을 넣는건 똑같은거 같은데? 라는 생각이 들것이다.
둘의 차이는 공유 객체 shareObject이다.
일관된 설정값을 공유하기 위해 초기화 단계에서는 일부값들을 공유 객체에 저장하는걸 볼 수 있다.
그리고 설정할 때 공유객체를 통해 다른 Configurer들이 값을 가져다 일관된 정책을 유지한다.

만약에 커스텀 필터를 구현해서 시큐리티에서 사용하고 있는 설정값들을 적용하고 싶다면 Configuer를 구현해 공유객체로 값을 가져다 설정해주면된다.

예시 코드는 다음과 같다.

public class LoginFilterConfigurer extends AbstractHttpConfigurer<LoginFilterConfigurer, HttpSecurity> {
    private  AuthenticationSuccessHandler successHandler;
    private  AuthenticationFailureHandler failureHandler;
    private  AuthenticationManager authenticationManager;
    private ObjectMapper objectMapper;

    @Override
    public void init(HttpSecurity http) throws Exception {
       
    }

    @Override
    public void configure(HttpSecurity http) throws Exception {
      LoginAuthenticationFilter authFilter = new LoginAuthenticationFilter("/login", objectMapper);
      authFilter.setAuthenticationManager(authenticationManager);
      authFilter.setAuthenticationSuccessHandler(successHandler);
      authFilter.setAuthenticationFailureHandler(failureHandler);
      authFilter.setSecurityContextRepository(http.getSharedObject(SecurityContextRepository.class));
      authFilter.setSessionAuthenticationStrategy(http.getSharedObject(SessionAuthenticationStrategy.class));
      http.addFilterBefore(authFilter, UsernamePasswordAuthenticationFilter.class);
    }

    public LoginFilterConfigurer successHandler(AuthenticationSuccessHandler successHandler) {
        this.successHandler = successHandler;
        return this;
    }

    public LoginFilterConfigurer failureHandler(AuthenticationFailureHandler failureHandler) {
        this.failureHandler = failureHandler;
        return this;
    }

    public LoginFilterConfigurer authenticationManager(AuthenticationManager authenticationManager) {
        this.authenticationManager = authenticationManager;
        return this;
    }

    public LoginFilterConfigurer objectMapper(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
        return this;
    }

    public static LoginFilterConfigurer loginFilterConfigurer() {
        return new LoginFilterConfigurer();
    }
}
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    
     		http
              .formLogin(form -> form.disable())
              .httpBasic(basic -> basic.disable());
            http.with(LoginFilterConfigurer.loginFilterConfigurer(), dsl -> dsl
                .successHandler(authenticationSuccessHandler())
                .failureHandler(authenticationFailureHandler())
                .authenticationManager(authenticationManager())
                .objectMapper(objectMapper));
            
            return http.build();
}

공유 객체를 중심으로 다시 정리하면

  1. HttpSecurity객체를 통해 각 Configurer들이 설정값이 셋팅된채로 등록된다.
  2. HttpSecurity객체가 bulid()를 통해 각 Configurer들이 init()을 실행하는데 이 때 각 Configurer들에서 공유할 객체를 설정한다.
  3. 이후 configure()를 실행하며 Configurer들이 공유 객체를 통해 필요한 설정값들을 가져다 쓰며 일관된 설정을 하게된다.

마치며

커스텀 필터를 적용하다가 시큐리티 기본 설정값을 가져다 쓸 수 없을까 찾아보다가 Custom Dsl을 지원하는걸 알게되었다.
이를 계기로 설정들이 어떻게 이루어지는지 궁금해졌고 제대로 처음부터 디버깅해보며 공부해 보았다.
단순 흐름만 이해하고 있어 두리뭉술했는데 구현을 뜯어보며 선명해졌다.

profile
감사합니다.

0개의 댓글