티스토리 뷰
지난 글에서 스프링 시큐리티의 전반적인 필터 구조 및 흐름을 살펴봤다. 이번 글에서는 스프링 시큐리티가 기본적으로 제공하는 DefaultSercurityFilterChain
(일련의 시큐리티 관련 작업을 수행하는 필터들의 묶음) 내 필터들의 종류와 각 필터의 역할에 대해 간단히? 알아보고, 해당 필터들을 기반으로 커스텀 필터를 생성하고 등록해보자.
스프링 시큐리티의 DefaultSecurityFilterChain
스프링 시큐리티의 필터 구조는 DelegatingFilterChain
→ FilterChainProxy
→ SecurityFilterChain
→ List<Filter>
순으로 구조화되어 있다고 했다. 참고 이때 스프링 시큐리티가 기본적으로 제공하는 DefaultSecurityFilterChain
(SecurityFilterChain
인터페이스의 구현 클래스)이 있는데 이는 FilterChainProxy
에 중단점을 찍어서 확인할 수 있다.
FilterChainProxy
는 내부에 여러 SecurityFilterChain
을 관리하며 이 중 특정 요청을 처리할 수 있는 하나의 SecurityFilterChain
에게 처리를 위임한다. 스프링 시큐리티 의존성을 추가하고 아무 설정 클래스도 생성하지 않았을 때에 기본적으로 DefaultSecurityFilterChain
하나만 존재하며, 해당 클래스는 다시 (현재는) 16개의 Filter
에 의존하게 된다.
여러 종류의 Filter, List<Filter>
이렇게 스프링 시큐리티는 내부적으로 필터 체인을 유지하며 각각의 필터들은 로그아웃, 로그인 등 주요한 로직을 담당한다. 따라서 서비스 요구사항에 따라 특정 역할을 수행하는 필터가 추가되거나 제거될 수 있다.
실제로 각 필터들은 주요 로직을 직접 실행하기 보다는, 요청을 가로 채 스프링 컨테이너 내 시큐리티 관련 빈에게 요청 처리를 위임한다.(각 로직의 시작점 역할을 함) 즉 DefaultSecurityFilterChain
내부에 등록된 여러 필터들을 거치면서 특정 요청에 대해 로그인 처리가 필요하다면 로그인 관련 필터가 이를 가로 채고 실제 로그인 처리는 스프링 컨테이너에 등록된 시큐리티 관련 빈들에게 위임하는 것이다. 요청 처리가 완료된 후 다시 로그인 관련 필터로 되돌아 오고 다시 다음 필터로 요청 흐름이 이어진다. (필터 내부적으로 모두 처리할 수 있을 만큼 간단한 작업이라면 시큐리티 관련 빈에게 요청 처리 위임하지 않고 직접 처리하기도 한다.)
GenericFilterBean & OncePerRequestFilter
시큐리티 관련 필터들은 모두 GenericFilterBean
또는 OncePerRequestFilter
추상 클래스를 상속하고 있다. 이때 GenericFilterBean
는 자바 서블릿 필터 기반으로, 자바 서블릿 영역에서 스프링 영역에 접근할 수 있도록 구현되어 있다. OncePerRequestFilter
는 GenericFilterBean
를 상속한 추상 클래스로, 둘의 가장 큰 차이점은 GenericFilterBean
는 필터를 여러번 통과한다면 통과 수만큼 내부 로직 실행되는 반면, OncePerRequestFilter
는 클라이언트의 한번 요청에 대해 해당 필터를 여러번 거칠 경우 딱 한번만 로직이 실행된다는 것이다.
이때 중요한 것은 클라이언트의 요청이 기준이라는 것이다. 즉 302 redirect 상황에서는 실제 클라이언트 → 서버로 총 2번의 요청을 보내는 것으로 OncePerRequestFilter
가 2번 동작하는 반면, 서버 내부적으로 재요청을 하는 forward의 경우에는 1번만 동작하게 된다.
뒤에서 설명하겠지만 커스텀 필터를 SecurityFilterChain
에 등록하기 위해 GenericFilterBean
또는 OncePerRequestFilter
를 구현한 필터를 생성할 것이다. 그럼 이제 본격적으로 DefaultSecurityFilterChain
에 등록되는 16개의 기본 필터들의 역할과 동작 원리를 살펴보자. 조금 길 수 있으니 끊어 읽으면 좋을듯 하다. (각 필터의 주요 코드 및 로직을 중점적으로 살펴보고자 하였으며 더 자세한 내용은 첨부한 Spring Security Docs를 참고하시길. 기본적으로 스프링 시큐리티로 인증, 인가 로직을 한번이라도 구현해보고 관련 코드를 대략적으로 까본적이 있다는 것을 가정하고 글을 썼다.)
DisableEncodeUrlFilter
필터 체인의 가장 첫번째에 위치하는 필터로, URL로 간주되지 않는 부분(보안 위협이 존재할 수 있는 부분)을 포함하지 않도록 설정하는 역할을 한다. 이게 뭔말인가 하면, 세션 기반 로그인을 구현한 경우 기본적으로 응답의 URL 파라미터에 세션 ID가 인코딩되어 전달되거나 로그로 남게 된다. 세션 ID가 유출되면 세션 공격이 가능해지기 때문에 이를 방지하는 역할을 DisableEncodeUrlFilter
가 하는 것이다. 물론 세션 기반 로그인과 관련 있는 필터이기 때문에 다음과 같이 스프링 시큐리티의 세션 관리 설정을 disable 하면 비활성화 되며, 커스텀 SecurityFilterChain
을 생성해도 자동 등록된다.
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.sessionManagement(config -> config.disable());
return http.build();
}
}
DisableEncodeUrlFilter
의 doFilterInternal
메소드에서 다음 필터를 호출 시 DisableEncodeUrlResponseWrapper
로 현재 response
객체를 한번더 감싸서 전달하는 것을 확인할 수 있다. DisableEncodeUrlResponseWrapper
는 DisableEncodeUrlFilter
의 정적 내부 클래스로, 오버라이드된 메소드를 보면 순수 url을 그대로 반환하고 있다.
public class DisableEncodeUrlFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
filterChain.doFilter(request, new DisableEncodeUrlResponseWrapper(response)); //response 객체를 wrapping
}
private static final class DisableEncodeUrlResponseWrapper extends HttpServletResponseWrapper {
private DisableEncodeUrlResponseWrapper(HttpServletResponse response) {
super(response);
}
@Override
public String encodeRedirectURL(String url) {
return url; //순수 URL만 반환
}
@Override
public String encodeURL(String url) {
return url;
}
}
}
그럼 기존의 HttpServletResponse
구현체인 org.apache.catalina.connector.Response
의 encodedRedirectURL
, encodeURL
메소드는 어떻게 정의되어 있길래 세션 ID가 파라미터로 전달되는지 확인해보자.
public class Response implements HttpServletResponse {
public String encodeRedirectURL(String url) {
return this.isEncodeable(this.toAbsolute(url)) ? this.toEncoded(url, this.request.getSessionInternal().getIdInternal()) : url;
}
public String encodeURL(String url) {
String absolute;
try {
absolute = this.toAbsolute(url);
} catch (IllegalArgumentException var4) {
return url;
}
if (this.isEncodeable(absolute)) {
if (url.equalsIgnoreCase("")) {
url = absolute;
} else if (url.equals(absolute) && !this.hasPath(url)) {
url = url + "/";
}
return this.toEncoded(url, this.request.getSessionInternal().getIdInternal());
} else {
return url;
}
}
//URL에 세션 ID 추가
protected String toEncoded(String url, String sessionId) {
if (url != null && sessionId != null) {
String path = url;
String query = "";
String anchor = "";
int question = url.indexOf(63);
if (question >= 0) {
path = url.substring(0, question);
query = url.substring(question);
}
int pound = path.indexOf(35);
if (pound >= 0) {
anchor = path.substring(pound);
path = path.substring(0, pound);
}
StringBuilder sb = new StringBuilder(path);
if (sb.length() > 0) {
sb.append(';');
sb.append(SessionConfig.getSessionUriParamName(this.request.getContext()));
sb.append('=');
sb.append(sessionId); //세션 ID가 쿼리 파라미터로 전달
}
sb.append(anchor);
sb.append(query);
return sb.toString();
} else {
return url;
}
}
}
encodedRedirectURL
, encodeURL
메소드에서는 toEncoded
를 호출하고 있으며, toEncoded
메소드는 기존 url에 세션 ID값을 쿼리 파라미터로 추가하여 반환하고 있다. 이렇게 세션 ID가 응답으로 전달되어 탈취될 가능성을 막고자 DisableEncodeUrlFilter
가 동작하는 것이다.
WebAsyncManagerIntegrationFilter
WebAsyncManagerIntegrationFilter
는 비동기로 처리되는 작업에 대해 알맞은 SecurityContext
를 설정해주는 역할을한다. 즉 서블릿 단에서 비동기 작업을 수행할 때 서블릿 입출력 쓰레드와 작업 쓰레드가 동일한 SecurityContextHolder
의 SecurityContext
를 참조하도록 도와주는 것이다. 다시 말해 하나의 요청을 처리하는데 여러 쓰레드가 사용될 경우 모두 같은 SecurityContext
를 참조할 수 있도록 싱크를 맞춰주는 역할을 한다.
SecurityContextHolder
의 SecurityContext
기본 관리 전략은 ThreadLocal로, 따라서 동일한 쓰레드에서만 동일한 SecurityContext
에 접근 가능하다. (SecurityContextHolderStrategy
내부에 ThreadLocal<Supplier<SecurityContext>>
필드로 SecurityContext
객체를 쓰레드 로컬 저장소에서 관리하고 있다.) 비동기 방식의 경우 하나의 작업을 2개 이상의 쓰레드로 수행하기 때문에 이 부분을 보완하기 위해 해당 필터가 필요하다. (커스텀 SecurityFilterChain
을 생성해도 항상 자동 등록)
@Slf4j
@RestController
public class AsyncController {
@GetMapping("/async")
public Callable<String> asyncTest() {
log.info("Thread = {}, SecurityContext = {}", Thread.currentThread().getName(), SecurityContextHolder.getContext().hashCode());
return () -> {
Thread.sleep(4000);
log.info("Thread = {}, SecurityContext = {}", Thread.currentThread().getName(), SecurityContextHolder.getContext().hashCode());
return "async";
};
}
}
Thread = http-nio-8080-exec-2, SecurityContext = -1114173449
Thread = task-1, SecurityContext = -1114173449
위 컨트롤러를 직접 호출해보면 각기 다른 쓰레드에서 로그가 출력되지만 같은 SecurityContext
객체를 참조하는 것을 확인할 수 있다. 그럼 어떻게 컨트롤러 단에서 발생하는 멀티 쓰레드 문제를 필터에서 처리할 수 있는 것일까?? 이를 이해하기 위해서는 WebAsyncManagerIntegrationFilter
와 반환 타입이 Callable
인 컨트롤러의 동작 방식을 먼저 살펴봐야 한다.
WebAsyncManagerIntegrationFilter
는 실제로 요청 쓰레드(서블릿 컨테이너가 요청 하나를 처리하기 위해 할당한 쓰레드)의 SecurityContext
를 관리하는 SecurityContextCallableProcessingInterceptor
를 WebAyncManager
에 등록하는 작업만을 수행하다. 실질적인 SecurityContext
의 동기화 작업은 MVC 단에서 수행되는 것이다.
public final class WebAsyncManagerIntegrationFilter extends OncePerRequestFilter {
//...
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);
}
}
그리고 Callable
객체를 반환하는 컨트롤러 호출 시 동작 원리는 아주 간단하게 말하자면, Controller
→ CallableMethodReturnValueHandler
→ WebAsyncManager
→ SecurityContextCallableProcessingInterceptor
가 차례대로 실행된다. 이 과정에서 미리 WebAsyncManagerIntegrationFilter
를 통해 등록한 SecurityContextCallableProcessingInterceptor
를 이용해 같은 SecurityContext
객체에 접근할 수 있는 것이다.
컨트롤러가 Callable
객체를 반환하는 경우 동작하는 핸들러를 좀더 살펴보면, handleReturnValue
메소드에서 비동기 작업을 WebAyncManager
의 startCallableProcessing
메소드를 호출함으로써 처리를 위임하고 있다.
public class CallableMethodReturnValueHandler implements HandlerMethodReturnValueHandler {
public CallableMethodReturnValueHandler() {
}
public boolean supportsReturnType(MethodParameter returnType) {
return Callable.class.isAssignableFrom(returnType.getParameterType());
}
public void handleReturnValue(@Nullable Object returnValue, MethodParameter returnType, ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {
if (returnValue == null) {
mavContainer.setRequestHandled(true);
} else {
Callable<?> callable = (Callable)returnValue;
WebAsyncUtils.getAsyncManager(webRequest).startCallableProcessing(callable, new Object[]{mavContainer}); //callable 객체의 비동기 작업을 위임
}
}
}
코드가 좀 긴데, 주요하게 볼 메소드 호출 흐름은 startCallableProcessing
→ applyBeforeConcurrentHandling
→ applyPreProcess
→ call
순이다. applyBeforeConcurrentHandling
메소드에서는 본격적인 비동기 작업 처리 전 현재 요청 쓰레드의 쓰레드 로컬에 저장된 SecurityContext
객체를 SecurityContextCallableProcessingInterceptor
의 내부 필드로 초기화, 즉 다른 쓰레드에서도 접근할수 있도록 SecurityContext
객체를 임시 저장해둔다. 그리고 applyPreProcess
메소드에서는 현재 작업 쓰레드의 쓰레드 로컬에 앞서 임시 저장했던 SecurityContext
객체를 옮겨 놓는다. 따라서 call
메소드 내에서 SecurityContextHolder
를 통해 SecurityContext
에 접근하더라도 (분명 다른 쓰레드임에도 불구하고) 같은 객체를 참조할 수 있는 것이다.
public final class WebAsyncManager {
//WebAsyncManagerIntegrationFilter에서 SecurityContextCallableProcessingInterceptor 등록을 위해 호출되는 메소드
public void registerCallableInterceptors(CallableProcessingInterceptor... interceptors) {
CallableProcessingInterceptor[] var2 = interceptors;
int var3 = interceptors.length;
for(int var4 = 0; var4 < var3; ++var4) {
CallableProcessingInterceptor interceptor = var2[var4];
String var10000 = interceptor.getClass().getName();
String key = var10000 + ":" + interceptor.hashCode();
this.callableInterceptors.put(key, interceptor);
}
}
public void startCallableProcessing(Callable<?> callable, Object... processingContext) throws Exception {
this.startCallableProcessing(new WebAsyncTask(callable), processingContext);
}
public void startCallableProcessing(final WebAsyncTask<?> webAsyncTask, Object... processingContext) throws Exception {
if (!this.state.compareAndSet(WebAsyncManager.State.NOT_STARTED, WebAsyncManager.State.ASYNC_PROCESSING)) {
throw new IllegalStateException("Unexpected call to startCallableProcessing: [" + this.state.get() + "]");
} else {
List<CallableProcessingInterceptor> interceptors = new ArrayList();
interceptors.add(webAsyncTask.getInterceptor());
interceptors.addAll(this.callableInterceptors.values()); //WebAsyncManagerIntegrationFilter에서 등록한 SecurityContextCallableProcessingInterceptor 추가
interceptors.add(timeoutCallableInterceptor);
Callable<?> callable = webAsyncTask.getCallable();
CallableInterceptorChain interceptorChain = new CallableInterceptorChain(interceptors);
//타임아웃, 에러, 종료 핸들러 등록
interceptorChain.applyBeforeConcurrentHandling(this.asyncWebRequest, callable); //현재 요청 쓰레드 로컬에 저장된 SecurityContext를 인터셉터 내부 필드에 저장
this.startAsyncProcessing(processingContext);
try {
Future<?> future = this.taskExecutor.submit(() -> {
Object result = null;
try {
interceptorChain.applyPreProcess(this.asyncWebRequest, callable); //SecurityContextCallableProcessingInterceptor 내부 필드에 저장된 SecurityContext를 현재 Callable 실행 쓰레드의 쓰레드 로컬에 저장
result = callable.call(); //callable 실행
} catch (Throwable var8) {
Throwable ex = var8;
result = ex;
} finally {
result = interceptorChain.applyPostProcess(this.asyncWebRequest, callable, result); //현재 Callable 실행 쓰레드의 SecurityContext 초기화
}
//...
});
interceptorChain.setTaskFuture(future);
} catch (Throwable var10) {
//...
}
}
}
//...
}
public final class SecurityContextCallableProcessingInterceptor implements CallableProcessingInterceptor {
private volatile SecurityContext securityContext;
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
public SecurityContextCallableProcessingInterceptor() {
}
public SecurityContextCallableProcessingInterceptor(SecurityContext securityContext) {
this.setSecurityContext(securityContext);
}
//현재 요청 쓰레드의 쓰레드 로컬의 SecurityContext로 내부 필드 초기화
public <T> void beforeConcurrentHandling(NativeWebRequest request, Callable<T> task) {
if (this.securityContext == null) {
this.setSecurityContext(this.securityContextHolderStrategy.getContext());
}
}
//내부 필드로 작업 쓰레드의 쓰레드 로컬의 SecurityContext 객체 초기화
public <T> void preProcess(NativeWebRequest request, Callable<T> task) {
this.securityContextHolderStrategy.setContext(this.securityContext);
}
//작업 쓰레드의 쓰레드 로컬의 SecurityContext 객체 무효화
public <T> void postProcess(NativeWebRequest request, Callable<T> task, Object concurrentResult) {
this.securityContextHolderStrategy.clearContext();
}
public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) {
this.securityContextHolderStrategy = securityContextHolderStrategy;
}
private void setSecurityContext(SecurityContext securityContext) {
this.securityContext = securityContext;
}
}
그러나 아쉽게도 해당 필터는 Callable
객체를 통한 컨트롤러 단에서의 비동기 작업만을 보완해주며 @Async
방식의 비동기 작업에는 별도의 설정이 필요하다. 이것까지 알아보면 글의 주제와 벗어나고 글이 너무 길어지므로 일단 패스한다.
SecurityContextHolderFilter
접근한 유저에 대해 시큐리티 컨텍스트를 관리하는 필터로, 이전 요청을 통해 이미 인증된 사용자 정보를 현재 요청의 SecurityContextHolder
의 SecurityContext
에 할당하는 역할을 수행하며, 현재 요청이 끝나면 자동으로 SecurityContext
를 무효화한다. 세션 기반 로그인에서 유지되고 있는 세션의 유저 정보를 SecurityContext
에 저장하기 위해 주로 사용되며, JWT 기반 로그인에서도 응답 전달 시 현재 요청에 대한 SecurityContext
를 자동 무효화해주기 때문에 해당 필터가 유용하게 사용될 수 있다. (특히 SecurityContext
는 기본적으로 쓰레드 로컬 저장소를 사용하기 때문에, 쓰레드 풀을 통해 쓰레드를 재사용하는 서블릿 컨테이너에서는 이를 초기화 해주는 것이 중요하다.) 해당 필터는 다음과 같이 비활성화 할 수 있다. (커스텀 SecurityFilterChain
생성 시에도 자동 등록)
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.securityContext(config -> config.disable());
return http.build();
}
}
SecurityContextHolderFilter
의 코드를 좀더 살펴보자.
이전 요청에서 사용자가 로그인을 진행한 경우, 서버 세션 또는 레디스와 같은 저장 매체에 로그인한 유저 정보가 존재한다. 이를 SecurityContextRepository
의 loadDeferredContext
메소드를 호출하여 저장된 정보를 조회한다. 참고로 loadDeferredContext
는 로딩을 지연시켜 SecurityContext
를 조회하는 동작인 Supplier
를 반환하고, 필요 시점에 Supplier
를 실행시켜 SecurityContext
를 조회해오도록 한다. (없다면 Authentication
객체가 null인 SecurityContext
반환) 이렇게 조회된 유저 정보를 SecurityContextHolder
에 저장하고 다음 필터를 호출한다. 이후 요청 처리가 끝나 되돌아 오는 경우에는 SecurityContext
를 무효화 해준다.
public class SecurityContextHolderFilter extends GenericFilterBean {
private static final String FILTER_APPLIED = SecurityContextHolderFilter.class.getName() + ".APPLIED";
private final SecurityContextRepository securityContextRepository;
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
//...
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
if (request.getAttribute(FILTER_APPLIED) != null) { //해당 필터가 한번 동작했다면 다음 필터로 넘어감
chain.doFilter(request, response);
} else {
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request); //저장된 SecurityContext 조회
try {
this.securityContextHolderStrategy.setDeferredContext(deferredContext);
chain.doFilter(request, response);
} finally {
this.securityContextHolderStrategy.clearContext(); //SecurityContext 무효화
request.removeAttribute(FILTER_APPLIED);
}
}
}
}
여기서 SecurityContext
의 저장 매체에 따라 SecurityContextRepository
의 다른 구현체를 사용한다. 주요 구현체는 다음과 같다.
- HttpSessionSecurityContextRepository
: SecurityContext
를 HttpSession
에 저장해 영속성을 유지
- RequestAttributeSecurityContextRepository
: SecurityContext
를 ServletContext
에 저장해 후속 요청 시 영속성 유지 불가능
- NullSecurityContextRepository
: 빈 SecurityContext
객체 반환 (세션을 사용하지 않는 JWT, OAuth2 인증 방식 등 stateless하게 동작하는 경우 사용)
- DelegatingSecurityContextRepository
: 기본 설정돼 내부적으로 HttpSessionSecurityContextRepository
, RequestAttributeSecurityContextRepository
리스트의 delegates
필드 관리
이 외에도 직접 SecurityContextRepository
를 구현하여 아래와 같이 설정해줄 수 있다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.securityContext(config -> config.securityContextRepository(new NullSecurityContextRepository()));
return http.build();
}
SecurityContextPersistenceFilter vs SecurityContextHolderFilter
SecurityContextHolderFilter
는 SecurityContextPersistenceFilter
후속으로 시큐리티 5.8 버전부터 내부 구현이 변경되면서 기존 클래스는 deprecated 되었다. 둘의 기능은 거의 동일하며 가장 큰 차이점은 응답이 되돌아올 때 변경된 SecurityContext
객체를 저장소에 재저장할 것인지 여부이다.
이전 클래스는 SecurityContext
가 변경되면 변경된 객체를 SecurityContextRepository
저장소에 업데이트한 반면, 현재 클래스(SecurityContextHolderFilter
)는 변경된 객체를 재저장하지 않는다. 즉 이전까지 사용됐던 SecurityContextPersistenceFilter
는 saveContext
메서드를 강제로 수행해 세션에 인증 정보를 저장했는데, SecurityContextHolderFilter
는 사용자가 명시적으로 saveContext
를 실행하도록 한다.
인증 필터에서 인증 처리 시, 필요한 경우에만 인증 정보를 세션에 저장해야 한다. 따라서 SecurityContextHolderFilter
필터는 시큐리티가 무조건 인증 정보를 저장하도록 강제하기 보다는, 인증 매커니즘에 따라 다르게 구현할 수 있도록 유연성을 제공한 것이다.
@Deprecated
public class SecurityContextPersistenceFilter extends GenericFilterBean {
static final String FILTER_APPLIED = "__spring_security_scpf_applied";
private SecurityContextRepository repo;
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
.getContextHolderStrategy();
private boolean forceEagerSessionCreation = false;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (request.getAttribute(FILTER_APPLIED) != null) {
chain.doFilter(request, response);
return;
}
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
if (this.forceEagerSessionCreation) {
HttpSession session = request.getSession();
}
HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request, response);
SecurityContext contextBeforeChainExecution = this.repo.loadContext(holder);
try {
this.securityContextHolderStrategy.setContext(contextBeforeChainExecution);
//...
chain.doFilter(holder.getRequest(), holder.getResponse());
}
finally {
SecurityContext contextAfterChainExecution = this.securityContextHolderStrategy.getContext();
this.securityContextHolderStrategy.clearContext();
this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse()); //인증 정보 세션에 항상 저장
request.removeAttribute(FILTER_APPLIED);
}
}
}
그러나 항상 인증 정보를 세션에 저장하도록 설정하고 싶다면 다음과 같이 SecurityConfig
를 작성해주면 되고, 이 경우에는 SecurityContextHolderFilter
가 아닌 SecurityContextPersistenceFilter
가 필터 체인에 등록된다.
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.securityContext(config -> config.requireExplicitSave(false));
return http.build();
}
}
HeaderWriterFilter
HTTP 응답 헤더에 사용자 보호를 위한 시큐리티 관련 헤더를 추가해주는 필터로, 아래 설정을 통해 비활성화 할 수 있다. (커스텀 SecurityFilterChain
생성 시에도 자동 등록)
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.headers(config -> config.disable());
return http.build();
}
}
HeaderWriterFilter
코드를 살펴보면 헤더를 설정하는 타이밍이 요청 처리(filterChain.doFilter
호출) 전/후인지에 따라 각기 다른 메소드를 호출한다. (기본값은 false로 요청을 모두 처리 후 헤더를 설정) 그리고 실제 헤더에 write하는 책임은 HeaderWriter
리스트에게 위임한다.
public class HeaderWriterFilter extends OncePerRequestFilter {
private final List<HeaderWriter> headerWriters;
private boolean shouldWriteHeadersEagerly = false;
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (this.shouldWriteHeadersEagerly) {
this.doHeadersBefore(request, response, filterChain); //요청 처리 전
} else {
this.doHeadersAfter(request, response, filterChain); //요청 처리 후
}
}
private void doHeadersBefore(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
this.writeHeaders(request, response);
filterChain.doFilter(request, response);
}
private void doHeadersAfter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
HeaderWriterResponse headerWriterResponse = new HeaderWriterResponse(request, response);
HeaderWriterRequest headerWriterRequest = new HeaderWriterRequest(request, headerWriterResponse);
try {
filterChain.doFilter(headerWriterRequest, headerWriterResponse);
} finally {
headerWriterResponse.writeHeaders();
}
}
void writeHeaders(HttpServletRequest request, HttpServletResponse response) {
Iterator var3 = this.headerWriters.iterator();
while(var3.hasNext()) {
HeaderWriter writer = (HeaderWriter)var3.next();
writer.writeHeaders(request, response);
}
}
}
HeaderWriterFilter
가 동작함에 따라 기본 등록된 XContentTypeOptionsHeaderWriter
, XXssProtectionHeaderWriter
, CacheControlHeadersWriter
, HstsHeaderWriter
, XFrameOptionsHeaderWriter
들이 순서대로 동작해 아래 7개의 응답 헤더가 기본 설정된다.
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000 ; includeSubDomains
X-Frame-Options: DENY
X-XSS-Protection: 1; mode=block
물론 원하는 응답 헤더를 커스텀 설정할 수도 있다.
CorsFilter
먼저 Cors란? Corss-Origin Resource Sharing(교차 출처 리소스 공유)의 약자로, 출처가 다른 서버 간의 리소스 공유를 의미한다. 여기서 출처는 프로토콜, 도메인(호스트 이름), 포트로 구성되며 이 중 하나라도 다르다면 다른 출처로 간주된다. 브라우저는 본래 보안 상의 이유로 출처가 다른 리소스에 대한 접근을 차단하지만, 출처가 다르더라도 요청과 응답을 주고 받을 수 있도록 서버에 리소스 호출이 허용된 출처(Origin)을 명시해주는 것이 바로 Cors 정책이다. 참고
cors 에러를 방지하기 위해서는 허용하는 출처 관련 데이터를 CorsConfigurationSource
에 설정해주는 것이 필요하며, 이 설정값을 기반으로 CorsFilter
가 cors 관련 응답 헤더를 설정해주는 역할을 한다. CorsConfigurationSource
는 아래와 같이 스프링 빈으로 등록할 수도 있으며 여러 커스텀 SecurityFilterChain
을 설정하는 경우에는 configurationSource
메소드를 통해 직접 설정해줄 수도 있다. 아래 설정을 통해 이를 비활성화 할 수 있으며 커스텀 SecurityFilterChain
생성 시에도 자동 등록된다.
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.cors(
config -> config.disable()
// .configurationSource(corsConfigurationSource()) //이렇게도 설정 가능
);
return http.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("https://example.com"));
configuration.setAllowedMethods(Arrays.asList("GET","POST"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
스프링 시큐리티 뿐만 아니라 스프링 MVC에서도 아래와 같은 WebConfig
클래스 정의 또는 @CorssOrigin
어노테이션을 통해 cors 설정이 가능하다. 시큐리티, MVC 각 cors 설정의 동작 방식이 어떻게 다른지 아주 간단히만 보고 넘어가자.
@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://domain2.com")
.allowedMethods("PUT", "DELETE")
.allowedHeaders("header1", "header2", "header3")
.exposedHeaders("header1", "header2")
.allowCredentials(true).maxAge(3600);
}
}
MVC에서의 cors 설정은 스프링 MVC의 HandlerMapping
(AbstractHandlerMapping
클래스의 getHandler
메소드 내에서 getCorsHandlerExecutionChain
메소드를 호출하여 cors 설정)와 Interceptor
(CorsInterceptor
)가 동작하는 반면, Security에서의 cors 설정은 org.springframework.web.filter
패키지의 CorsFilter
가 동작한다. (스프링 MVC의 cors 설정이 나중에 동작하므로 MVC의 cors 설정이 우선하지 않을까 싶다... 확실하지 않음) 아무튼 스프링 시큐리티 의존성을 추가할 경우 MVC, Security 모두 cors 설정을 할 필요 없이 다음과 같이 설정해주면 시큐리티에서도 MVC의 cors 설정을 그대로 사용할 수 있다.
@EnableWebSecurity
@Configuration
public class WebSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// if Spring MVC is on classpath and no CorsConfigurationSource is provided,
// Spring Security will use CORS configuration provided to Spring MVC
.cors(Customizer.withDefaults());
return http.build();
}
}
마지막으로 CorsFilter
의 코드를 살펴보자. 필터가 동작하는 doFilterInternal
메소드 내에서 CorsProcessor
의 processRequest
메소드를 호출함으로써 cors 관련 응답 헤더를 설정한 후 현재 요청이 pre-flight 요청이 아니라면 다음 필터로 넘어가고 있다. (위에서 언급했듯 해당 필터는 스프링 시큐리티가 제공하는 필터가 아니기 때문에 시큐리티 의존성을 추가하지 않고도 사용할 수 있다.)
public class CorsFilter extends OncePerRequestFilter {
private final CorsConfigurationSource configSource;
private CorsProcessor processor = new DefaultCorsProcessor();
@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); //cors 관련 응답 헤더 설정
if (!isValid || CorsUtils.isPreFlightRequest(request)) {
return;
}
filterChain.doFilter(request, response);
}
}
public class DefaultCorsProcessor implements CorsProcessor {
//...
//cors 관련 응답 헤더 설정
@Override
@SuppressWarnings("resource")
public boolean processRequest(@Nullable CorsConfiguration config, HttpServletRequest request,
HttpServletResponse response) throws IOException {
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);
}
//...
}
CsrfFilter
먼저 CSRF 공격이란? Corss-Site Request Forgery(사이트 간 요청 위조)의 약자로, 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 하는 웹 취약점 공격 중 하나로, 세션 기반 로그인에서 주로 발생한다. 사이트 간 스크립팅(XSS)을 이용한 공격이 사용자가 특정 웹사이트를 신용하는 점을 노린 것이라면, 사이트간 요청 위조는 특정 웹사이트가 사용자의 웹 브라우저를 신용하는 상태를 노린 것이다. 사용자가 웹 사이트에 로그인한 상태에서 사이트간 요청 위조 공격 코드가 삽입된 페이지를 열면, 공격 대상이 되는 웹사이트는 위조된 공격 명령이 믿을 수 있는 사용자로부터 발송된 것으로 판단하여 공격에 노출된다. 참고
스프링 시큐리티의 CsrfFilter
의 검증 방식은 토큰 방식으로, csrf 공격 방어를 위해 HTTP 메소드 중 GET, HEAD, TRACE, OPTIONS 메소드를 제외한 요청에 대해 검증을 진행한다. 해당 요청에 대해 서버에 저장된 csrf 토큰이 없다면, csrf 토큰을 생성하여 서버 저장소에 저장하고 응답으로 csrf 토큰을 전달한다. 이후 클라이언트는 모든 요청에 csrf 토큰을 헤더(X-XSRF-TOKEN
) 또는 파라미터(_csrf
)로 함께 전달하고, 서버는 클라이언의 csrf 토큰과 서버에 저장된 토큰을 비교 검증한다. 마찬가지로 다음 설정을 통해 필터를 비활성화할 수 있으며 커스텀 SecurityFilterChain
을 생성해도 자동 등록된다.
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(config -> config.disable())
return http.build();
}
}
CsrfFilter
코드를 대략적으로 살펴보자면, 먼저 현재 요청 메소드가 GET, HEAD, TRACE, OPTIONS 중 하나인 경우 csrf 토큰 검증을 할 필요가 없으므로 바로 다음 필터로 넘어간다. 만약 csrf 토큰 검증이 필요하다면 서버에 저장된 csrf 토큰과 클라이언트가 요청으로 보낸 csrf 토큰을 각각 조회하여 둘의 값이 일치하는지 검증한다. 만약 일치하지 않는 경우 403(FORBIDDEN) 에러를 뱉는다.
public final class CsrfFilter extends OncePerRequestFilter {
public static final RequestMatcher DEFAULT_CSRF_MATCHER = new DefaultRequiresCsrfMatcher();
private static final String SHOULD_NOT_FILTER = "SHOULD_NOT_FILTER" + CsrfFilter.class.getName();
private final Log logger = LogFactory.getLog(this.getClass());
private final CsrfTokenRepository tokenRepository;
private RequestMatcher requireCsrfProtectionMatcher;
private AccessDeniedHandler accessDeniedHandler;
private CsrfTokenRequestHandler requestHandler;
//...
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
DeferredCsrfToken deferredCsrfToken = this.tokenRepository.loadDeferredToken(request, response); //토큰 저장소에서 csrf 토큰 조회
request.setAttribute(DeferredCsrfToken.class.getName(), deferredCsrfToken); //다음 요청을 위해 추가
CsrfTokenRequestHandler var10000 = this.requestHandler;
var10000.handle(request, response, deferredCsrfToken::get);
if (!this.requireCsrfProtectionMatcher.matches(request)) { //요청 메소드가 GET, HEAD, TRACE, OPTIONS 중 하나인 경우
filterChain.doFilter(request, response);
} else {
CsrfToken csrfToken = deferredCsrfToken.get(); //서버에 저장된 토큰
String actualToken = this.requestHandler.resolveCsrfTokenValue(request, csrfToken); //클라이언트가 요청으로 보낸 토큰
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) { //서버 토큰 != 클라이언트 토큰 인 경우
boolean missingToken = deferredCsrfToken.isGenerated();
AccessDeniedException exception = !missingToken ? new InvalidCsrfTokenException(csrfToken, actualToken) : new MissingCsrfTokenException(actualToken);
this.accessDeniedHandler.handle(request, response, (AccessDeniedException)exception);
} else {
filterChain.doFilter(request, response);
}
}
}
//...
private static boolean equalsConstantTime(String expected, String actual) {
if (expected == actual) {
return true;
} else if (expected != null && actual != null) {
byte[] expectedBytes = Utf8.encode(expected);
byte[] actualBytes = Utf8.encode(actual);
return MessageDigest.isEqual(expectedBytes, actualBytes);
} else {
return false;
}
}
private static final class DefaultRequiresCsrfMatcher implements RequestMatcher {
private final HashSet<String> allowedMethods = new HashSet(Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS"));
public boolean matches(HttpServletRequest request) {
return !this.allowedMethods.contains(request.getMethod());
}
}
}
참고로 클라이언트의 요청으로부터 csrf 토큰을 조회하는 CsrfTokenRequestHandler
의 resolveCsrfTokenValue
메소드를 보면, 요청 헤더 또는 파라미터로부터 csrf 토큰을 가져오는 것을 확인할 수 있다.
@FunctionalInterface
public interface CsrfTokenRequestHandler extends CsrfTokenRequestResolver {
void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken);
default String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
//요청 헤더 또는 파라미터로부터 csrf 토큰 get
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
return actualToken;
}
}
csrf 토큰의 생성 및 관리는 CsrfTokenRepository
라는 인터페이스 구현체에게 위임하며, 대표적인 구현체에는 HttpSessionCsrfTokenRepository
(세션 기반, 기본값), CookieCsrfTokenRepository
(쿠키 기반)가 있으며 아래와 같이 커스텀하게 설정 가능하다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(config -> config.csrfTokenRepository(new HttpSessionCsrfTokenRepository()))
return http.build();
}
기본 동작은 SSR(Server Side Rendering) 세션 기반으로 설정되어 있어, 뷰를 렌더링할 때 html form 태그의 hidden 타입의 input 태그에 csrf 토큰이 다음과 같이 포함되어야 한다. 이 경우 POST /some-endpoint 요청 시 csrf 토큰이 자동 포함되어 CsrfFilter
를 무사히 통과할 수 있다.
<html>
<head></head>
<body>
<form method="POST" action="/some-endpoint">
<input type="hidden"
name="_csrf"
value="IcxWu3KPl2VbRxPh_4vjMYBt4ohK16rFG-gN5wT0o9dkeeSXRfk33xO_old2fyLXnqbXBrIIz7B555joKt1uhmKQkbZXT9Oj"/>
</form>
</body>
@Controller
public class CsrfController {
@GetMapping("/csrf")
public String csrf(CsrfToken csrfToken, Model model) {
model.addAttribute("_csrf", csrfToken);
return "csrf.html"; //csrf 토큰값으로 뷰 렌더링
}
}
<html xmlns:th="http://www.thymeleaf.org">
<head></head>
<body>
<form method="POST" action="/some-endpoint">
<input type="hidden"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}"/>
</form>
</body>
</html>
반면 Stateless한 REST API에서는 해당 필터를 사용할 일이 거의 없는데, 그 이유는 JSESSION에 대한 서버 세션이 상태를 가지지 않아 csrf 공격 위험 자체가 없기 때문이다. 따라서 이 경우에는 csrf 설정을 disable 하는 것이 보편적이지만, JWT를 쿠키에 저장하는 경우에는 csrf 공격 위험이 있을 수 있기 때문에 활성화하는 것이 좋다. 이 경우에는 발급된 csrf 토큰을 클라이언트에게 전달해줄 수 있도록 별도 API가 필요하다. 아래 코드에서 GET /csrf 요청 시 응답값인 token을 클라이언트는 매 요청마다 헤더 또는 파라미터로 실어 보내야 한다.
@RestController
public class CsrfController {
@GetMapping("/csrf")
public CsrfToken csrf(CsrfToken csrfToken) {
return csrfToken;
}
}
{
"parameterName": "_csrf",
"token": "_NGSH1_BJ9cZsEMnC5Db2SGQBe0V9t1KaWsZP_84SA600oX4mOTzez7xEuU0iHIRar3v7hP1KNUmxu9nWF56Xplcem-H5LLM",
"headerName": "X-XSRF-TOKEN"
}
LogoutFilter
말 그대로 로그아웃을 수행하는 필터로 내부적으로 여러 로그아웃 핸들러를 의존하여 처리를 위임한다. 기본값으로 세션 기반 로그아웃이 구현되어 있어 JWT 방식이나 추가할 로직이 많은 경우 필터를 커스텀해야 한다. 마찬가지로 다음과 같이 추가 설정이 가능하며 커스텀 SecurityFilterChain
생성 시에도 자동 등록된다.
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.logout(config -> config.addLogoutHandler(new CompositeLogoutHandler()) //로그아웃 핸들러 추가
.logoutSuccessHandler(new SimpleUrlLogoutSuccessHandler()) //로그아웃 성공 핸들러 추가
.disable());
return http.build();
}
}
public class LogoutFilter extends GenericFilterBean {
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
private RequestMatcher logoutRequestMatcher;
private final LogoutHandler handler;
private final LogoutSuccessHandler logoutSuccessHandler;
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
if (this.requiresLogout(request, response)) { //로그아웃 요청인지 확인
Authentication auth = this.securityContextHolderStrategy.getContext().getAuthentication();
this.handler.logout(request, response, auth); //쿠키, 세션, 시큐리티 컨텍스트 등 삭제
this.logoutSuccessHandler.onLogoutSuccess(request, response, auth);
} else {
chain.doFilter(request, response);
}
}
protected boolean requiresLogout(HttpServletRequest request, HttpServletResponse response) {
if (this.logoutRequestMatcher.matches(request)) {
return true;
} else {
return false;
}
}
}
LogoutFilter
의 주요 로직은 로그아웃 요청인지 확인 → 로그아웃 처리 → 로그아웃 성공 후처리 순으로 진행된다. 실제 로그아웃 처리는 LogoutHandler
인터페이스의 구현체인 CompositeLogoutHandler
에게 위임하며, CompositeLogoutHandler
는 다시 여러 LogoutHandler
에게 처리를 위임하고 있다. 기본적으로 동작하는 핸들러에는 CsrfLogoutHandler
(csrf 설정이 활성화된 경우, csrf 토큰 저장소에 토큰을 null값으로 저장), SecurityContextLogoutHandler
(세션 및 SecurityContext
무효화), LogoutSuccessEventPublishingLogoutHandler
(LogoutSuccessEvent
를 발행)가 있다.
public final class CompositeLogoutHandler implements LogoutHandler {
private final List<LogoutHandler> logoutHandlers;
//...
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Iterator var4 = this.logoutHandlers.iterator();
while(var4.hasNext()) {
LogoutHandler handler = (LogoutHandler)var4.next();
handler.logout(request, response, authentication);
}
}
}
로그아웃 성공 후에는 URL 리다이렉션과 같은 로그아웃 성공 후처리 작업을 수행하기 위한 LogoutSuccessHandler
가 동작하며 기본 구현체에는 DelegatingLogoutSuccessHandler
, ForwardLogoutSuccessHandler
, HttpStatusReturningLogoutSuccessHandler
, SimpleUrlLogoutSuccessHandler
등이 있다.
UsernamePasswordAuthenticationFilter
말 그대로 유저의 아이디와 비밀번호로 인증하는 필터로, POST /login 경로에서 form 기반 인증을 진행할 수 있도록 multipart/form-data 형태의 username, password 데이터를 받아 실제 인증 클래스에게 값을 넘겨주는 역할을 수행한다. 커스텀 SecurityFilterChain
생성 시 자동 등록되지 않아 아래 구문을 통해 필터를 활성화해주어야 한다.
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.formLogin(Customizer.withDefaults());
return http.build();
}
}
UsernamePasswordAuthentication
필터에는 템플릿 메소드 패턴이 적용되어 있다. 따라서 기능의 골격이 정의된 부모 클래스 AbstractAuthenticationProcessingFilter
의 요청 처리 흐름을 먼저 살펴보자. 현재 요청이 인증이 필요한 요청인지 확인 후, 만약 인증이 필요하다면 UsernamePasswordAuthenticationFilter
에서 구현하고 있는 메소드 attemptAuthentication
를 호출하여 인증이 완료된 Authentication
객체를 반환받는다. 이후 다음 필터를 호출하거나 인증 결과에 따라 성공/실패 핸들러를 호출한다.
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
//실제 인증 처리 클래스들
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
protected ApplicationEventPublisher eventPublisher;
protected AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource = new WebAuthenticationDetailsSource();
private AuthenticationManager authenticationManager;
protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
private RememberMeServices rememberMeServices = new NullRememberMeServices();
private RequestMatcher requiresAuthenticationRequestMatcher;
private boolean continueChainBeforeSuccessfulAuthentication = false;
private SessionAuthenticationStrategy sessionStrategy = new NullAuthenticatedSessionStrategy();
private boolean allowSessionCreation = true;
private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();
private SecurityContextRepository securityContextRepository = new RequestAttributeSecurityContextRepository();
//...
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
if (!this.requiresAuthentication(request, response)) {
chain.doFilter(request, response); //인증이 필요하지 않은 요청이라면 다음 필터로 진행
} else {
try {
Authentication authenticationResult = this.attemptAuthentication(request, response); //UsernamePasswordAuthenticationFilter 구현 메소드 호출
if (authenticationResult == null) {
return;
}
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
this.successfulAuthentication(request, response, chain, authenticationResult); //인증 성공 핸들러 호출
} catch (InternalAuthenticationServiceException var5) {
InternalAuthenticationServiceException failed = var5;
this.unsuccessfulAuthentication(request, response, failed); //인증 실패 핸들러 호출
} catch (AuthenticationException var6) {
AuthenticationException ex = var6;
this.unsuccessfulAuthentication(request, response, ex); //인증 실패 핸들러 호출
}
}
}
protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) {
if (this.requiresAuthenticationRequestMatcher.matches(request)) {
return true;
} else {
return false;
}
}
//UsernamePasswordAuthenticationFilter에서 구현하는 메소드
public abstract Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException;
//인증 성공 처리
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
//SecurityContext 설정
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authResult);
this.securityContextHolderStrategy.setContext(context);
this.securityContextRepository.saveContext(context, request, response);
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.rememberMeServices.loginFail(request, response);
this.failureHandler.onAuthenticationFailure(request, response, failed);
}
}
UsernamePasswordAuthenticationFilter
에서 눈여겨 보아야 할 부분은 실제 인증 처리를 수행하는 attemptAuthentication
메소드이다. 코드를 보면 알겠지만 요청 객체 request
로부터 username
, password
를 조회한 후 아직 인증되지 않은 객체 UsernamePasswordAuthenticationToken
을 생성하여 AuthenticationManager
에게 인증 처리를 위임하고 있다.
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";
public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";
private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login", "POST");
private String usernameParameter = "username";
private String passwordParameter = "password";
private boolean postOnly = true;
//인증 처리 수행
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 : "";
//요청으로 전달된 username, password를 가진 미인증 객체 Authentication 생성
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
this.setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest); //인증 처리 위임
}
}
}
이렇게 실질적인 인증 처리는 AuthenticationManager
의 구현체에게 위임하며, UsernamePasswordAuthenticationFilter
는 단순히 인증의 시작점 역할을 할 뿐이다. 인증 관련 내부 로직을 상세히 이해하는 것도 중요한데, 이번 글의 주제와는 약간 벗어나므로 이에 대한 자세한 내용은 다음 글에서 스프링 시큐리티 로그인을 직접 구현해보면서 다루기로 하자.
추가로 왜 AbstractAuthenticationProcessingFilter
, UsernamePasswordAuthenticationFilter
에는 템플릿 메소드 패턴이 적용되었을까? 답변하기 전 일단 사용자 인증 처리의 대략적인 흐름은 다음과 같다.
인증이 필요한 요청인지 확인 → 인증 처리 후 Authentication 객체 반환 → 인증 성공/실패 핸들러 호출
username
, password
데이터를 form, JSON 방식 등으로 보내도 전체적인 처리 흐름은 동일하며, 이러한 흐름의 골격을 정의한 클래스가 바로 AbstractAuthenticationProcessingFilter
인 것이다. 그리고 추상화된 atteptAuthentication
메소드를 구현하여 username
, password
데이터를 어떤 방식으로 보내야 할지, 어떤 식으로 인증을 처리할지 구체적인 방식을 정의한 클래스가 UsernamePasswordAuthenticationFilter
인 것이다.
form 방식으로 받도록 구현된 UsernamePasswordAuthenticationFilter
이외에도 OAuth2 코드를 받도록 구현된 OAuth2LoginAuthenticationFilter
도 존재하며, 만약 json 방식으로 받도록 구현하고자 한다면 UsernamePasswordAuthenticationFilter
를 상속한 커스텀 필터를 정의하면 된다.
DefaultLoginPageGeneratingFilter
로그인 설정에 대해 GET /login 경로에 기본 로그인 페이지를 응답하는 필터이다. 여러 로그인 설정에 의존하며, 커스텀 로그인 페이지를 사용할 경우에는 자동 제외된다. 예를 들어 여러 로그인 설정 중 formLogin 기본값 설정 시 해당 필터가 자동 활성화 되며, loginPage 메소드를 통해 커스텀 로그인 페이지를 설정하지 않는 이상 기본 동작한다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.formLogin(Customizer.withDefaults())
// .formLogin(config -> config.loginPage("/login")) //이 경우에는 DefaultLoginPageGeneratingFilter 비활성화
return http.build();
}
DefaultLoginPageGeneratingFilter
코드는 아주 간단한데, 현재 요청이 GET /login 인지를 확인하고 html 코드를 생성하여 응답으로 반환한다. 참고로 generateLoginPageHtml
메소드에서는 현재 요청이 form, OAuth2, saml 방식인지에 따라 각기 다른 로그인 페이지를 생성한다. generateLoginPageHtml
메소드 구현 코드를 보면 참 재미있는데, html이 진짜 문자열로 하드 코딩되어 있다.
public class DefaultLoginPageGeneratingFilter extends GenericFilterBean {
public static final String DEFAULT_LOGIN_PAGE_URL = "/login";
public static final String ERROR_PARAMETER_NAME = "error";
//...
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
boolean loginError = this.isErrorPage(request);
boolean logoutSuccess = this.isLogoutSuccess(request);
if (!this.isLoginUrlRequest(request) && !loginError && !logoutSuccess) {
chain.doFilter(request, response);
} else {
String loginPageHtml = this.generateLoginPageHtml(request, loginError, logoutSuccess); //로그인 페이지 html 코드 생성
response.setContentType("text/html;charset=UTF-8");
response.setContentLength(loginPageHtml.getBytes(StandardCharsets.UTF_8).length);
response.getWriter().write(loginPageHtml);
}
}
//...
}
차라리 별도의 *.html 파일을 정의하고 컨트롤러 단에서 기본 로그인 페이지를 제공하면 이렇게 html을 하드 코딩하지 않아도 될 것이다. 그러나 만약 기본 로그인 페이지를 컨트롤러 단에서 제공할 경우 디폴트 컨트롤러(스프링 시큐리티가 기본 제공하는 로그인 페이지)와 커스텀 컨트롤러(개발자가 직접 정의)가 충돌할 수 있으며(둘다 GET /login으로 정의되는 경우) (내 피셜) 아직 이를 해결하지 못했기 때문에? 필터 단에서 html을 하드 코딩하여 제공할 수 밖에 없는 것 같다.
DefaultLogoutPageGeneratingFilter
GET /logout 경로에 대해 기본 로그아웃 페이지를 응답하는 필터로, 마찬가지로 여러 로그인 설정에 의존한다. (자체적인 활성/비활성 설정 불가능) 코드를 간단하게 살펴보면 현재 요청이 로그아웃 요청인지 판단 후 로그아웃 페이지를 렌더링한다. (DefaultLoginPageGeneratingFilter
와 동일하게 하드 코딩된 html을 response
객체에 write
하여 응답한다.)
public class DefaultLogoutPageGeneratingFilter extends OncePerRequestFilter {
private RequestMatcher matcher = new AntPathRequestMatcher("/logout", "GET");
private Function<HttpServletRequest, Map<String, String>> resolveHiddenInputs = (request) -> {
return Collections.emptyMap();
};
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (this.matcher.matches(request)) {
this.renderLogout(request, response); //로그아웃 페이지 요청이라면 페이지 렌더링
} else {
filterChain.doFilter(request, response);
}
}
//...
}
BasicAuthenticationFilter
말그대로 Basic 기반의 인증을 수행하는 필터로, 커스텀 SecurityFilterChain
생성 시 자동 등록되지 않으므로 다음과 같이 설정해주어야 한다.
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.httpBasic(Customizer.withDefaults());
return http.build();
}
}
먼저 Basic 인증이란? 말 그대로 가장 기본적인 인증 방식으로, Base64로 인코딩한 "사용자 ID:비밀번호" 문자열을 Basic-
prefix와 함께 인증 헤더에 입력하는 방식으로 인증을 진행한다. 서버는 요청에 대해 username/password만 일치하는지 확인 후 사용자를 기억하지 않기 때문에 매 요청마다 Authorization 헤더를 보내야 한다. 그러나 세션 기반 인증인 경우 스프링 시큐리티의 Basic 인증 로직은 매번 재인증을 요구하지 않고 세션에 값을 저장하여 유저를 기억한다. (이 부분이 어떻게 구현되어 있는지는 뒤에서 코드로 살펴보자.)
Authorization: Basic base64({USERNAME}:{PASSWORD})
참고로 Base64 인코딩은 쉽게 복호화가 가능하기 때문에 단순 Base64 인코딩된 사용자 ID, 비밀번호를 HTTP로 전달하면 보안에 매우 취약하기 때문에 Basic 인증을 사용하는 요청은 HTTPS, SSL/TLS로 통신해야 한다. 참고
public class BasicAuthenticationFilter extends OncePerRequestFilter {
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
private AuthenticationEntryPoint authenticationEntryPoint;
private AuthenticationManager authenticationManager;
private RememberMeServices rememberMeServices = new NullRememberMeServices();
private boolean ignoreFailure = false;
private String credentialsCharset = "UTF-8";
private AuthenticationConverter authenticationConverter = new BasicAuthenticationConverter();
private SecurityContextRepository securityContextRepository = new RequestAttributeSecurityContextRepository();
//...
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
Authentication authRequest = this.authenticationConverter.convert(request); //요청 Authorization 헤더에서 username, password 조회 후 인증 객체로 반환
if (authRequest == null) {
chain.doFilter(request, response);
return;
}
String username = authRequest.getName();
if (this.authenticationIsRequired(username)) { //SecurityContext에 해당 username이 이미 존재하는지 확인
Authentication authResult = this.authenticationManager.authenticate(authRequest); //인증 진행
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authResult);
this.securityContextHolderStrategy.setContext(context);
this.rememberMeServices.loginSuccess(request, response, authResult);
this.securityContextRepository.saveContext(context, request, response);
this.onSuccessfulAuthentication(request, response, authResult);
}
} catch (AuthenticationException var8) {
AuthenticationException ex = var8;
this.securityContextHolderStrategy.clearContext();
this.rememberMeServices.loginFail(request, response);
this.onUnsuccessfulAuthentication(request, response, ex);
if (this.ignoreFailure) {
chain.doFilter(request, response);
} else {
this.authenticationEntryPoint.commence(request, response, ex);
}
return;
}
chain.doFilter(request, response);
}
protected boolean authenticationIsRequired(String username) {
Authentication existingAuth = this.securityContextHolderStrategy.getContext().getAuthentication();
if (existingAuth == null || !existingAuth.getName().equals(username) || !existingAuth.isAuthenticated()) {
return true;
}
return (existingAuth instanceof AnonymousAuthenticationToken);
}
//...
}
BasicAuthenticationFilter
코드를 살펴보면, 먼저 converter를 통해 요청 헤더로부터 미인증 객체를 반환받는다. 이후 authenticationIsRequired
메소드를 통해 인자로 전달된 username
에 해당하는 인증 객체가 SecurityContext
에 존재하는지 확인한다. 만약 해당 인증 객체가 존재한다면 Basic 재인증을 수행하지 않으며, 없다면 UsernamePasswordAuthenticationFilter
와 비슷하게 AuthenticationManager
에게 인증 처리를 위임하여, 성공 시에는 SecurityContext
에 Authentication
객체를 저장하는 등의 후처리를 진행한다. 참고로 authenticationIsRequired
메소드에서 securityContextHolderStrategy.getContext()
에서 조회되는 SecurityContext
는, 앞선 필터인 SecurityContextHolderFilter
에서 세션 기반 인증인 경우 HttpSessionSecurityContextRepository
에 저장된 SecurityContext
를 조회하여 미리 세팅해둔 것이다.
HttpServletRequest
요청 Authorization 헤더로부터 username/password를 조회해 Authentication
객체로 변환해주는 BasicAuthenticationCoverter
의 주요 코드는 다음과 같다. 코드 상으로 쉽게 확인할 수 있는데, Authorization 헤더에서 Basic
prefix를 제거한 후 해당 토큰을 Base64로 디코딩하고 ':' 이 문자를 기준으로 username, password를 분리하여 UsernamePasswordAuthenticationToken
를 생성해 반환한다.
public class BasicAuthenticationConverter implements AuthenticationConverter {
public static final String AUTHENTICATION_SCHEME_BASIC = "Basic";
private AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource;
private Charset credentialsCharset;
//...
public UsernamePasswordAuthenticationToken convert(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header == null) {
return null;
} else {
header = header.trim();
if (!StringUtils.startsWithIgnoreCase(header, "Basic")) {
return null;
} else if (header.equalsIgnoreCase("Basic")) {
throw new BadCredentialsException("Empty basic authentication token");
} else {
byte[] base64Token = header.substring(6).getBytes(StandardCharsets.UTF_8);
byte[] decoded = this.decode(base64Token);
String token = new String(decoded, this.getCredentialsCharset(request));
int delim = token.indexOf(":");
if (delim == -1) {
throw new BadCredentialsException("Invalid basic authentication token");
} else {
UsernamePasswordAuthenticationToken result = UsernamePasswordAuthenticationToken.unauthenticated(token.substring(0, delim), token.substring(delim + 1));
result.setDetails(this.authenticationDetailsSource.buildDetails(request));
return result;
}
}
}
}
//...
}
RequestCacheAwareFilter
이전 HTTP 요청에서 처리할 작업이 있고 현재 요청에서 (아직 처리하지 못한) 이전 작업을 수행하기 위해 등록되는 필터로, 아래와 같이 비활성화 가능하다. (커스텀 SecurityFilterChain 생성 시 자동 등록)
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.requestCache(config -> config.disable());
return http.build();
}
}
예를 들어 '로그인하지 않은 사용자가 권한이 필요한 요청 → 권한 없음 예외 발생 → 핸들러에서 해당 요청 경로를 캐싱 → 스프링 시큐리티가 로그인 창을 띄우고 인증을 진행(세션 방식에서 디폴트 formLogin 설정 시) → 로그인 상태로 캐싱된 이전 요청 재실행' 이러한 실행 흐름을 구현하기 위해 RequestCacheAwareFilter
가 필요한 것이다.
위 실행 흐름 중 두번째 '권한 없음 예외 발생' 시 이러한 인증, 인가 예외는 뒤에서 살펴볼 ExceptionTranslationFilter
에서 처리하는데, 이 중 호출되는 sendStartAuthentication
메소드에서 requestCache
에 현재 권한이 필요한 요청 객체를 저장하고 있다.
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
this.securityContextHolderStrategy.setContext(context);
this.requestCache.saveRequest(request, response); //cookie, http session 등에 현재 요청을 저장
this.authenticationEntryPoint.commence(request, response, reason);
}
그리고 사용자가 다시 정상 로그인 후 요청을 보내 RequestCacheAwareFilter
를 거치게 되면, requestCache
에서 로그인 전 인증 예외가 발생한 요청이 있는지 확인하고, 존재한다면 해당 값으로 request
객체를 대체한다.
public class RequestCacheAwareFilter extends GenericFilterBean {
private RequestCache requestCache;
//...
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
//requestCache에서 이전 요청에 캐싱해둔 데이터가 있는지 조회
HttpServletRequest wrappedSavedRequest = this.requestCache.getMatchingRequest((HttpServletRequest)request, (HttpServletResponse)response);
//캐싱된 요청이 존재할 경우 reqeust 객체 대체
chain.doFilter((ServletRequest)(wrappedSavedRequest != null ? wrappedSavedRequest : request), response);
}
}
예를 들어 유튜브의 A 영상을 보고 있다가 일시 정지 후 아주 오랜 기간동안 해당 페이지에 재접속하지 않으면 로그인이 풀리게 된다. 이때 새로고침 버튼을 누르면 로그인 페이지로 이동하는데, 로그인 성공 후 유튜브 홈이 아닌 기존에 보고 있던 A 영상으로 이동하게 되는 원리가 바로 RequestCacheAwareFilter
인 것이다.
SecurityContextHolderAwareRequestFilter
ServletRequest
요청에 스프링 시큐리티 API를 다룰 수 있는 메소드를 추가하기 위해 존재하는 필터이다. (커스텀 SecurityFilterChain
생성 시에도 기본 등록)
'ServletRequest
요청에 스프링 시큐리티 API를 다룰 수 있는 메소드를 추가'가 무슨 말인가 싶을 텐데, 일단 SecurityContextHolderAwareRequestFilter
코드를 먼저 살펴보자. 주요한 부분은 requestFactory
의 create
메소드를 통해 현재 request
객체를 대체하고 있는 부분이다. 이때 반환되는 request
객체는 Servlet3SecurityContextHolderAwareRequestWrapper
(HttpServlet3RequestFactory
클래스의 정적 내부 클래스)클래스 타입으로, authentication
, login
, logout
등의 시큐리티 API를 제공하고 있다. (약간 TMI이긴 한데 Servlet3SecurityContextHolderAwareRequestWrapper
의 authentication
, login
, logout
메소드 구현을 살펴보면, 아래 createServlet3Factory
메소드에서 설정해주고 있는 AuthenticationManager
, LogoutHandler
등에게 처리를 위임하는 것을 확인할 수 있다.)
public class SecurityContextHolderAwareRequestFilter extends GenericFilterBean {
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
private String rolePrefix = "ROLE_";
private HttpServletRequestFactory requestFactory;
private AuthenticationEntryPoint authenticationEntryPoint;
private AuthenticationManager authenticationManager;
private List<LogoutHandler> logoutHandlers;
private AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();
private SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
chain.doFilter(this.requestFactory.create((HttpServletRequest)req, (HttpServletResponse)res), res);
}
private void updateFactory() {
String rolePrefix = this.rolePrefix;
this.requestFactory = this.createServlet3Factory(rolePrefix); //요청 팩토리 초기화
}
private HttpServletRequestFactory createServlet3Factory(String rolePrefix) {
HttpServlet3RequestFactory factory = new HttpServlet3RequestFactory(rolePrefix, this.securityContextRepository);
factory.setTrustResolver(this.trustResolver);
factory.setAuthenticationEntryPoint(this.authenticationEntryPoint);
factory.setAuthenticationManager(this.authenticationManager);
factory.setLogoutHandlers(this.logoutHandlers);
factory.setSecurityContextHolderStrategy(this.securityContextHolderStrategy);
return factory;
}
}
따라서 이 필터 덕분에 아래와 같은 구현이 가능하다.
@RestController
public class SecurityContextHolderAwareRequestController {
@GetMapping("/test")
public void test(HttpServletRequest request, HttpServletResponse response) throws Exception {
request.authenticate(response); //인증 여부 확인
request.login("username", "password"); //AuthenticationManager를 통해 인증 진행
request.logout(); //로그아웃 핸들러 호출
AsyncContext asyncContext = request.startAsync(); //Callable를 사용하여 비동기 처리 진행 시 기존의 SecurityContext를 복사
asyncContext.start(() -> {
SecurityContext securityContext = SecurityContextHolder.getContext(); //동일한 SecurityContext에 접근 가능
});
}
}
AnonymousAuthenticationFilter
최초 접속으로 기존의 인증 정보가 없고, 해당 필터까지 인증을 하지 않았을 경우 익명 사용자 데이터를 설정해주는 필터이다. 즉 여러 필터를 거치면서 해당 필터까지 SecurityContext
값이 null인 경우 anonymous 값을 넣어주는 역할을 하며, 커스텀 SecurityFilterChain
생성 시에도 기본 등록된다. 아래의 defaultWithAnonymous
메소드를 보면 현재 필터까지 SecurityContext
의 Authentication
객체가 null인 경우 익명 인증 객체(AnonymousAuthenticationToken
)를 생성하여 SecurityContext
에 설정해주고 있다.
public class AnonymousAuthenticationFilter extends GenericFilterBean implements InitializingBean {
private SecurityContextHolderStrategy securityContextHolderStrategy;
private AuthenticationDetailsSource<HttpServletRequest, ?> authenticationDetailsSource;
private String key;
private Object principal;
private List<GrantedAuthority> authorities;
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
Supplier<SecurityContext> deferredContext = this.securityContextHolderStrategy.getDeferredContext();
this.securityContextHolderStrategy.setDeferredContext(this.defaultWithAnonymous((HttpServletRequest)req, deferredContext));
chain.doFilter(req, res);
}
private Supplier<SecurityContext> defaultWithAnonymous(HttpServletRequest request, Supplier<SecurityContext> currentDeferredContext) {
return SingletonSupplier.of(() -> {
SecurityContext currentContext = (SecurityContext)currentDeferredContext.get();
return this.defaultWithAnonymous(request, currentContext);
});
}
private SecurityContext defaultWithAnonymous(HttpServletRequest request, SecurityContext currentContext) {
Authentication currentAuthentication = currentContext.getAuthentication();
//인증 객체가 null인 경우
if (currentAuthentication == null) {
Authentication anonymous = this.createAuthentication(request);
SecurityContext anonymousContext = this.securityContextHolderStrategy.createEmptyContext();
anonymousContext.setAuthentication(anonymous); //익명 인증 객체 설정
return anonymousContext;
} else {
return currentContext;
}
}
protected Authentication createAuthentication(HttpServletRequest request) {
AnonymousAuthenticationToken token = new AnonymousAuthenticationToken(this.key, this.principal, this.authorities);
token.setDetails(this.authenticationDetailsSource.buildDetails(request));
return token;
}
}
참고로 AnonymousAuthenticationToken
객체 생성 시 전달되는 값은 중단점을 찍어 확인할 수 있는데, 기본 username 값은 anonymousUser
, 기본 role은 ROLE_ANONYMOUS
이다.
ExceptionTranslationFilter
해당 필터 이후에 발생하는 인증, 인가 관련 예외를 핸들링하기 위해 사용되는 필터로, 커스텀 SecurityFilterChain
생성 시에도 기본 등록된다. 참고로 ExceptionTranslationFilter
필터 이전에 발생한 예외는 처리 불가능한데, 이는 자바의 예외 전파 방식을 생각해보면 당연하다. (a 메소드에서 b 메소드 호출 → b 메소드에서 예외 발생 → b 메소드 내부에서 예외 처리하지 않는다면, a 메소드로 예외 전파)
public class ExceptionTranslationFilter extends GenericFilterBean implements MessageSourceAware {
private SecurityContextHolderStrategy securityContextHolderStrategy;
private AccessDeniedHandler accessDeniedHandler;
private AuthenticationEntryPoint authenticationEntryPoint;
private AuthenticationTrustResolver authenticationTrustResolver;
private ThrowableAnalyzer throwableAnalyzer;
private final RequestCache requestCache;
protected MessageSourceAccessor messages;
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
this.doFilter((HttpServletRequest)request, (HttpServletResponse)response, chain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
try {
chain.doFilter(request, response);
} catch (IOException var7) {
IOException ex = var7;
throw ex;
} catch (Exception var8) {
Exception ex = var8;
Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
RuntimeException securityException = (AuthenticationException)this.throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain);
if (securityException == null) {
securityException = (AccessDeniedException)this.throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
}
if (securityException == null) {
this.rethrow(ex);
}
if (response.isCommitted()) {
throw new ServletException("Unable to handle the Spring Security Exception because the response is already committed.", ex);
}
//실질적인 예외 처리
this.handleSpringSecurityException(request, response, chain, (RuntimeException)securityException);
}
}
private void rethrow(Exception ex) throws ServletException {
if (ex instanceof ServletException) {
throw (ServletException)ex;
} else if (ex instanceof RuntimeException) {
throw (RuntimeException)ex;
} else {
throw new RuntimeException(ex);
}
}
private void handleSpringSecurityException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, RuntimeException exception) throws IOException, ServletException {
if (exception instanceof AuthenticationException) { //인증 예외 처리
this.handleAuthenticationException(request, response, chain, (AuthenticationException)exception);
} else if (exception instanceof AccessDeniedException) { //인가 예외 처리
this.handleAccessDeniedException(request, response, chain, (AccessDeniedException)exception);
}
}
private void handleAuthenticationException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException exception) throws ServletException, IOException {
this.sendStartAuthentication(request, response, chain, exception); //재인증 요청
}
private void handleAccessDeniedException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AccessDeniedException exception) throws ServletException, IOException {
Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
boolean isAnonymous = this.authenticationTrustResolver.isAnonymous(authentication);
if (!isAnonymous && !this.authenticationTrustResolver.isRememberMe(authentication)) {
this.accessDeniedHandler.handle(request, response, exception);
} else {
this.sendStartAuthentication(request, response, chain, new InsufficientAuthenticationException(this.messages.getMessage("ExceptionTranslationFilter.insufficientAuthentication", "Full authentication is required to access this resource")));
}
}
protected void sendStartAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, AuthenticationException reason) throws ServletException, IOException {
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
this.securityContextHolderStrategy.setContext(context);
this.requestCache.saveRequest(request, response);
this.authenticationEntryPoint.commence(request, response, reason);
}
}
handleSpringSecurityException
메소드에서 실질적인 인증, 인가 예외를 처리하며 다시 해당 메소드에서 인증 예외인지, 인가 예외인지에 따라 분기 처리를 한다. 앞선 RequestCacheAwareFilter
에서 언급한 sendStartAuthentication
메소드를 인증 예외 발생 시 호출하고 있다.
AuthorizationFilter
SecurityFilterChain
의 authorizeHttpRequests()
를 통해 설정된 인가 값에 따라 최종적으로 인가를 수행하는 필터로, 커스텀 SecurityFilterChain
생성 시에도 기본적으로 등록되며, 다음과 같이 인가 값을 설정할 수 있다.
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(registry -> registry
.requestMatchers("/error", "/favicon.ico").permitAll() //해당 요청 인증, 인가 필요 없음
.requestMatchers(EMAIL_AUTH_API_PATH, SNS_SIGN_UP_API_PATH, LOGOUT_API_PATH).hasRole("GUEST") //해당 요청 ROLE_GUEST 이상인 유저만 요청 가능
.anyRequest().hasRole("USER") //이외 요청 ROLE_USER 이상인 유저만 요청 가능
)
return http.build();
}
}
doFilter
의 앞부분은 부가적인 처리이고 중간의 authorizationManager
에게 authentication
, request
객체를 전달하여 인가 작업을 위임하고 있다. 만약 인가 권한이 없다면 AccessDeniedException
예외를 throw 하며(해당 예외는 ExceptionTranslationFilter
에서 처리), 권한이 있다면 다음 필터를 호출하고 있다. 여기서 중요한건? 인가 작업을 위해 해당 필터에서만큼은 SecurityContext
에 Authentication
객체가 null이 아닌 값으로 존재해야 한다는 것이다. 그래서 거의 직전인 AnonymousAuthenticationFilter
에서 이를 설정해주는 작업을 하나보다.
public class AuthorizationFilter extends GenericFilterBean {
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder.getContextHolderStrategy();
private final AuthorizationManager<HttpServletRequest> authorizationManager;
private AuthorizationEventPublisher eventPublisher = AuthorizationFilter::noPublish;
private boolean observeOncePerRequest = false;
private boolean filterErrorDispatch = true;
private boolean filterAsyncDispatch = true;
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws ServletException, IOException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
//특정 설정이 enable이고 이 필터가 이번 요청에서 이미 사용되었다면 건너뜀
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); //최종적으로 모든 작업 처리 후 사용 기록 삭제
}
}
}
private boolean skipDispatch(HttpServletRequest request) {
if (DispatcherType.ERROR.equals(request.getDispatcherType()) && !this.filterErrorDispatch) {
return true;
} else {
return DispatcherType.ASYNC.equals(request.getDispatcherType()) && !this.filterAsyncDispatch;
}
}
private boolean isApplied(HttpServletRequest request) {
return request.getAttribute(this.getAlreadyFilteredAttributeName()) != null;
}
private String getAlreadyFilteredAttributeName() {
String name = this.getFilterName();
if (name == null) {
name = this.getClass().getName();
}
return name + ".APPLIED";
}
private Authentication getAuthentication() {
Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
if (authentication == null) {
throw new AuthenticationCredentialsNotFoundException("An Authentication object was not found in the SecurityContext");
} else {
return authentication;
}
}
}
커스텀 SecurityFilterChain 필터 생성 및 등록
스프링 시큐리티 필터 체인에 커스텀 필터를 추가하기 위해서는 먼저 GenericFilterBean
또는 OncePerRequestFilter
클래스를 구현한 커스텀 Filter
정의해야 한다. 앞서 말했듯 클라이언트 요청에 대해 필터를 여러번 통과하더라도 딱 한번만 동작하도록 하고 싶다면 OncePerRequestFilter
를 구현하면 된다.
public class CustomFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
chain.doFilter(request, response);
}
}
이후 SecurityConfig
설정 클래스에 커스텀 필터를 다음과 같이 추가해준다.
@Configuration
public class CustomWebSecurityConfigurerAdapter {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.addFilterAfter(new CustomFilter(), BasicAuthenticationFilter.class);
return http.build();
}
}
기존 필터에 커스텀 필터를 등록할 수 있는 api는 다음과 같다.
- addFilterBefore(filter, class)
: 기존 시큐리티 필터 전에 커스텀 필터 추가
- addFilterAfter(filter, class)
: 기존 시큐리티 필터 후에 커스텀 필터 추가
- addFilterAt(filter, class)
: 기존 시큐리티 필터의 위치에 커스텀 필터 추가 (기존 필터를 replace하지 않음)
- addFilter(filter)
: 커스텀 필터는 기존 시큐리티 필터의 인스턴스이거나 이를 상속해야 하며, 기존 필터의 위치에 추가 (기존 필터를 replace하지 않음)
특정 요청은 시큐리티 필터 제외
특정 요청은 시큐리티 필터 체인을 아예 거치지 않도록 설정해주고 싶다면, 다음과 같이 WebSecurityCustomizer
빈을 등록해주면 된다. 이 경우 해당 경로의 RequestMatcher
를 가진 SecurityFilterChain
이 인덱스 0번으로 자동 등록되며 이 필터 체인은 내부적으로 List<Filter>
가 없다.(즉 빈 리스트) 이 설정은 SecurityFilterChain
빈 등록 시의 authorizeHttpRequests
의 permitAll
설정과 달리 시큐리티 필터 체인을 아예 타지 않으므로 인증이 필요 없는 요청을 더 빠르게 처리할 수 있다는 장점이 있다.
@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring().requestMatchers("/img/**");
}
}
이렇게 기본 등록되는 필터들 이외에도 SessionManagementFilter
, RememberMeAuthenticationFilter
등 중요한 역할을 하는 필터들이 있다. 다 알아보면 좋겠지만 글이 너무 길어지기도 하고 각 필터들은 하나의 글로 정리하는게 더 좋을 것 같아 일단 여기까지 이번 글을 마무리하려고 한다.
글을 단순히 읽고 끝내기 보다는 실제 코딩을 하면서 기존 필터를 내 입맛대로 커스텀해보고 이해되지 않는 부분이 있다면 공식 문서를 읽거나 실제 코드를 까보는 것이 도움이 많이 된다. 이 글은 시큐리티 필터에 대한 대략적인 그림을 그리는 용도로 사용하고 본격적인 공부는 공식 문서를 활용하면 좋을 듯 하다. 공부하다보니 스프링 시큐리티 너어무 복잡하다...
끝.
'Spring > Security & OAuth2' 카테고리의 다른 글
[Spring Security] 스프링 시큐리티의 핵심 필터 DelegatingFilterProxy, FilterChainProxy (0) | 2024.03.13 |
---|
- Total
- Today
- Yesterday
- rest api
- 서블릿 컨테이너
- spring
- 디자인 패턴
- junit5
- Assertions
- Gitflow
- Java
- Linux
- QueryDSL
- vscode
- 단위 테스트
- 전략 패턴
- C++
- github
- Transaction
- FrontController
- 템플릿 콜백 패턴
- servlet filter
- Spring Security
- spring aop
- spring boot
- SSE
- facade 패턴
- ParameterizedTest
- Front Controller
- JPA
- mockito
- 모두의 리눅스
- Git
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 |