[Spring Security] 스프링 시큐리티 - 인증 프로세스

impala·2023년 7월 13일
1
post-thumbnail

인증(Authentication)과 인가(Authorization)

'보안' 하면 가장 기본이되는 개념이 바로 인증과 인가이다. 하지만 단어가 비슷하게 생겨서 그런지 두 용어를 항상 헷갈리는 것 같다. 그래서 Spring Security에 대해 설명하기 이전에 인증과 인가에 대한 개념을 잡고 가겠다.

두 용어에 대한 정의는 다음과 같다.

인증(Authentication): 참이라는 근거가 있는 무언가를 확인하거나 확증하는 행위

인가(Authorization, 권한부여): 리소스에 대한 접근 권한 및 정책을 지정하는 기능

즉, 인증(Authentication)이란 사용자의 신원을 확인하는 과정이고, 인가(Authorization)란 사용자에게 특정 리소스에 대한 접근권한이 있는지 확인하는 과정이다.

예를 들어 사용자가 은행 서비스에 로그인할 때 서버에서는 이 사용자가 서비스에 회원으로 등록되어있는지 확인하고(인증), 회원이 맞으면 본인의 계좌를 조회하거나 이체할 수 있는 권한을 부여한다(인가).

Spring Security

스프링 시큐리티는 스프링 어플리케이션의 보안설정을 담당하는 스프링의 하위 프레임워크로, 인증과 인가등의 과정을 Filter Chain에서 처리한다. 즉, 요청이 Dispacher Servlet에 도달하기 이전에 요청에 대한 인증/인가처리를 수행하기 때문에 Dispacher Servlet에는 인증/인가된 요청만 도달할 수 있도록 한다.

스프링 시큐리티는 기본적으로 쿠키-세션방식의 인증을 수행한다. 사용자가 로그인 요청을 보내면 시큐리티 필터체인을 거쳐 사용자 인증을 수행하고, 인증에 성공하면 사용자의 정보를 세션에 담아 저장한 뒤 JSESSIONID에 세션id를 담아 사용자에게 반환한다. 이후 사용자는 이 세션id를 통해 서비스에 접근할 수 있다. 만약 쿠키-세션방식이 아닌 토큰방식을 사용하고 싶으면 config 클래스 아래 문장을 추가하면 된다.

@Configuration
@EnableWebSecurity 
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 쿠키 - 세션 사용x
    ...
    }
}

스프링 시큐리티의 필터체인은 크게 인증필터와 인가필터로 나누어진다. 이때, 인증필터는 인가필터보다 앞쪽에 위치하기 때문에 인증에 성공한 요청만이 인가 프로세스로 진입할 수 있다. 이 과정에서 스프링 시큐리티는 Principal(접근주체)을 아이디로, Credential을 비밀번호로 하는 Credential 기반 인증을 사용한다.

스프링 시큐리티 인증 아키텍쳐

스프링 시큐리티의 인증 아키텍쳐는 다음과 같다.

인증 프로세스가 구체적으로 어떻게 동작하는지 알아보기 전에, 그림의 각 모듈에 대해 알아보자.

Authentication Filter

시큐리티 필터체인을 구성하는 필터로, http요청을 받아 인증 프로세스를 시작한다.

SecurityContextHolder

SecurityContext로 감싼 인증객체를 보관하는 보관소로, MODE_THREADLOCAL, MODE_INHERITABLETHREADLOCAL등의 공유전략을 선택할 수 있다. (Spring Security 6.0.4)

  • MODE_THREADLOCAL(default) : 해당 요청을 처리하는 쓰레드에서만 인증정보를 공유
  • MODE_INHERITABLETHREADLOCAL : 해당 요청을 처리하는 쓰레드와 그 하위 쓰레드가 인증정보를 공유
  • MODE_GLOBAL : 어플리케이션의 모든 쓰레드에서 인증정보를 공유
  • MODE_PRE_INITIALIZED : SecurityContextHolder를 미리 초기화하는 전략

SecurityContext

인증정보를 감싸는 객체로 추가적인 속성을 정의한다.

public class SimpleSecurityContext extends HashMap<String,Object> implements SecurityContext {
    // Hashmap으로 인증정보를 관리
}

Authentication

실제 인증에 관련된 정보를 담은 객체로, 인증의 주체(Principal)과 권한(Authorities), 비밀번호(Credentials)등의 정보를 보관한다.

public interface Authentication extends Principal, Serializable {

	Collection<? extends GrantedAuthority> getAuthorities(); // 사용자의 권한 목록

	Object getCredentials(); // 비밀번호

	Object getDetails(); // 추가정보(request IP 등)

	Object getPrincipal(); // 사용자 정보

	boolean isAuthenticated(); // 인증 여부

	void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException; // 인증 여부를 설정

}

참고로 어플리케이션에서는 SecurityContextHolder를 통해 현재 요청의 인증정보를 얻을 수 있다.

SecurityContextHolder.getContext().getAuthentication();

UsernamePasswordAuthenticationToken

Authentication의 구현체중 하나로, 사용자의 아이디와 비밀번호를 각각 Principal과 Credential로 사용한다.

public class UsernamePasswordAuthenticationToken extends AbstractAuthenticationToken {

	private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;

	private final Object principal;

	private Object credentials;

	public UsernamePasswordAuthenticationToken(Object principal, Object credentials) {
		super(null);
		this.principal = principal;
		this.credentials = credentials;
		setAuthenticated(false);
	}

	public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
			Collection<? extends GrantedAuthority> authorities) {
		super(authorities);
		this.principal = principal;
		this.credentials = credentials;
		super.setAuthenticated(true); // must use super, as we override
	}

    // 인증되지 않은 객체를 생성
	public static UsernamePasswordAuthenticationToken unauthenticated(Object principal, Object credentials) {
		return new UsernamePasswordAuthenticationToken(principal, credentials);
	}

    // 인증된 객체를 생성
	public static UsernamePasswordAuthenticationToken authenticated(Object principal, Object credentials,
			Collection<? extends GrantedAuthority> authorities) {
		return new UsernamePasswordAuthenticationToken(principal, credentials, authorities);
	}
}

만약 ID/PW방식이 아닌 다른 인증방법을 사용하면 상황에 맞게 Authentication의 다른 구현체를 사용하여 인증정보를 보관할 수 있다.(ex. OAuth2LoginAuthenticationToken)

AuthenticationProvider

실제 인증로직이 수행되는 곳으로, 인증 전(isAuthenticated = false) Authentication 객체를 받아서 인증이 완료된(isAuthenticated = true) Authentication객체를 반환한다.

AuthenticationProvider는 인증객체와 인증방식에 따라 다양한 구현체가 있어, AuthenticationManager에서는 등록된 AuthenticationProvider중 현재 인증객체를 인증할 수 있는 Provider를 선택하여 인증 프로세스를 수행한다.

public interface AuthenticationProvider {

    // 실제 인증 로직
	Authentication authenticate(Authentication authentication) throws AuthenticationException;

    // Provider 적용 가능 여부
	boolean supports(Class<?> authentication);

}

예를 들면 AbstractUserDetailsAuthenticationProvider는 UsernamePasswordAuthenticationToken을 인증하는 Provider로 다음과 같은 구조로 인증 프로세스를 수행한다.

public abstract class AbstractUserDetailsAuthenticationProvider
		implements AuthenticationProvider, InitializingBean, MessageSourceAware {
    
    ...

	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		...
		if (user == null) {
			cacheWasUsed = false;
			try {
				user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); // 데이터베이스에서 사용자 정보를 불러옴
			}
			catch (UsernameNotFoundException ex) {
				...
            }
		}
		try {
			this.preAuthenticationChecks.check(user); // 인증 수행
			additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); // 인증 수행
		} catch{
            ...
        }
		this.postAuthenticationChecks.check(user); // 인증 수행
		if (!cacheWasUsed) {
			this.userCache.putUserInCache(user);
		}
		Object principalToReturn = user;
		if (this.forcePrincipalAsString) {
			principalToReturn = user.getUsername();
		}
		return createSuccessAuthentication(principalToReturn, authentication, user); // 인증된 Authentication객체를 생성(isAuthenticated = true)
	}

	@Override
	public boolean supports(Class<?> authentication) {
		return (UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication)); //UsernamePasswordAuthenticationToken의 하위 클래스들에 적용 가능
	}

    ...
}

AuthenticationManager

AuthenticationFilter에 의해 호출되어 실제 인증에 사용될 Provider를 선택하고, 인증을 수행한다. 이때, 인증에 성공하면 인증된 Authentication객체에 부가정보를 추가하거나 기밀을 제거하는 등의 처리를 거친 뒤 Authentication객체를 AuthenticationFilter로 반환한다.

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

아래는 AuthenticationManager의 구현체중 하나인 ProviderManager로, 등록된 Provider를 for문으로 돌면서 적용가능한 Provider를 선택한다.

public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean {
    ...
	@Override
	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        ...
		for (AuthenticationProvider provider : getProviders()) { // 등록된 Provider중에서 현재 인증객체에 적용할 수 있는 Provider를 선택
			if (!provider.supports(toTest)) {
				continue;
			}
			try {
				result = provider.authenticate(authentication); // 선택된 Provider로 인증 수행
				if (result != null) {
					copyDetails(authentication, result); // 인증된 Authentication객체에 현재 요청정보 추가
					break;
				}
			}
			catch {
                ...
            }
		}
		if (result != null) {
			if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
				((CredentialsContainer) result).eraseCredentials(); // 인증객체에서 비밀번호를 제거
			}
			...
			return result;
		}
		...
		throw lastException;
	}
    ...

}

만약 별도의 인증로직을 사용하기 위해서는 AuthenticationProvider의 구현체를 만들고 AuthenticationManager에 등록하면 된다. AuthenticationManager에 Provider를 등록하기 위해서는 config클래스에 AuthenticationManager를 빈으로 등록하고 커스텀한 Provider를 주입하면 된다. (Spring Security 6.0.4)

@Configuration
@EnableWebSecurity  // SpringSecurity 설정 활성화
public class SecurityConfig {
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        ProviderManager authenticationManager = (ProviderManager)authenticationConfiguration.getAuthenticationManager(); // AuthenticationManager 생성
        authenticationManager.getProviders().add(new CustomAuthenticationProvider()); // AuthenticationManager에 CustomAuthenticationProvider 등록
        return authenticationManager;
    }
}

UserDetails

인증된 사용자 정보를 담는 VO(Value Object)로 UsernamePasswordAuthenticationToken의 Principal로 사용된다. 일반적으로 UserDetails 구현체에 DB에서 조회한 사용자 정보를 담아서 사용한다.

public interface UserDetails extends Serializable {

	Collection<? extends GrantedAuthority> getAuthorities();

	String getPassword();

	String getUsername();

	boolean isAccountNonExpired();

	boolean isAccountNonLocked();

	boolean isCredentialsNonExpired();

	boolean isEnabled();

}

UsernamePasswordAuthenticationToken - UserDetails의 관계와 비슷하게 OAuth2LoginAuthenticationToken의 Principal로는 OAuth2User를 사용한다.

UserDetailsService

DB에 인증을 요청한 사용자가 등록되어있는지 찾고, 사용자의 정보를 UserDetails에 담아 반환한다. 일반적으로 DB에 접근하기 위해 Repository를 주입받아 사용한다.

public interface UserDetailsService {

	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; // DB에서 username으로 사용자를 조회하여 UserDetails를 생성

}

이외에도 비밀번호를 암호화하는 PasswordEncoder, 사용자의 권한을 나타내는 GrantedAuthority등의 모듈이 있다.

스프링 시큐리티 인증 프로세스

다시 그림으로 돌아와서 스프링 시큐리티의 인증 프로세스가 어떤 순서로 이루어지는지 알아보자.

  1. 어플리케이션으로 http요청이 들어오면 AuthenticationFilter가 이 요청을 기반으로 인증되지 않은 UsernamePasswordAuthenticationToken(Authentication객체)을 만든다(isAuthenticated = false).
  2. AuthenticationFilter에서 AuthenticationManager를 호출하여 Authentication객체에 대한 인증을 요청한다.
  3. AuthenticationManager는 전달받은 Authentication객체를 인증할 수 있는 Provider를 찾아 인증을 요청한다.
  4. 선택된 AuthenticationProvider는 Authentication객체에서 principal(username)을 추출하고 UserDetailsService에게 해당하는 사용자를 찾아달라고 요청한다.
  5. UserDetailsService는 전달받은 principal(username)과 일치하는 사용자를 DB에서 찾고, UserDetails객체로 포장하여 AuthenticationProvider에게 돌려준다.
  6. AuthenticationProvider는 UserDetails를 받아 인증을 수행하고, 인증에 성공하면 새로운 Authentication객체를 생성하여 AuthenticationManager에게 돌려준다(isAuthenticated = true).
  7. AuthenticationManager는 인증된 Authentication객체를 가공하여 AuthenticationFilter로 돌려준다.
  8. AuthenticationFilter는 인증된 Authentication객체를 SecurityContext로 감싸고 SecurityContextHolder에 저장한다.

위의 과정을 통해 인증에 성공한 Authentication객체는 SecurityContextHolder에 보관되고, 이후 필터에서 인가 프로세스를 처리할 때 사용된다.

참고자료

다음 포스트

0개의 댓글