다루는 내용
1. WebAsyncManagerIntegrationFilter
2. @Async 와 SpringSecurity
3. SecurityContextPersistenceFilter
4. 요청을 처리하고 SecurityContextHolder 를 비워주는 이유
SpringMVC Async Hanlder 를 지원하기 위한 필터이다.
SecurityContextHolder 는 thread local 을 사용해 securityContext를 관리하는 것은 앞서서 살펴보았다.
동일한 thread 에서만 securityContext 가 공유되기 때문에 callable 을 사용한 async 한 handler 가 핸들러를 사용할 경우, 내부에서 사용되는 thread 가 SecurityContext 를 사용할 수 있게끔 도와주는 필터이다.
preprocessing 과정에서 새로만든 thread 에 SecurityContext를 공유하는 작업이 일어나고, postprocessing 과정에서 SecurityContext 를 비워주는 작업이 일어난다.
코드를 통해 살펴보면 다음과 같다.
public class SecurityLogger {
public static void log(String message){
System.out.println(message);
Thread thread = Thread.currentThread();
System.out.println("Thread : " + thread.getName());
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
System.out.println("Principal : " + principal);
System.out.println("===================");
}
}
------------------------------------------------------
<<<<Spring MVC Controller 를 구현한 클래스에서 aysncHanlder 핸들러만 뽑아왔습니다. >>>>
@GetMapping("/async-handler")
@ResponseBody
public Callable<String> aysncHanlder(){
SecurityLogger.log("MVC");
/* callable 을 사용하면 call() 을 처리하기전에 Request 를 처리하던 thread 를 반환한다.
그리고 call() 의 동작이 완료되면 응답을 보내준다.
*/
return new Callable<String>() {
@Override
public String call() throws Exception {
SecurityLogger.log("Callable");
return "Aysnc Handler";
}
};
}
위 코드의 결과는 다음과 같다
MVC
Thread : http-nio-8080-exec-1
Principal : org.springframework.security.core.userdetails.User [Username=jaden, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]]
===================
Callable
Thread : task-3
Principal : org.springframework.security.core.userdetails.User [Username=jaden, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]]
===================
task-3 thread 에서도 principal 이 공유되고 있음을 확인할 수 있다. 이것이 가능하게 하는 필터가 바로 WebAsyncManagerIntegrationFilter 이다.
필터와 관련된 이야기는 아니지만, 위에서 비동기처리와 관련된 문제를 다뤘음으로, 이어서 설명하도록 하겠다.
@Async
public void asyncService() {
SecurityLogger.log("Async Service");
System.out.println("Async Service is called");
}
다음과 같이 @Aysnc 어노테이션을 통해 해당 메서드가 호출될때, 별도의 thread 를 생성해 비동기적으로 동작하게 한다고 생각하자.
물론 @Async 만 붙인다고 비동기적으로 동작하진 않음으로, @EnableAsync 를 설정 클래스에 추가해주었다.
그리고 다음과 같은 핸들러를 생성하여 확인해보도록 하자.
(여기서 사용한 SecurityLogger.log()는 바로 위에 사용한 것과 같은것임)
@GetMapping("/async-service")
@ResponseBody
public String asyncService(){
SecurityLogger.log("MVC, before async service");
sampleSerivce.asyncService();
SecurityLogger.log("MVC, after async service");
return "Async Service";
}
결과는
MVC, before async service
Thread : http-nio-8080-exec-5
Principal : org.springframework.security.core.userdetails.User [Username=jaden, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]]
===================
MVC, after async service
Thread : http-nio-8080-exec-5
Principal : org.springframework.security.core.userdetails.User [Username=jaden, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]]
===================
Async Service
Thread : task-1
2020-12-20 15:49:27.174 ERROR 47831 --- [ task-1] .a.i.SimpleAsyncUncaughtExceptionHandler : Unexpected exception occurred invoking async method: public void me.summerbell.springsecurity.form.SampleSerivce.asyncService()
java.lang.NullPointerException: null
SecurityLogger.log("MVC, before async service");
sampleSerivce.asyncService();
SecurityLogger.log("MVC, after async service");
에서 sampleSerivce.asyncService(); 가 비동기적으로 동작함으로 위 코드에서
sampleSerivce.asyncService();
SecurityLogger.log("MVC, after async service");
의 순서는 보장할 수 가 없다 그래서
SecurityLogger.log("MVC, after async service");
가 먼저 찍히는 것을 확인할 수 있다.
그런데 async 한 동작을 위해 생성된 쓰레드 내부에서 Principal 이 null 이라
NPE 가 발생한것을 확인할 수 있다. 기본적으로 thread local 한 전략을 사용하기 때문에 발생한 이슈인데 이를 해결하기 위해선 스프링 시큐리티 설정을 바꿔주면 된다.
protected SecurityConfig() {
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
}
MODE_INHERITABLETHREADLOCAL 이름에서 알 수 있듯이 쓰레드에서 생겨난 쓰레드에 대해서도 SecurityContext 를 공유하게 해주는 전략을 제공한다.
위와 같은 설정으로 변경하고 실행해보면
MVC, before async service
Thread : http-nio-8080-exec-5
Principal : org.springframework.security.core.userdetails.User [Username=jaden, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]]
===================
MVC, after async service
Thread : http-nio-8080-exec-5
Principal : org.springframework.security.core.userdetails.User [Username=jaden, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]]
===================
Async Service
Thread : task-1
Principal : org.springframework.security.core.userdetails.User [Username=jaden, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]]
===================
Async Service is called
다음과 같이 잘 task-1 thread 에서도 principal 을 공유할 수 있음을 확인할 수 있다.
이 필터는 여러 요청간 SecurityContext 를 공유할 수 있도록 하는 filter 이다. 쉽게 말해, 로그인 이후에는 요청마다 또 인증을 하지 않고 기존에 로그인된 정보를 활용해 인증할 수 있게끔 도와주는 filter 이다.
SecurityContextRepository 를 활용하여 이와같은 일을 할 수 있도록 해주는데, 기본 구현체로 HttpSessionSecurityContextRepository 를 사용하고 있다.
HttpSessionSecurityContextRepository 는 이름처럼 Http Session을 활용하는데 최초요청에 의해 인증된 SecurityContext 정보를 session 에 저장해두었다가 다음 요청이 들어오면 session의 SecurityContext 을 이용하여 인증을 처리하고 이 과정이 바로 SecurityContextPersistenceFilter 에서 이루어 지는 것이다.
또 한, 요청처리가 끝난 이후에 이 필터에서 SecurityContextHolder 를 비워주는 역할도 하고 있다.
다음은 SecurityContextPersistenceFilter 의 doFilter() 내부의 일부 코드이다.
SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
// Crucial removal of SecurityContextHolder contents before anything else.
SecurityContextHolder.clearContext();
this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
---------- 코드 분석
SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();
--> 사용된 SecurityContext를 SecurityContextHolder 에서 가져오고
SecurityContextHolder.clearContext();
--> SecurityContextHolder 를 비워준 다음
this.repo.saveContext(contextAfterChainExecution, holder.getRequest(), holder.getResponse());
--> 가져온 securityContext를 SecurityContextRepository 에 저장한다.
여기서, 잠시 왜 SecurityContextHolder 를 비워주는지에 대해 생각해보자
SecurityContextHolder 는 ThreadLocal로 SecurityContext를 관리한다고 했다. 서블릿 기반의 웹프로그램은 요청마다 쓰레드를 생성하게 된다.
요청이후 다음 요청에서는 이전 쓰레드에서 만든 SecurityContext를 사용할 수 없다는 뜻이다.
그럼 쓰레드마다 SecurityContext가 생겨날텐데, 이 자원을 반납하지 않으면 쓰레드는 사라졌는데 해당자원은 남아 메모리 누수가 생기게 될 것이라는 것을 예상할 수 있다. 그럼으로 SecurityContextHolder 는 요청에 대한 작업이 끝나면 비워줘야한다.
*참고: 이 포스팅은 백기선님의 스프링시큐리티 강의를 듣고, 제 나름대로 다시 공부하고 정리한 포스팅 입니다. 만약, 조금 더 자세한 내용을 듣고 싶으시다면 이곳의 강의를 참고해보시기 바랍니다