이번엔 스프링 시큐리티의 서블릿 인증에서 사용되는 주요 아키텍처 구성 요소에 대해 살펴보자.

SecurityContextHolder

스프링 시큐리티에서 인증 모델의 핵심 요소이고, SecurityContext 를 포함하고 있다.

(SecurityContextSecurityContextHolder 안에 들어있는 컨테이너로, 현재 요청의 Authentication 객체를 보관한다.)

 

인증된 사용자의 세부 정보를 저장하는 곳이고, 스프링 시큐리티는 SecurityContextHolder 가 내부적으로 어떻게 채워졌는지는 신경 쓰지는 않는다. 홀더에 들어있는 Authentication 을 현재 인증 정보로 사용하게 된다.

 

사용자 인증의 가장 간단한 방법은 그냥 이 홀더에 값을 설정해주면 된다.

 

SecurityContextHolder 설정하기:

SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication =
    new TestingAuthenticationToken("username", "password", "ROLE_USER");
context.setAuthentication(authentication);

SecurityContextHolder.setContext(context);
  1. 먼저 비어있는 SecurityContext 를 생성한다. 이 때, 여러 스레드 간의 경쟁 조건(race conditions)을 피하기 위해 SecurityContextHolder.getContext().setAuthentication(authentication) 대신 새로운 SecurityContext 인스턴스를 생성해야 한다.
  2. 다음으로 새로운 Authentication 객체를 생성한다. 스프링 시큐리티는 어떤 인증 객체가 설정되든 신경쓰지 않으므로 자유롭게 커스텀이 가능하다.
  3. 마지막으로 SecurityContextHolderSecurityContext 를 설정해주면, 스프링 시큐리티는 이 정보를 가지고 인가(authorization)에 사용한다.

 

Q1: 왜 SecurityContextHolder.getContext() 를 사용하면 문제가 되지?

같은 세션을 공유하면서 동시 요청이 들어오는 웹 애플리케이션에서는, HttpSession 에 들어있는 동일한 SecurityContext 인스턴스가 여러 스레드에 의해 공유될 수 있다.
이 상태에서 SecurityContextHolder.getContext().setAuthentication() 처럼 그 인스턴스를 직접 변경(mutate) 하면, 다른 스레드 요청에도 영향이 갈 수 있다.

 

스레드가 실행되는 동안 컨텍스트를 일시적으로 바꾸는 코드가 없다면 문제는 없지만, 있다면 다른 스레드에 영향을 끼칠 수 있으므로 새로운 컨텍스트를 만들어 써야한다.

 

Q2: JWT를 사용할 때도 문제가 되는가?

보통 JWT를 사용하면, 세션 정책을 STATELESS 로 설정하게 된다. 그러면 요청 간 SecurityContext 를 로드/저장하지 않으므로(세션 영속화 X) 세션 공유로 인한 동일한 인스턴스 변경 문제가 줄어든다.

 

그런데 한 요청 안에서 스레드를 생성하는 경우엔 같은 컨텍스트를 공유하니, 수정을 할 경우 문제가 생길 수 있음에 유의한다.

 


인증된 주체(Principal)에 대한 정보를 얻고 싶다면, SecurityContextHolder 에 접근하면 된다.

SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();
Object principal = authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

 

SecurityContextHolder 가 컨텍스트를 저장하는 방식

  • MODE_THREADLOCAL (기본값)
    • 스레드마다 독립적으로 SecurityContext 참조를 ThreadLocal에 보관한다.
    • 요청이 끝나면 clear된다.
    • 요청 간 세션 저장이나 로드는 SecurityContextRepository 이 수행한다.
  • MODE_GLOBAL
    • 정적 전역 변수 하나에 컨텍스트를 저장
    • 모든 스레드가 동일한 하나의 컨텍스트 공유
  • MODE_INHERITABLETHREADLOCAL
    • 부모 스레드에 설정된 컨텍스트가 자식 스레드에 상속된다.

 

요청 생명주기에서의 SecurityContext 관리

요청이 시작되면 SecurityContextRepository가 저장소(예: HttpSession)에서 컨텍스트를 로드해 SecurityContextHolder에 설정한다. 요청이 끝난 후 컨텍스트를 저장소에 다시 저장할지는 사용하는 필터에 따라 달라진다.

  • SecurityContextPersistenceFilter (기존 방식, 6에서 deprecated): 요청 시작 시 로드 + 종료 시 자동 저장
  • SecurityContextHolderFilter (6.x 이상): 로드만 수행하고 자동 저장하지 않음

SecurityContextHolderFilter 를 사용하는 환경에서 커스텀 인증 필터를 만들어 세션에 컨텍스트를 유지하고 싶다면, SecurityContextRepository#saveContext를 명시적으로 호출해야 한다.

기본 제공 인증 필터(AbstractAuthenticationProcessingFilter 등)는 내부적으로 저장을 처리해준다.

 

Authentication

Authentication 인터페이스는 스프링 시큐리티 안에서 두 개의 주요 목적이 있다.

  • 유저가 인증을 위해 제공한 자격 증명(credentials)을 AuthenticationManager 에 입력으로 제공. 이 때는 아직 인증 전이라 isAuthenticated() 메서드는 false 를 반환한다.
  • 현재 인증된 사용자를 나타낸다.

 

Authentication 에 다음과 같은 요소가 있다.

  • principal: 사용자를 식별한다. 사용자 이름/비밀번호로 인증할 때 주로 UserDetails 의 인스턴스가 된다.
  • credentials: 주로 비밀번호다. 보통 사용자 인증이 되면 누출되지 않게 지운다.
  • authorities: 사용자에게 부여된 상위 수준의 권한 목록들(각 목록은 GrantedAuthority 인스턴스)

 

AdditionalRequiredFactorsBuilder 를 이용해 Authentication 인스턴스를 변경하거나 다른 것과 병합할 수 있다.

Authentication latestResult = authenticationManager.authenticate(authenticationRequest);
Authentication previousResult = SecurityContextHolder.getContext().getAuthentication();
if (previousResult != null && previousResult.isAuthenticated()) {
    latestResult = latestResult.toBuilder()
            .authorities((a) -> a.addAll(previousResult.getAuthorities()))
            .build();
}

폼 로그인 같은 인증 단계에서 권한을 가져온 다음, 일회용 토큰 인증 같은 다른 단계에서 권한을 추가하는 시나리오 같은 시나리오도 생각해볼 수 있다.

 

GrantedAuthority

사용자(principal)에게 부여된 상위 수준 권한(high-level permissions). 여기서 상위 수준이라 함은 애플리케이션/보안 정책에서의 관점이다. 역할(roles)과 스코프(scopes)가 있다. 나중에 인가를 위해 필요하다.

⇒ 일반적으로 GrantedAuthority 객체는 특정 도메인 객체에 대한 권한을 가지지 않는데, 그 이유는 만약 수천 개의 그런 권한이 있으면 메모리를 많이 잡아먹거나 사용자를 인증하는 데 오랜 시간이 걸린다.

 

  • Authentication.getAuthorities() 메서드로 GrantedAuthority 객체의 컬렉션을 얻을 수 있다.
  • username/password 기반 인증을 사용할 때 보통 GrantedAuthority 인스턴스는 보통 UserDetailsService 에 의해 로드된다.

 

AuthenticationManager

스프링 시큐리티 필터가 인증을 수행(성공/실패 결정)할 수 있도록 API를 제공한다.

@FunctionalInterface
public interface AuthenticationManager {
    Authentication authenticate(Authentication authentication) throws AuthenticationException;
}

 

시큐리티 필터를 사용하는 경우, 필터가 AuthenticationManager 를 호출하고 결과를 SecurityContextHolder 에 넣는 것까지 자동으로 처리한다.

 

커스텀으로 필터를 만들고 시큐리티 필터 인스턴스와 통합하지 않으면, 굳이 AuthenticationManager 를 거칠 필요 없고 바로 컨텍스트 홀더에 인증을 넣어주면 된다.

SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(myCustomAuthentication);
SecurityContextHolder.setContext(context);

 

ProviderManager

가장 흔히 사용되는 AuthenticationManager 의 구현체다. 이 ProviderManager는 인증 판정에 대한 총괄 관리자고, 인증 자체는 Provider들에게 위임한다.

⇒ 내부에 List<AuthenticationProvider> 를 가지고 있고, 리스트를 순회하면서 순서대로 Provider에게 인증을 처리할 수 있는지 물어본다.

 

AuthenticationProvider 들은 각자 맡은 인증 방법의 유형이 다르다.

  • Username/Password 검증 (DaoAuthenticationProvider)
  • JWT 검증 (JwtAuthenticationProvider)
  • OAuth 검증 등…

Provider는 다음 3가지 중 하나를 한다.

  • 인증 성공 → Authentication 반환
  • 인증 실패 → AuthenticationException 에러
  • 판단 불가 (supports = false)

만약에 리스트에 있는 어떠한 Provider로도 인증할 수 없으면, ProviderNotFoundException 에러와 함께 인증에 실패하게 된다.

 

매니저가 부모를 가질 수도 있는데, 만약 어떠한 Provider로도 인증할 수 없을 때 부모가 있다면 참조해 인증을 시도할 수 있다.

 

여러 매니저들이 동일한 부모를 참조할 수도 있다. (여러 SecurityFilterChain 인스턴스가 있는 경우)

 

기본적으로 ProviderManager 는 인증 요청이 성공해 반환된 Authentication 객체에서 민감한 자격 증명 정보(credentials)를 지우려고 한다. ⇒ 비밀번호 같은 정보가 세션에 오랫동안 보관되는 것을 방지한다.

  • Authentication 객체가 CredentialsContainer 이면 eraseCredentials() 메서드를 호출해 민감한 정보들을 지워버린다.
  • 지우고 싶지 않는 경우가 있을 수 있는데, 그 때는 매니저의 eraseCredentialsAfterAuthentication 속성을 false 로 바꾸면 된다.

 

AuthenticationEntryPoint

클라이언트에게 자격 증명을 요청하는 HTTP 응답을 보내는 데 사용된다. (인증이 필요한데 인증이 안된 경우)

⇒ 접근 권한이 없는 리소스에 인증되지 않은 요청을 보내면

  • 로그인 페이지로 리다이렉트하거나
  • WWW-Authenticate 헤더로 응답한다.
  • 다른 작업을 할 수도 있다.

 

AbstractAuthenticationProcessingFilter

주로 세션 기반 인증에서, 사용자의 자격 증명을 인증하기 위한 기본 필터로 사용된다.

 

(1) 사용자가 자격 증명을 제출하면, 필터가 인증 객체를 만들게 된다.

  • 필터에 따라 인증 객체 유형은 달라질 수 있다.
  • UsernamePasswordAuthenticationFilter 는 HttpServletRequest에 제출된 사용자 이름과 비밀번호로 UsernamePasswordAuthenticationToken 을 생성한다.

(2) AuthenticationManager 에 인증을 위해 생성한 인증 객체를 전달한다.

(3) 인증에 실패하면:

  • SecurityContextHolder clear
  • RememberMeServices.loginFail 호출. 단, remember me가 설정되지 않았다면 아무것도 안한다.(no-op)
  • AuthenticationFailureHandler 호출

(4) 인증에 성공하면:

  • SessionAuthenticationStrategy 에 로그인이 됐다고 알린다.
  • SecurityContextHolder 에 이미 인증된 Authentication 이 있으면 여기에 있던 권한들을 (2)에서 방금 인증된 Authentication 객체에 추가한다.
  • SecurityContextHolder 에 인증된 인증 객체를 설정한다. (시큐리티가 제공하는 인증 필터들은 내부적으로 저장 로직이 있으므로, 직접 커스텀 필터를 만들 때만 신경 쓰면 된다.)
  • RememberMeServices.loginSuccess 호출. 단, remember me가 설정되지 않았다면 no-op
  • ApplicationEventPublisherInteractiveAuthenticationSuccessEvent 이벤트 발행
  • AuthenticationSuccessHandler 호출

 

 


레퍼런스
Spring Security 7.0.2 - Servlet Authentication Architecture

 

https://vcfvct.wordpress.com/2014/09/16/thread-safe-in-spring-security/