Spring Security - 1. Authentication, SecurityContextHolder

Authentication과 SecurityContextHolder

Spring Security document를 보면서 SecurityContextHolder에 대해서 알아본다.
Unit Test 기반으로 document에 설명되어 있는 내용을 직접 확인해보자.
Spring Security 인증 모델에서 핵심은 SecurityContextHolder이다.

Authentication

Session에 저장되는 정보가 Authentication이라 생각하면 된다. Authentication은 역할에 따라서 principal, credentials, authorities, details로 구성된다.
Authentication은 SecurityContextHolder 내부에 보관되는 SecurityContext에 저장된다.

  • Principal
    • 식별된 사용자 정보를 보관한다. UserDetails의 인스턴스이다.
    • 시스템에 따라 UserDetails 클래스를 상속하여, 커스텀한 형태로 유지할 수 있다.
  • Credentials
    • 주체 (사용자)가 올바르다는 것을 증명하는 자격 증명이다.
    • 보통 비밀번호를 의미하며, AuthenticationManager와 관련된 항목일 수 있다.
  • Authorities
    • AuthenticationManager가 설정한 권한을 의미
    • Authentication 상태에 영향을 주지 않거나 수정할 수 없는 인스턴스를 사용해야 한다.

SecurityContextHolder

spring security context holder

  • 인증된 사용자의 구체적인 정보를 보관한다.
  • Spring security는 SecurityContextHolder가 어떻게 만들어지는지에 대해서는 신경쓰지 않는다.
  • 만약 값을 포함하고 있다면, 현재 인증된 사용자 정보로 사용된다.
  • 사용자가 인증되었음을 나타내는 가장 간단한 방법은 SecurityContextHolder를 직접 설정하는 것이다.

Test 1. SecurityContextHolder 세팅 및 조회

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
@BeforeEach
void setUp() {
// 멀티 쓰레드에서 ContextHolder에 인증된 사용자 정보 세팅시,
// race condition 상태를 피하기 위해서 새로운 SecurityContext를 생성한다.
SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_THREADLOCAL);
setMockAuthentication(ANY_USER, ANY_PASSWORD, USER_ROLE);
}

@Test
@DisplayName("SecurityContextHolder에 현재 인증된 사용자 정보를 세팅한다.")
void securityContextHolder() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

assertThat(authentication.isAuthenticated()).isTrue();
assertThat(authentication.getName()).isEqualTo(ANY_USER);
assertThat(authentication.getCredentials()).isEqualTo(ANY_PASSWORD);
assertThat(authentication.getAuthorities())
.extracting(GrantedAuthority::getAuthority)
.contains(USER_ROLE);
}

private void setMockAuthentication(String principal, String credential, String role) {
SecurityContext context = SecurityContextHolder.createEmptyContext();

Authentication mockAuthentication = new TestingAuthenticationToken(principal, credential, role);
context.setAuthentication(mockAuthentication);

SecurityContextHolder.setContext(context);
}

Test 2. Multi Thread에서 SecurityContextHolder 조회

기본적으로 SecurityContextHolder는 ThreadLocal을 사용하여 인증된 사용자의 세부 정보를 저장한다.
SecurityContextHolder는 메소드에 인수로 명시적으로 전달되지 않더라도, 동일한 쓰레드의 메소드에서 사용할 수 있다.
현재 principal의 요청이 처리된 후, 쓰레드를 지우도록 주의를 기울일 경우, ThreadLocal 기반의 방식은 매우 안전하다.
Spring Security의 FilterChainProxy는 SecurityContextHolder가 항상 지우는 것을 보장한다.

  • 일부 어플리케이션은 쓰레드를 사용하는 특정 방식으로 인해, ThreadLocal을 사용하는데 완전히 적합하지 않다.
  • 예를들어 Swing 클라이언트는 JVM에 모든 쓰레드에서 같은 security context를 사용하기 원한다.
  • SecurityContextHolder는 시작할 때 컨텍스트 저장 방법을 지정하는 전략으로 설정할 수 있다.
  • Standalone한 어플리케이션의 경우, MODE_GLOBAL 전략을 사용하여 SecurityContextHolder를 사용할 수 있다.

Multi Thread에서 exception handling

Multi thread에서 발생한 exception은 thread 바깥에서 try..catch 블럭을 사용해서 exception 접근이 안된다.
UncaughtExceptionHandler를 구현하여 thread에서 발생한 exception을 처리할 수 있다.

두 가지 방법으로 thread에서 발생한 exception을 처리할 수 있다.

  1. 모든 Thread에서 발생하는 default Exception handler 지정
  2. Thread 별로 exception handler 지정
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class SampleThread extends Thread {
@Override
public void run() {
super.run();

Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication)
.orElseThrow(() -> new RuntimeException("Multi thread...authentication is null"));
}
}

class SampleExceptionHandler implements Thread.UncaughtExceptionHandler {
private String handlerName;
private Runnable errorHandler;
private CountDownLatch latch;

public SampleExceptionHandler(String handlerName, Runnable errorHandler) {
this.handlerName = handlerName;
this.errorHandler = errorHandler;
}

public SampleExceptionHandler(String handlerName, Runnable errorHandler, CountDownLatch latch) {
this.handlerName = handlerName;
this.errorHandler = errorHandler;
this.latch = latch;
}

@Override
public void uncaughtException(Thread t, Throwable e) {
System.out.println(this.handlerName + " : " + e.getMessage());
this.errorHandler.run();
latch.countDown();
}
}

@Test
@DisplayName("try...catch 블록으로 thread 외부에서 exception을 핸들링 할 수 없다.")
void errorCase() {
Stream.of(new SampleThread(), new SampleThread())
.forEach(thread -> {
try {
thread.start();
assertThat(true).isTrue();
} catch (Exception e) {
Assertions.fail("try...catch 블록으로 thread exception 핸들링 할 수 없다.");
}
});
}

Multi Thread에서 검증을 위한 Unit Test 작성 계획

Thread가 종료되고 나서, 각각의 exception handler가 호출되었는지 확인한다.

  • Thread가 종료될때까지 기다리기 위해서 CountDownLatch를 thread 갯수만큼 선언했다.
  • exception Handler에서 에러를 처리하고 나서, latch.countDown() 호출하여, 갯수를 줄여준다.
  • latch.await()를 사용하여 latch count가 0이 될때까지 기다린다.
  • 그 이후에 verify를 통해서, exception handler가 호출되었는지 확인한다.

1. default exception handler 지정

모든 Thread에 적용되는 default exception handler를 지정할 수 있다.

  • Thread를 2개를 사용하기 때문에, verify 테스트시에 defaultExceptionHandler가 2번 호출되었는지 확인한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @Test
    @DisplayName("Multi Thread 에러 핸들링 - default thread exception handler 지정")
    void withDefaultExceptionHandler() throws InterruptedException {
    CountDownLatch latch = new CountDownLatch(2);
    Runnable defaultErrorHandler = mock(Runnable.class);
    Thread.setDefaultUncaughtExceptionHandler(new SampleExceptionHandler("defaultHandler", defaultErrorHandler, latch));

    Stream.of(new SampleThread(), new SampleThread()).forEach(SampleThread::start);
    latch.await();

    then(defaultErrorHandler).should(times(2)).run();
    }

2. Thread 별로 exception handler 지정

Thread 별로 각각의 exception handler 지정할 수 있다.

  • 각 Thread별로 exceptionHandler를 지정했다.
  • Thread가 종료 후에 각각의 exceptionHandler가 한번씩 호출되었는지 확인한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    @Test
    @DisplayName("Multi Thread 에러 핸들링 - thread마다 exception handler 지정")
    void withExceptionHandlerEachThread() throws InterruptedException {
    CountDownLatch latch = new CountDownLatch(2);

    SampleThread firstThread = new SampleThread();
    Runnable firstExceptionHandler = mock(Runnable.class);
    firstThread.setUncaughtExceptionHandler(new SampleExceptionHandler("firstHandler", firstExceptionHandler, latch));

    SampleThread secondThread = new SampleThread();
    Runnable secondExceptionHandler = mock(Runnable.class);
    secondThread.setUncaughtExceptionHandler(new SampleExceptionHandler("secondHandler", secondExceptionHandler, latch));

    Stream.of(firstThread, secondThread).forEach(SampleThread::start);
    latch.await();

    then(firstExceptionHandler).should(times(1)).run();
    then(secondExceptionHandler).should(times(1)).run();
    }

Multi thread 환경에서 SecurityContextHolder의 전략 수정

ThreadLocal기반의 ContextHolder를 사용시, Multi thread 환경에서는 기존 쓰레드에 담긴 내용을 동기화해주는 작업을 별도로
진행해야 한다. SecurityContextHolder의 경우는 세가지의 전략을 지원하고, Multi thread 환경에서 적절한 전략을 선택하면 된다.

Strategy 종류

  • MODE_THREADLOCAL : 기본 설정
  • MODE_INHERITABLETHREADLOCAL : 멀티 쓰레드에서 새로운 안전한 쓰레드를 생성하는 경우에 기존 쓰레드와 동일하게 사용하고 싶은 경우
  • MODE_GLOBAL : standalone application에서 사용

Unit Test 설명

  1. SecurityContextHolder의 전략을 MODE_INHERITABLETHREADLOCAL로 지정
  2. mock 인증 정보 생성
  3. Multi thread 수행시, SecurityContextHolder에 기존 쓰레드에 지정된 authentication 정보가 새로운 thread에도 지속디는 것을 확인
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @Test
    @DisplayName("SecurityContextHolder에서 threadLocal mode변경을 통해서, multi thread에서 동기화 가능")
    void withGlobalMode() {
    SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);
    setMockAuthentication(ANY_USER, ANY_PASSWORD, USER_ROLE);

    Runnable defaultErrorHandler = mock(Runnable.class);
    Thread.setDefaultUncaughtExceptionHandler(new SampleExceptionHandler("defaultHandler", defaultErrorHandler));

    Stream.of(new SampleThread(), new SampleThread()).forEach(SampleThread::start);

    then(defaultErrorHandler).should(never()).run();
    }

마무리

SecurityContextHolder는 인증된 사용자 정보 저장소로써 역할을 수행한다.
기본적으로 ThreadLocal 기반으로 사용하며, Multi thread 환경에서 기존 인증정보를 sync해서 사용하기 위해서는 strategy 수정이 필요하다.

  • 메소드 호출시 인증정보를 파라미터로 전달받지 않더라도, SecurityContextHolder에 접근하면 인증된 사용자 정보 확인이 가능하다.
  • 물론 layer(Controller-Service-Repository)를 넘나들면서 SecurityContextHolder에 접근은 가능하지만, 인증정보를 session 이라고 생각한다면,
    Presentation Layer의 역할인 Controller에만 SecurityContextHolder에 접근하고, 나머지 layer에서는 메소드 파라미터로 전달받는 것이 바람직해보인다.

Java 8에서 멀티 쓰레드에서 exception을 다루기 위한 방법에 대해서도 알아보았다.

  • default uncaughtExceptionHandler를 사용하여 모든 쓰레드에 공통적으로 적용
  • 각 Thread별로 exception handler를 지정
  • 본문에서 테스트해보지는 않았지만 두 가지 방법을 혼용해서도 사용가능하다.

마지막으로 Unit Test에서 Multi thread 상태가 종료될 때까지 assertion을 기다리기 위해서,
java.util.concurrent.CountDownLatch를 사용하는 방법도 알아보았다. CountDownLatch를 사용하여 멀티 쓰레드 환경에서 테스트를 작성할 수 있었다.

참고

Share