본문 바로가기

Development/Spring

[Spring] Spring Security의 Authentication과 SecurityContext 동작, 그리고 Authentication을 얻는 방식

서론

 @Transactional(readOnly = true)
    public UserResponseDto getUserProfile(Authentication authentication) {
        return userMapper.toUserResponseDto(UserUtil.getUserFromAuthentication(authentication));
    }

이 코드가 있고

@Transactional(readOnly = true)
    public UserResponseDto getUserProfile() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return userMapper.toUserResponseDto(UserUtil.getUserFromAuthentication(authentication));
    }

이 코드가 있다.

두 메서드는 같은 기능을 동작하지만, Authentication을 인자로 받는 방식과 SecurityContextHolder를 사용하여 얻어오는 방식의 차이가 있다.

 

이 글에서 두 방식이 어떤 차이가 있는지 이해하기 위해 공부한 내용을 작성했다.

 

일단 Spring Security에 Authentication 관련 개념부터 알아보았다.

AuthenticationManager, AuthenticationProvider

 

이미지 출처: https://docs.spring.io/spring-security/reference/_images/servlet/authentication/architecture/providermanager-parent.png

Spring Security에서 Authentication은 사용자 인증 정보를 나타내는 인터페이스이다.

즉 인증 요청 토큰 역할과 함께 요청이 처리된 후 인증된 사용자 정보를 나타낸다.

 

AuthenticationManager

AuthenticationManager는 Authentication을 처리하는 방법을 정의한 API로, 이를 구현한 ProviderManager는 Spring Security Filter로부터 받은 Authentication 객체를 가지고 실제 인증을 수행하는 AuthenticationProvider를 찾아 위임하는 역할을 한다.

public interface AuthenticationManager {

  Authentication authenticate(Authentication authentication)
    throws AuthenticationException;
}

AuthenticationManager의 authenticate() 메서드는 사용자 인증 요청을 처리하는 역할을 한다.

이 메서드는 주어진 인증 정보(Authentication 객체)를 검증하여 다음과 같은 세 가지 결과 중 하나를 반환한다.

  1. Authentication 객체 반환 (성공):
    • 입력된 인증 정보가 유효한 사용자를 나타낸다고 판단되는 경우 Authentication 객체를 반환한다.
    • 반환된 객체의 authenticated 속성은 일반적으로 true 값으로 설정되어 인증 성공을 나타낸다.
  2. AuthenticationException 예외 발생 (실패):
    • 입력된 인증 정보가 유효하지 않다고 판단되는 경우 (AuthenticationException) 예외를 발생시킨다.
    • 예를 들어 사용자 이름이나 패스워드가 일치하지 않는 경우, 계정이 잠겨있는 경우 등에 예외가 발생할 수 있다.
  3. null 반환 (판단 불가):
    • AuthenticationManager가 인증 여부를 판단할 수 없는 경우 null을 반환한다.
    • 이는 보통 등록된 AuthenticationProvider 중 어떤 Provider도 해당 인증 방식을 처리할 수 없는 경우에 발생한다.

ProviderManager

ProviderManager는 AuthenticationManager의 가장 일반적인 구현체이다. ProviderManager는 AuthenticationProvider 목록을 위임받는다.

그리고 AuthenticationManager에게 위임받은 AuthenticationProvider 목록에 Authentication 객체를 순서대로 전달한다.

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {

	private static final Log logger = LogFactory.getLog(ProviderManager.class);

	private AuthenticationEventPublisher eventPublisher = new NullEventPublisher();

	private List<AuthenticationProvider> providers = Collections.emptyList();

	protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();

	private AuthenticationManager parent;
    
    ...
    
    @Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		Class<? extends Authentication> toTest = authentication.getClass();
		AuthenticationException lastException = null;
		AuthenticationException parentException = null;
		Authentication result = null;
		Authentication parentResult = null;
		int currentPosition = 0;
		int size = this.providers.size();
		for (AuthenticationProvider provider : getProviders()) {
			if (!provider.supports(toTest)) {
				continue;
			}
			if (logger.isTraceEnabled()) {
				logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
						provider.getClass().getSimpleName(), ++currentPosition, size));
			}
...

위 코드는 ProviderManager의 실제 구현된 코드의 일부이다.

AuthenticationManager 인터페이스를 구현하고 있으며, AuthenticationProvider 객체의 리스트 타입인 providers를 가지고 있는 것을 확인할 수 있다.

구현된 authenticate 메서드에서 provider를 iterate 하면서 각 AuthenticationProvider가 담당하는 인증을 처리하는 것을 확인할 수 있다.

 

AuthenticationProvider

AuthenticationProvider는 특정 인증 방식을 수행하는 구체적인 클래스이다.

AuthenticationProvider는 여러 종류가 있으며, 각각 담당하는 인증이 다르다.

예를 들어, DaoAuthenticationProvider (데이터베이스), JwtAuthenticationProvider(JWT 토큰 인증), LdapAuthenticationProvider (LDAP 서버), OpenIDAuthenticationProvider (OpenID Connect) 등 이 있다.

인증 성공 시에는 사용자 권한 정보를 포함하는 완전한 Authentication 객체를 반환한다.

 

아래는 구현되어 있는 코드이다.

public interface AuthenticationProvider {

	/**
	 * Performs authentication with the same contract as
	 * {@link org.springframework.security.authentication.AuthenticationManager#authenticate(Authentication)}
	 * .
	 * @param authentication the authentication request object.
	 * @return a fully authenticated object including credentials. May return
	 * <code>null</code> if the <code>AuthenticationProvider</code> is unable to support
	 * authentication of the passed <code>Authentication</code> object. In such a case,
	 * the next <code>AuthenticationProvider</code> that supports the presented
	 * <code>Authentication</code> class will be tried.
	 * @throws AuthenticationException if authentication fails.
	 */
	Authentication authenticate(Authentication authentication) throws AuthenticationException;

	/**
	 * Returns <code>true</code> if this <Code>AuthenticationProvider</code> supports the
	 * indicated <Code>Authentication</code> object.
	 * <p>
	 * Returning <code>true</code> does not guarantee an
	 * <code>AuthenticationProvider</code> will be able to authenticate the presented
	 * instance of the <code>Authentication</code> class. It simply indicates it can
	 * support closer evaluation of it. An <code>AuthenticationProvider</code> can still
	 * return <code>null</code> from the {@link #authenticate(Authentication)} method to
	 * indicate another <code>AuthenticationProvider</code> should be tried.
	 * </p>
	 * <p>
	 * Selection of an <code>AuthenticationProvider</code> capable of performing
	 * authentication is conducted at runtime the <code>ProviderManager</code>.
	 * </p>
	 * @param authentication
	 * @return <code>true</code> if the implementation can more closely evaluate the
	 * <code>Authentication</code> class presented
	 */
	boolean supports(Class<?> authentication);

}
  • authenticate(Authentication authentication) throws AuthenticationException: 실제 사용자 인증을 처리한다.
  • supports(Class <?> authentication):  주어진 Authentication 객체를 이 Provider가 처리할 수 있는지 여부를 판단한다.
    supports 메서드는 ProviderManager가 인증을 수행할 Provider를 선택하는 과정에서 사용된다.

Authentication , SecurityContext, SecurityContextHolder

이미지 출처:&nbsp;https://docs.spring.io/spring-security/reference/_images/servlet/authentication/architecture/securitycontextholder.png

Authentication

Authentication은 사용자 인증 정보를 나타내는 인터페이스다.

Authentication 객체는 다음과 같은 정보를 포함한다.

  • principal: 사용자를 식별하는 정보이다. Username/password 인증 방식에서 일반적으로 UserDetails 객체의 인스턴스이다.
  • credentials: 인증 자격 정보이다. 대부분 패스워드 정보를 포함하지만, 사용자 인증 후 정보 누출을 방지하기 위해 처리 과정에서 제거된다.
  • authorities: 사용자에게 부여된 권한 정보를 나타낸다. GrantedAuthority 객체를 통해 권한 목록을 제공하며, 역할(role)이나 스코프(scope) 등의 상위 레벨 권한 정보를 포함한다.

SecurityContext

SecurityContext는 Authentication 객체를 포함한다.

SecurityContext는 SecurityContextHolder에 저장된다. 

/**
 * Interface defining the minimum security information associated with the current thread
 * of execution.
 *
 * <p>
 * The security context is stored in a {@link SecurityContextHolder}.
 * </p>
 *
 * @author Ben Alex
 */
public interface SecurityContext extends Serializable {

	/**
	 * Obtains the currently authenticated principal, or an authentication request token.
	 * @return the <code>Authentication</code> or <code>null</code> if no authentication
	 * information is available
	 */
	Authentication getAuthentication();

	/**
	 * Changes the currently authenticated principal, or removes the authentication
	 * information.
	 * @param authentication the new <code>Authentication</code> token, or
	 * <code>null</code> if no further authentication information should be stored
	 */
	void setAuthentication(Authentication authentication);

}

 

SecurityContextHolder

SecurityContextHolder는 Spring Security가 인증된 사용자 정보(SecurityContext)를 저장하는 곳이다. 

이 클래스에서 구현된 모든 메서드가 static 메서드이기 때문에 클래스 전체가 JVM 전체 설정을 다루며, 호출 코드에서 쉽게 사용할 수 있도록 설계되었다.

SecurityContext를 설정하는 다양한 Strategy(전략)

SecurityContextHolder가 SecurityContext를 저장하는 전략에는 여러 가지가 있다.

  • SecurityContextHolder.MODE_THREADLOCAL:  같은 스레드 내에서 실행되는 모든 메서드들이 해당 스레드에 저장된 SecurityContext에 접근할 수 있다는 것을 의미한다. 
  • SecurityContextHolder.MODE_GLOBAL:이 전략은 모든 스레드가 동일한 SecurityContext를 공유한다. 
  • SecurityContextHolder.MODE_INHERITABLETHREADLOCAL: 안전한 스레드에 의해 생성된 하위 스레드도 동일한 보안 ID를 갖도록 설정하고 싶을 때 사용한다.

기본적으로 SecurityContextHolder는 ThreadLocal 전략을 사용하여 SecurityContext 정보를 저장한다.

이는 하위 호환성이 우수하고 JVM 비호환성이 적으며 서버에서 사용하기 적합하다.

 

하지만 메서드 호출 시 매개변수로 명시적으로 넘겨주지 않더라도 SecurityContext에 접근할 수 있다는 것은 편리하지만 주의사항이 있다.

현재 사용자의 요청이 처리된 후 반드시 스레드를 정리해야 한다.

SpringSecurity의 FilterChainProxy는 이러한 정리가 자동으로 이루어지도록 보장하지만, 모든 경우에 안전한 것은 아니다.

 

특히 스레드 작업 방식의 특수성 때문에 ThreadLocal이 적합하지 않은 경우가 있다.

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

위 블로그 상황을 예시로 들면,  ParallelStream 실행을 위해 하위 쓰레드들을 만들어서 작업을 진행할 때, 하위 쓰레드에SecurityContextHolder.getContext() 값이 null을 반환해서 문제가 발생한 상황이다.

이는 SecurityContextHolder.MODE_THREADLOCAL 전략에 의해 하위 쓰레드에서 Authentication 정보를 가져올 수 없기 때문이다. 따라서 SecurityContextHolder.MODE_INHERITABLETHREADLOCAL를 사용해야만 한다.

 

SecurityContextHolder.java

private static void initialize() {
        initializeStrategy();
        initializeCount++;
    }

    private static void initializeStrategy() {
        if (MODE_PRE_INITIALIZED.equals(strategyName)) {
            Assert.state(strategy != null, "When using " + MODE_PRE_INITIALIZED
                    + ", setContextHolderStrategy must be called with the fully constructed strategy");
            return;
        }
        if (!StringUtils.hasText(strategyName)) {
            // Set default
            strategyName = MODE_THREADLOCAL;
        }
        if (strategyName.equals(MODE_THREADLOCAL)) {
            strategy = new ThreadLocalSecurityContextHolderStrategy();
            return;
        }
        if (strategyName.equals(MODE_INHERITABLETHREADLOCAL)) {
            strategy = new InheritableThreadLocalSecurityContextHolderStrategy();
            return;
        }
        if (strategyName.equals(MODE_GLOBAL)) {
            strategy = new GlobalSecurityContextHolderStrategy();
            return;
        }
        // Try to load a custom strategy
        try {
            Class<?> clazz = Class.forName(strategyName);
            Constructor<?> customStrategy = clazz.getConstructor();
            strategy = (SecurityContextHolderStrategy) customStrategy.newInstance();
        }
        catch (Exception ex) {
            ReflectionUtils.handleReflectionException(ex);
        }
    }

위 코드에서 strategyName = MODE_THREADLOCAL;로 기본값이 설정되는 것을 볼 수 있다.

Authentication 객체를 얻는 두 방식

Authentication을 인자로 받는 방법

Authentication 객체를 메서드 인자로 받아 사용하는 방식이 있다.

@GetMapping("/profile")
    public ResponseEntity<UserResponseDto> getUserProfile(
            @Parameter(hidden = true) Authentication authentication) {
        return ResponseEntity.ok(userService.getUserProfile(authentication));
    }

이 방식에서 Authentication 객체를 어떻게 가져오는지 디버깅 모드로 추적해보았다.

 

InvocableHandlerMethod.java에서 getMethodArgumentValues 메서드를 통해 필요한 Argument 들을 가져온다.

여기서 parameter들은 request에 해당하는 Controller 클래스의 메서드 파라미터이다.

 

파라미터 타입마다 처리하는 resolver들이 있는데, supportsParameter 메서드를 통해 해당 파라미터를 처리할 resolver가 있는지 판단한다.

그리고 resolveArgument 메서드를 호출한다.

여기서 getArgumentResolver 메서드를 호출한다.

 

getArgumentResolver 메서드는 파라미터를 처리하는 HandlerMethodArgumentResolver 클래스들을 순회한다.

Authentication 파라미터 타입은 PrincipalMethodArgumentResolver가 처리하는 것을 확인할 수 있다.

따라서 Resolver를 리턴한다.

result: PrincipalMethodArgumentResolver를 확인할 수 있다.

 

resolveArgument 메서드로 돌아와서 return 문에 resolveArgument를 호출한다.

(이 때 resolver는 PrincipalMethodArgumentResolver이다.)

PrincipalMethodArgumentResolver의 resolveArgument 메서드에서 principal을 리턴한다. 이 때 principal을 어떻게 불러오는지 확인해보자.

getUserPrincipal()을 호출한다.

 

결과적으로 request가 들어오면 InvocableHandlerMethod 클래스에 의해 Authentication 인자를 SecurityContextHolder.getContext().getAuthentication() 을 호출하여 주입한다.

SecurityContextHolder 사용

SecurityContextHolder.getContext(). getAuthentication() 메서드를 사용하여 현재 실행 중인 스레드에 저장된 Authentication 객체를 가져오는 방식이다.

@Transactional(readOnly = true)
    public UserResponseDto getUserProfile() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        return userMapper.toUserResponseDto(UserUtil.getUserFromAuthentication(authentication));
    }

결론

동작의 방식 차이는 없어보인다. request 할 때 가져오느냐, 메서드를 실행하면서 언제 가져오느냐 차이?

참고