Spring Security: SecurityContextHolder 의 Thread 공유 전략

다수 정보를 리스트로 조회하는 페이지에서 현재 로그인한 유저가 가진 권한에 따라 일부 정보를 보여주지 않도록하는 처리가 필요했습니다. 그래서 먼저 리스트를 API 로부터 가져온 뒤, 현재 Spring Security 로그인 세션에 저장되어있는 권한을 통해 일부 정보를 필터링하여 최종적으로 조회 페이지에 렌더링하도록 작업하였었습니다.

하지만 이상하게 리스트에 노출되는 Row 가 총 10개라면 2 ~ 3개 약 1/4 에 해당하는 Row 만 해당 ‘세션 권한 필터링’ 로직이 적용되었고 나머지 3/4 에 대해서는 적용되지 않는 버그를 발견하였습니다. 심지어 1/4 에 해당하는 2 ~ 3개는 변칙적으로 계속 변경되는것이었습니다. 예를 들면 새로고침 한번에 2번째 3번째 Row 에만 ‘세션 권한 필터링’ 이 적용되었다가, 새로고침을 한번 더 하면 5번째 6번째 Row 에 ‘세션 권한 필터링’이 적용되는것입니다. 마치 슈뢰딩거의 고양이처럼요…

구현은 다음과 같았습니다.

1
2
3
4
5
6
7
8
List<SomeInformation> list = someApi.retreive(condition);
list.parallelStream()
.forEach(each -> {
if (!SecurityHelper.hasRole("ROLE_CAN_SEE_SENSITIVE_NUMBERS")) {
each.setSensitiveNumber1(null);
each.setSensitiveNumber2(null);
}
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class SecurityHelper {

public static boolean hasRole(String role) {
SecurityContext context = SecurityContextHolder.getContext();
if (Objects.isNull(context))) {
return false;
}
Authentication authentication = context.getAuthentication();
if (Objects.isNull(auth))) {
return false;
}
for (GrantAuthority eachAuthority : authentication.getAuthorities()) {
if (role.equals(eachAuthority.getAuthority())) {
return true;
}
}
return false;
}
}

실제로는 테스트했던 로그인 계정에 ROLE_CAN_SEE_SENSITIVE_NUMBERS 권한이 있었기 때문에, 리스트의 모든 Row 들에 sensitiveNumber1, 2 모두 정상 노출되는것이 맞습니다. 하지만 1/4만 노출되는건 아무리 생각해도 이상하여 parallelStream.forEach 내부에 로그를 추가하였더니 아래와 같은 결과가 나왔습니다.

1
2
3
4
5
6
7
8
9
10
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-3] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-2] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-7] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-1] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [http-nio-80-exec-3] [TEST] hasRole: true
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-4] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-5] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-6] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [ForkJoinPool.commonPool-worker-2] [TEST] hasRole: false
INFO 2021-01-01 00:00:01 [http-nio-80-exec-3] [TEST] hasRole: true

보아하니 ForkJoinPool 즉, ParallelStream 실행을 위해 할당된 하위 Thread(ForkJoinPool.commonPool-worker-1~7) 에서는 hasRole이 비정상적으로 false값을 반환하고, 메인 Thread(http-nio-80-exec-3) 에서는 hasRole이 정상적으로 true값을 반환하는걸 알 수 있습니다.

무언가 ParallelStream 과 SecurityContextHolder 혼용이 문제인것으로 보입니다.


SecurityContextHolder 의 Thread 간 공유 모드

ParallelStream 의 Thread 에서 hasRole = false 가 반환됐던 1차 원인은 SecurityContext context = SecurityContextHolder.getContext(); 호출시 null이 반환되고 있었습니다. 반면 메인 Thread 에서 SecurityContextHolder.getContext() 호출시에는 정상적으로 세션 데이터를 가져올 수 있었고, hasRole 에 알맞은 비교 로직까지 수행할 수 있었습니다. 알아보니 아래와 같은 사실을 발견했습니다.

SecurityContextHolderSecurityContext 로그인 세션 정보를 어떤 레벨의 Thread 까지 공유할지 모드를 지정하도록 되어있습니다. 기본값으로는 MODE_THREADLOCAL로써 SecurityContext 정보는 “메인 Thread” 에서만 볼 수 있습니다.

총 공유 모드는 3가지로 나뉘어져있습니다.

  1. MODE_THREADLOCAL: (Default) Local Thread 에서만 공유 가능
  2. MODE_INHERITABLETHREADLOCAL: Local Thread 에서 생성한 하위 Thread 에까지 공유 가능
  3. MODE_GLOCAL: 모든 Thread, 어플리케이션 전체에서 공유 가능

기본 모드MODE_THREADLOCAL 였기에, 아무런 설정도 하지 않았던 서버에서는 메인 Thread(http-nio-80-exec-3)에서만 SecurityContext 가 반환되었던고, 나머지 하위 Thread(ForkJoinPool.commonPool-worker-1~7)에서는 null 이 반환되었던것입니다.

Conclusion

SecurityContextHolder 의 기본 설정은 SecurityContext 정보를 Local Thread 만 공유하도록 되어있기 때문에 SecurityContextHolder 를 직접 하위 Thread 안에서 호출하여 사용하는것보다, 메인 Thread 에서 호출하여 해당 값을 하위 Thread 에서 참조하도록 하는것이, 성능적으로나 가시적으로도 더 깔끔한 코드가 될것입니다. ParallelStream 혹은 Async 관련된 기능을 사용 시 하위 Thread 에서 SecurityContextHolder 를 사용해야하는 경우가 있다면 SecurityContextHolder 의 공유 모드를 MODE_INHERITABLETHREADLOCAL 로 낮추는것을 고려해야합니다.


출처:

  1. Spring Security - SecurityContextHolder Strategy:
    http://ncucu.me/116

Spring Security: SecurityContextHolder 의 Thread 공유 전략

https://aaronryu.github.io/2021/03/14/thread-and-security-context-holder-mode/

Author

Aaron Ryu

Posted on

2021-03-14

Updated on

2021-03-16

Licensed under

Comments