Spring Security 기본 필터 [1 / 2]

junto·2024년 4월 6일
0

spring

목록 보기
9/30
post-thumbnail

Spring Security 기본 필터

  • build.gradle에 spring security 의존성을 추가하면, 다음과 같은 기본 필터들을 볼 수 있다. spring-security-core:6.2.3 기준으로 알아본다.
implementation 'org.springframework.boot:spring-boot-starter-security'
  • 스프링 시큐리티 의존성을 추가하면, 다음과 같은 기본 필터들이 추가된다. 기본 필터들의 내용을 오버라이딩하거나 새로운 필터를 추가해 특정 필터보다 먼저 실행되거나 늦게 실행되게 제어할 수 있다. 물론 특정 필터 위치에 추가할 수도 있다. 같은 위치에 있는 필터는 순서가 보장되지 않는다고 한다.
security.web.session.DisableEncodeUrlFilter

security.web.context.request.async.WebAsyncManagerIntegrationFilter

security.web.context.SecurityContextHolderFilter

security.web.header.HeaderWriterFilter

web.filter.CorsFilter

security.web.csrf.CsrfFilter

security.web.authentication.logout.LogoutFilter

security.web.authentication.UsernamePasswordAuthenticationFilter

security.web.authentication.ui.DefaultLoginPageGeneratingFilter

security.web.authentication.ui.DefaultLogoutPageGeneratingFilter

security.web.authentication.www.BasicAuthenticationFilter

security.web.savedrequest.RequestCacheAwareFilter

security.web.servletapi.SecurityContextHolderAwareRequestFilter

security.web.authentication.AnonymousAuthenticationFilter

security.web.access.ExceptionTranslationFilter

security.web.access.intercept.AuthorizationFilter

기본 필터들을 알아보기 전에 먼저 기본 필터들이 상속하는 OncePerRequestFilter와 GenericFilterBean을 살펴본다.

OncePerRequestFilter와 GenericFilterBean

  • OncePerRequestFilter를 상속하여 여러 필터를 만들 수 있다. 보안 뿐만 아니라 로깅, 검증 로직 등 다양하게 사용한다.
  • GenericFilterBean을 상속하고, GenericFilterBean은 Servlet filter와 ServletContextAware 등을 상속하기에 Servlet Filter가 제공하는 필터를 사용할 수 있고, 이를 Spring Context에 등록하여 쉽게 Spring과 통합할 수 있는 것이다.
public abstract class OncePerRequestFilter extends GenericFilterBean {
	...
   	@Override
	public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
	
    if (!((request instanceof HttpServletRequest httpRequest) && (response instanceof HttpServletResponse httpResponse))) {
			throw new ServletException("OncePerRequestFilter only supports HTTP requests");
	}
    
	String alreadyFilteredAttributeName =  getAlreadyFilteredAttributeName();
	boolean hasAlreadyFilteredAttribute = request.getAttribute(alreadyFilteredAttributeName) != null;
        
	if (skipDispatch(httpRequest) || shouldNotFilter(httpRequest)) {
			// Proceed without invoking this filter...
			filterChain.doFilter(request, response);
	}
    else if (hasAlreadyFilteredAttribute) {
			if (DispatcherType.ERROR.equals(request.getDispatcherType())) {
				doFilterNestedErrorDispatch(httpRequest, httpResponse, filterChain);
				return;
	}
    else {
    	// Do invoke this filter...
    }
    
}
  • 코드를 보면 HTTP 요청에 대해서만 수신하며, 접미사(Suffix)를 지정해 이미 필터를 거쳤다면 getAlreadyFilteredAttributeName(), skipDispatch(httpRequest)를 통해 해당 필터를 적용하지 않는다.
  • shouldNotFilter를 이용해 특정 접두사, 특정 API 리소스 등으로 해당 필터를 거치지 않게 할수도 있다.
protected boolean shouldNotFilterAsyncDispatch() {
	return true;
}
  • 기본적으로 OncePerRequestFilter는 요청이 비동기 처리가 시작되어도 필터링 하지 않도록 설정되어 있다. false를 반환하면 각 비동기 디스패치에 대해 정확히 스레드당 한 번씩 필터가 호출된다. 비동기 작업 결과를 처리하거나 비동기 처리 전후로 특정 작업을 해야할 때 유용하다고 한다.

  • 중요한 것은 하나의 요청이 한 번만 실행되는 것을 보장하는 필터이고, 어떤 필터가 처리되었을 때 OncePerRequest를 상속하는 다른 필터에서 위와 같은 방법으로 필터링 되지 않도록 할 수 있다는 것이다.

기본 필터 중에 GenericFilter를 상속하는 필터들은 다음과 같다.

SecurityContextHolderFilter
LogoutFilter
DefaultLoginPageGeneratingFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
AnonymousAuthenticationFilter
ExceptionTranslationFilter
AuthorizationFilter
  • 스프링과의 통합을 위해서 GenericBeanFilter을 상속하고, 단 한번만 실행하는 것을 보장하기 위해 OncePerRequestFiter를 상속한다는 것을 알았다.

의문이 드는 점은 LogoutFilter는 요청 당 단 한번만 처리해야 되는 거 아닌가?
DefaultLogoutPageGeneratingFilter는 OncePerRequestFilter를 상속한다. LogoutFilter는 왜 GenericBeanFilter을 상속하는 것인지?

  • 관련 키워드로 검색해도 이유를 찾지 못했다. LogoutFilter를 OncePerRequestFilter를 상속해도 로그아웃은 동작이 된다. 추측컨대, 어떤 인증을 구현할 때 검증 로직을 위해 LogoutFilter를 여러번 거칠 수 있게 한 것이 아닐까 싶다. 구체적인 이유는 잘 모르겠다.

기본 필터

1. DisableEncodeUrlFilter

public class DisableEncodeUrlFilter extends OncePerRequestFilter {
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
		filterChain.doFilter(request, new DisableEncodeUrlResponseWrapper(response));
	}
}
  • HTTP 액세스 로그 등에서 세션 ID가 유출될 수 있으므로 URL로 간주되지 않는 URL에 세션 ID가 포함되는 것을 방지하기 위해 HttpServletResponse를 사용하여 인코딩 URL을 비활성화하는 필터다.
  • 서블릿 컨테이너에서 사용자가 쿠키를 비활성화했을 경우 세션 ID를 사용자 redirect URL에 추가할 수 있는데 이는 세션 하이재킹(Session Hijacking)에 취약해진다. 이를 방지하는 필터라고 생각하면 된다. 시큐리티 필터는 서블릿 컨테이너 앞단에 위치하기에 일관된 보안 정책을 꾸릴 수 있다.

2. WebAsyncManagerIntegrationFilter

public final class WebAsyncManagerIntegrationFilter extends OncePerRequestFilter {
	...
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
		WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
		SecurityContextCallableProcessingInterceptor securityProcessingInterceptor = (SecurityContextCallableProcessingInterceptor) asyncManager
			.getCallableInterceptor(CALLABLE_INTERCEPTOR_KEY);
		if (securityProcessingInterceptor == null) {
			SecurityContextCallableProcessingInterceptor interceptor = new SecurityContextCallableProcessingInterceptor();
			interceptor.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
			asyncManager.registerCallableInterceptor(CALLABLE_INTERCEPTOR_KEY, interceptor);
		}
		filterChain.doFilter(request, response);
	}
}
public final class WebAsyncManager {
	...
	private volatile Object[] concurrentResultContext;
}
  • Security Context와 비동기 매너지를 통합하는 필터이다. Security Context는 인증 객체가 저장되는 곳으로 인증이 되었다면, 언제든지 해당 Security Context에 접근하여 정보를 가져올 수 있다. 이는 thread local에 저장(기본 전략)되므로 비동기 요청을 처리하는 스레드마다 독립적으로 관리할 수 있다. 하지만 여러 쓰레드가 접근했다고 해서 Security Context의 무결성이 깨지면 안된다.
  • 코드를 살펴보면, 먼저 요청을 담당하는 비동기 매니저를 가져온다. 비동기 매니저는 비동기 요청을 처리하기 전에 현재의 Security Context를 저장하여 일관성을 유지한다.

3. SecurityContextHolderFilter

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);
		}
	}
}
default DeferredSecurityContext loadDeferredContext(HttpServletRequest request) {
	Supplier<SecurityContext> supplier = () -> loadContext(new HttpRequestResponseHolder(request, null));
    
	return new SupplierDeferredSecurityContext(SingletonSupplier.of(supplier),
				SecurityContextHolder.getContextHolderStrategy());
}
  • 필터가 먼저 적용되었는지 검사한 후에(FILTER_APPLIED) securityContextRepository에서 Security Context를 지연 로딩으로 가져온다.
  • 즉, 인증된 사용자의 Security Context를 관리하는 코드이다.

4. HeaderWriterFilter

public class HeaderWriterFilter extends OncePerRequestFilter {
	
    private final List<HeaderWriter> headerWriters;

	private boolean shouldWriteHeadersEagerly = false;

	public HeaderWriterFilter(List<HeaderWriter> headerWriters) {
		Assert.notEmpty(headerWriters, "headerWriters cannot be null or empty");
		this.headerWriters = headerWriters;
	}
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
		if (this.shouldWriteHeadersEagerly) {
			doHeadersBefore(request, response, filterChain);
		}
		else {
			doHeadersAfter(request, response, filterChain);
		}
	}
    ...
}
public final class XFrameOptionsHeaderWriter implements HeaderWriter {}

public final class XXssProtectionHeaderWriter implements HeaderWriter {}

public final class XContentTypeOptionsHeaderWriter extends StaticHeadersWriter {}

public class StaticHeadersWriter implements HeaderWriter {}
  • 보안을 위해 응답 헤더 추가하는 필터이다. X-Frame-Options, X-XSS-Protection and X-Content-Type-Options 같은 보안 헤더를 List에 담아 지연 로딩 여부에 따라 적용한다.

5. CorsFilter

public class CorsFilter extends OncePerRequestFilter {
	private final CorsConfigurationSource configSource;

	private CorsProcessor processor = new DefaultCorsProcessor();

	public CorsFilter(CorsConfigurationSource configSource) {
		Assert.notNull(configSource, "CorsConfigurationSource must not be null");
		this.configSource = configSource;
	}

	public void setCorsProcessor(CorsProcessor processor) {
		Assert.notNull(processor, "CorsProcessor must not be null");
		this.processor = processor;
	}


	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
			FilterChain filterChain) throws ServletException, IOException {

		CorsConfiguration corsConfiguration = this.configSource.getCorsConfiguration(request);
		boolean isValid = this.processor.processRequest(corsConfiguration, request, response);
		if (!isValid || CorsUtils.isPreFlightRequest(request)) {
			return;
		}
		filterChain.doFilter(request, response);
	}
}
  • CORS 설정을 적용하는 필터이다. CorsConfiguation 인스턴스가 만들어지면, 기본적으로 교차 출처 허용하지 않는다. CorsConfiguration를 보면 교차 출처 요청을 허용하는 URL list가 있는데, 기본적으로 null로 초기화되기 때문이다.
public class DefaultCorsProcessor implements CorsProcessor {

	Collection<String> varyHeaders = response.getHeaders(HttpHeaders.VARY);
		if (!varyHeaders.contains(HttpHeaders.ORIGIN)) {
			response.addHeader(HttpHeaders.VARY, HttpHeaders.ORIGIN);
		}
		if (!varyHeaders.contains(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD)) {
			response.addHeader(HttpHeaders.VARY, HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD);
		}
		if (!varyHeaders.contains(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS)) {
			response.addHeader(HttpHeaders.VARY, HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS);
		}

		if (!CorsUtils.isCorsRequest(request)) {
			return true;
		}

		if (response.getHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN) != null) {
			logger.trace("Skip: response already contains \"Access-Control-Allow-Origin\"");
			return true;
		}

		boolean preFlightRequest = CorsUtils.isPreFlightRequest(request);
		if (config == null) {
			if (preFlightRequest) {
				rejectRequest(new ServletServerHttpResponse(response));
				return false;
			}
			else {
				return true;
			}
		}

		return handleInternal(new ServletServerHttpRequest(request), new ServletServerHttpResponse(response), config, preFlightRequest);
}
  • processRequest를 처리하는 부분을 살펴보면, VaryHeader를 설정한다. 이는 요청 출처(ORIGIN), 사용할 메소드(ACCESS_CONTROL_REQUEST_METHOD), 사용할 헤더(ACCESS_CONTROL_REQUEST_HEADERS)를 지정하여 요청마다 서버가 다양하게 응답할 수 있게한다.

  • CORS 설정을 하기 위해선 먼저 동일 출처 요청인지 아닌지부터 검사한다. 먼저 서버와 요청의 스킴(http, https)을 비교하고, 호스트(도메인)를 검사한다. 스킴과 도메인 그리고 포트가 같다면 동일 출처로 판단하여 cors를 적용하지 않는다.

return !(ObjectUtils.nullSafeEquals(scheme, originUrl.getScheme()) &&
				ObjectUtils.nullSafeEquals(host, originUrl.getHost()) &&
				getPort(scheme, port) == getPort(originUrl.getScheme(), originUrl.getPort()));
  • CORS 적용을 하기 전에 해당 요청이 사전 요청인지 검사한다. 사전 요청이란 실제 클라이언트 요청을 전송하기 전에 클라이언트가 서버의 요청을 수락할 수 있는지 확인하는 요청이다. 사전 요청이 서버에 도착하면, 서버는 요청에 대한 허용 여부만 응답하고 실제 요청을 처리하지 않기 때문에 다음 필터로 넘기지 않고 종료하는 것이다.
  • 사전 요청을 보내기 위해 클라이언트는 HTTP 요청에 OPTIONS라는 특별한 헤더를 이용한다. ORIGIN을 통해서 헤더의 출처를 확인하고, 실제 사용 가능한 메서드를 제공한다. 즉, 서버는 이 요청을 검사해서 OPTIONS 헤더를 사용하고 출처가 다르면서 실제 사용 가능하다면 올바른 사전 요청이라고 판단한다.
public static boolean isPreFlightRequest(HttpServletRequest request) {
		return (HttpMethod.OPTIONS.matches(request.getMethod()) &&
				request.getHeader(HttpHeaders.ORIGIN) != null &&
				request.getHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD) != null);
}

6. CsrfFilter

  • CsrfFilter는 왜 필요할까? Csrf란 Cross-Site Request Forgery, 교차 출처 요청 위조 공격이다. 즉, 다른 사이트에서 어떤 스크립트를 실행했을 때 기존에 로그인한 사용자의 세션을 이용하여 어떤 요청을 보내는 것이다. 이를 방지하기 위해 클라이언트의 요청에 csrf 토큰을 함께 보내면 악의적인 사용자가 해당 세션을 이용하더라도 csrf 토큰을 모르기 때문에 원하는 작업을 수행할 수 없다.
  • CsrfFilter에서는 요청 검증하여 csrf 토큰이 유효하지 않다면 에러를 발생시키고 유효하다면 다음 필터로 진행시킨다.
public final class CsrfFilter extends OncePerRequestFilter {
	
    ...
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {
		DeferredCsrfToken deferredCsrfToken = this.tokenRepository.loadDeferredToken(request, response);
		request.setAttribute(DeferredCsrfToken.class.getName(), deferredCsrfToken);
		this.requestHandler.handle(request, response, deferredCsrfToken::get);
		if (!this.requireCsrfProtectionMatcher.matches(request)) {
			if (this.logger.isTraceEnabled()) {
				this.logger.trace("Did not protect against CSRF since request did not match "
						+ this.requireCsrfProtectionMatcher);
			}
			filterChain.doFilter(request, response);
			return;
		}
		CsrfToken csrfToken = deferredCsrfToken.get();
		String actualToken = this.requestHandler.resolveCsrfTokenValue(request, csrfToken);
		if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
			boolean missingToken = deferredCsrfToken.isGenerated();
			this.logger
				.debug(LogMessage.of(() -> "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request)));
			AccessDeniedException exception = (!missingToken) ? new InvalidCsrfTokenException(csrfToken, actualToken)
					: new MissingCsrfTokenException(actualToken);
			this.accessDeniedHandler.handle(request, response, exception);
			return;
		}
		filterChain.doFilter(request, response);
	}
}
  • CSRF 공격은 아래와 같이 조회하는 메서드에 대해선 사용자에게 직접적인 위험이 없다. 이러한 메서드들에 대해서는 csrf 토큰 검증이 진행되지 않는다. 한 가지 의문이 있다면 csrf 토큰 조회하고 나서 해당 메서드들을 검증하는 것이다. 추측컨대, CsrfFilter를 사용자가 재정의했을 때 조회하는 메서드들에 대해서도 csrf토큰이 일치하지 않는다면 보안 취약점을 알려줄 수도 있고, 특정 상황에선 Get 메서드에 대해서도 토큰 검증을 진행할 수 있게 하기 위해서 어떤 형식을 제공해준 거 같았다.
private final HashSet<String> allowedMethods = new HashSet<>(Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS"));

참고자료

profile
꾸준하게

0개의 댓글