이전 글에서 Spring Security가 Filter를 통해 요청을 처리하는 방식에 대해 학습했다. 이번엔 아래 글을 바탕으로 Spring Security의 인증(요청에 따라 사용자의 신원을 확인하는 방식) 기능에 대해 알아보려고 한다.
API를 구현할 때 사용한 자바, 스프링 부트, 라이브러리 버전은 아래와 같다.
Java: 17
Spring Boot: 3.3.1
Spring Security: 6.1.9 → 공식 문서 버전도 동일
인증(Authentication) 구조
저번 글에서 학습한 [Servlet 인증에서 사용되는 Spring Security의 핵심 구조적 요소]를 더 확장시킨 내용이다.
아래 요소들에 대해 하나씩 알아보자.
SecurityContextHolder | 인증된 사용자에 대한 정보를 저장하는 곳이다. |
SecurityContext | SecurityContextHolder로부터 얻을 수 있으며, 현재 인증된 사용자의 Authentication이 포함된다. |
Authentication | SecurityContext에서 사용자 또는 현재 사용자가 인증을 위해 제공한 자격 증명을 제공하기 위해 AuthenticationManager에 입력값으로 들어간다. |
GrantedAuthority | roles, scopes 같은 Authentication 주체에 부여되는 권한이다. |
AuthenticationManager | Spring Security의 Filters가 Authentication을 수행하는 방법을 정의한 API이다. |
ProviderManager | AuthenticationManager의 구현체 중 하나로 가장 많이 쓰인다. |
AuthenticationProvider | ProviderManager가 특별한 유형의 인증을 위해 사용한다. |
Request Credentials with AuthenticationEntryPoint | 클라이언트의 자격 증명을 요청하는 데에 사용된다. (로그인 페이지로 리다이렉팅, WWW-Authenticate 응답 전송 등) |
AbstractAuthenticationProcessingFilter | 인증하기 사용되는 기본 Filter다. 인증 과정과 위의 요소들이 함께 동작하는 방식을 이해할 수 있다. |
SecurityContextHolder & SecurityContext
SecurityContextHolder는 Spring Security 인증 모델의 핵심이라고 할 수 있다. 아래 그림처럼 SecurityContext를 포함하고 있다.
- Spring Security가 인증된 사용자의 세부 정보를 저장하는 곳이다.
- SecurityContext를 저장하고 있다고도 하며, SecurityContext는 Authentication 객체를 포함하고 있다.
- Spring Security는 SecurityContextHolder가 어떻게 채워지는지는 신경 쓰지 않고, 채워져 있는지 아닌지 여부만 판단한다. 값이 채워져 있다면 현재 인증된 유저가 사용하고 있다는 뜻이다.
아래 코드처럼 직접적으로 SecurityContextHolder를 설정해 사용자가 인증됐다는 걸 나타낼 수 있다.
- 비어있는 SecurityContext를 생성한다.
- 멀티 스레드로 인한 경쟁 상태를 피하기 위해 SecurityContext 인스턴스를 만들어야 한다.
- SecurityContextHolder.getContext().setAuthentication(authentication)처럼 사용하면 멀티 스레드 환경에서 경쟁 상태가 발생할 수 있다.
- Authentication 객체를 생성한다.
- Spring Security는 SecurityContext에 설정되는 Authentication 구현체의 타입을 신경쓰지 않는다. 따라서 아래 예시 코드에서 임의의 TestingAuthenticationToken을 만들어 사용했다.
- 보통은 UsernamePasswordAuthenticationToken(userDetails, password, authorities);를 사용한다.
- SecurityContextHolder에 위에서 만든 SecurityContext를 설정한다.
- Spring Security는 인증을 위해 이 정보를 사용한다.
SecurityContext context = SecurityContextHolder.createEmptyContext(); // (1)
Authentication authentication =
new TestingAuthenticationToken("username", "password", "ROLE_USER"); // (2)
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context); // (3)
인증된 사용자에 대한 정보를 얻으려면 아래처럼 SecurityContextHolder에 접근하면 된다.
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();
Object principal = authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
SecurityContextHolder는 이런 세부 정보를 저장할 때 ThreadLocal을 기본값으로 사용한다.
- 따라서 SecurityContext가 명시적으로 메서드에 파라미터로 전달되지 않더라도, 같은 스레드에 있는 메서드는 언제나 SecurityContext를 사용할 수 있다.
- 이런 방식으로 ThreadLocal을 사용하면, 현재 사용자의 요청이 처리되고 나서 스레드를 지우는 데 집중할 필요가 없어서 안전하다.
- Spring Security의 FilterChainProxy는 SecurityContext가 항상 지워지도록 보장한다.
이때 ThreadLocal을 사용하기에 맞지 않는 몇몇 애플리케이션도 존재한다. 예를 들어 Swing 클라이언트는 JVM의 모든 스레드가 동일한 Security Context를 사용하도록 만들고 싶어 할 수도 있다.
이때 SecurityContextHolder에 전략을 명시하면 컨텍스트가 저장되는 방법을 지정할 수 있다.
1. 독립 실행형 애플리케이션의 경우, SecurityContextHolder.MODE_GLOBAL 전략을 사용할 수 있다.
2. 다른 애플리케이션의 경우, SecurityContextHolder.MODE_INHERITABLETHREADLOCAL 전략을 사용해 secure 스레드에 의해 생성된 스레드가 동일한 secure ID를 가정할 수 있다. 이 모드가 기본값이다.
Authentication
Authentication 인터페이스는 Spring Security에서 아래 2가지 목적을 위해 사용된다.
- 사용자가 인증하기 위해 제공한 자격 증명을 AuthenticationManager에 입력값으로 제공하는 경우다. 이때 isAuthenticated() 메서드가 false를 반환한다.
- 현재 인증된 사용자를 나타내는 경우다. SecurityContext에서 현재 인증 정보를 얻을 수 있다.
Authentication이 포함하는 건 아래와 같다.
- principal: 사용자를 식별한다. 예시는 UserDetails로, 사용자 이름과 비밀번호로 인증하는 경우가 있다.
- credentials: 사용자가 인증되고 나면 유출을 막기 위해 삭제되는 정보로, 보통 비밀번호가 들어간다.
- authorities: GrantedAuthority 인스턴스는 사용자에게 부여되는 고급 권한으로, roles나 scopes 등이 들어간다.
GrantedAuthority
GrantedAuthority 인스턴스는 사용자에게 부여되는 고급 권한으로, roles나 scopes 등이 들어간다.
- 아래 메서드를 통해 인스턴스를 얻을 수 있다. 반환값은 GrantedAuthority 객체의 컬렉션이다.
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
- 주체에게 부여된 권한이라기보단, 역할이라고 보는 게 더 이해하기 쉽다. ex. ROLE_USER, ROLE_ADMIN
- 이런 역할은 웹 인가나 메서드 인가, 도메인 객체 인가 등을 위해 설정된다. (ex. ADMIN 페이지 등)
- 사용자 이름/비밀번호 기반 인증 방식을 사용한다면, 보통 UserDetailsService가 GrantedAuthority 인스턴스를 로드한다. (아래에 DaoAuthenticationProvider의 동작 방식을 설명해뒀다.)
GrantedAuthority 객체는 특정 도메인 객체에 특정되지 않고, 애플리케이션 전체에서 사용할 수 있다. 따라서 권한(역할)을 많이 만들어두면, 메모리를 너무 많이 차지하게 되거나 사용자를 인증하는 데에 시간이 오래 걸리게 되므로 주의해야 한다.
AuthenticationManager
AuthenticationManager은 Spring Security의 Filters가 인증을 수행하는 방법을 정의한 API다. 반환된 Authentication은 AuthenticationManager를 호출한 Controller에 의해 SecurityContextHolder에 설정된다.
- Spring Security의 Filter 인스턴스와 통합하지 않았다면, SecurityContextHolder를 직접 설정할 수 있다. 따라서 AuthenticationManager를 사용할 필요가 없다.
ProviderManager
ProviderManager는 AuthenticationManager의 구현체 중 가장 많이 사용된다. ProviderManager는 AuthenticationProvider 인스턴스의 목록에 위임한다. 각각의 AuthenticationProvider는 인증의 성공 및 실패 여부를 보여주거나, 해당 여부를 결정할 수 없다고 판단하면 밑 계층의 AuthenticationProvider에게 판단을 넘긴다.
- 이때, 설정된 모든 AuthenticationProvider 인스턴스에서 인증할 수 없으면, ProviderNotFoundException을 발생시키고 인증은 실패한다.
- ProviderNotFoundException: 특별한 AuthenticationException으로, 인증을 진행할 ProviderManager가 설정되지 않았다는 뜻이다.
실제론 각각의 AuthenticationProvider는 특정 타입의 인증을 수행하는 방법을 알고 있다.
- 예를 들어, 한 AuthenticationProvider는 사용자 이름/비밀번호를 검증할 수 있고, 다른 하나는 SAML Assertion을 검증할 수 있다. 이렇게 하면 각각의 AuthenticationProvider는 여러 유형의 인증을 지원하면서, 매우 특정한 유형의 인증을 수행하고 단일 AuthenticationManager 빈만 보여줄 수 있다.
- 모든 AuthenticationProvider가 인증을 수행할 수 없는 경우, ProviderManager는 선택적인 부모로 AuthenticationManager를 설정할 수 있다.
아래 사진처럼 다수의 ProviderManager 인스턴스가 동일한 부모 AuthenticationManager를 공유할 수도 있다.
- 공통된 인증 방식 또는 서로 다른 인증 방식을 가진 여러 SecurityFilterChain 인스턴스가 존재하는 상황을 생각해보면 된다.
기본적으로, ProviderManager은 인증 요청이 성공적으로 끝난 뒤 반환된 Authentication 객체에서 민감한 자격 증명을 지운다. 이렇게 하면 비밀번호 같은 정보가 HttpSession에서 필요 이상으로 오래 유지되는 것을 막을 수 있다.
- 무상태 애플리케이션에서 성능을 향상하기 위해 회원 객체를 캐시에 저장한 경우를 생각해보자. 인증이 UserDetails 인스턴스 같은 캐시에 있는 객체에 대한 참조를 포함하고 자격 증명이 지워져 있다면, 당연히 인증에 실패하게 된다. 따라서 캐시를 사용한다면 이 점을 고려해서 사용해야 한다.
- 해결책은 캐시 구현체나 반환된 인증 객체를 생성하는 AuthenticationProvider에서 먼저 객체의 복사본을 만들어 두는 것이다. 또는, ProviderManager가 자격 증명을 지우지 않도록 설정하는 방법도 있다. 해당 문서를 참고하자.
AuthenticationProvider
ProviderManager에 다양한 AuthenticationProvider 인스턴스를 주입할 수 있다. 여기서 각각의 AuthenticationProvider는 특정 유형의 인증을 수행한다.
- 예를 들면, DaoAuthenticationProvider는 사용자 이름/비밀번호 기반 인증을 지원하고, JwtAuthenticationProvider는 JWT 토큰 인증을 지원할 수 있다.
- DaoAuthenticationProvider의 동작 방식은 아래와 같다.
- 인증 Filter가 사용자 이름과 비밀번호가 담긴 UsernamePasswordAuthenticationToken을 AuthenticationManager에 넘긴다.
- ProviderManager가 AuthenticationProviders 중 DaoAuthenticationProvider를 사용해야 한다고 판단한다.
- DaoAuthenticationProvider가 UserDetailsService를 이용해 UserDetails를 찾은 뒤,
- PasswordEncoder로 해당 UserDetails에 있는 비밀번호를 검증한다.
- 인증이 성공하면, UsernamePasswordAuthenticationToken 유형의 Authentication이 반환되고, 안에는 Principal(UserDetails)와 Authorities가 담겨 있다. 이후 인증 Filter에 의해 해당 토큰이 SecurityContextHolder에 설정된다.
Request Credentials with AuthenticationEntryPoint
AuthenticationEntryPoint는 클라이언트에게 자격 증명을 요청하는 HTTP 응답(401)을 보낼 때 사용된다.
- 클라이언트가 자원을 요청하기 위해 자격 증명을 사전에 포함(ex. 토큰)시킬 수 있다. 이런 경우, 자격 증명이 이미 포함돼 있으므로 AuthenticationEntryPoint를 사용할 필요가 없다.
클라이언트가 접근 권한이 없는 자원에 대한 인증되지 않은 요청(403)을 하는 경우가 있다.
- 이런 경우, AuthenticationEntryPoint의 구현체로 클라이언트에게 자격 증명을 요청한다. 이후 로그인 페이지로 리다이렉션하거나 헤더에 WWW-Authenticate를 담아 응답한다.
AbstractAuthenticationProcessingFilter
AbstractAuthenticationProcessingFilter는 사용자의 자격 증명을 인증하기 위해 사용하는 기본 Filter로 사용된다. 자격 증명이 인증되기 전, Spring Security는 AuthenticationEntryPoint를 사용해 자격 증명을 요청한다. 해당 Filter에 전달된 어떤 인증 요청이든 인증할 수 있다는 특징이 있다. 아래 그림을 살펴보자.
- 사용자가 자격 증명을 전달하면, 인증된 HttpServletRequste로부터 Authentication을 만든다.
- 이때 생성된 Authentication 타입은 AbstractAuthenticationProcessingFilter의 서브 class에 따라 달라진다.
- 예를 들어, UsernamePasswordAuthenticationFilter가 HttpServletRequest에 전달된 사용자 이름과 비밀번호로 UsernamePasswordAuthenticationToken을 만든다.
- Authentication이 인증되기 위해 AuthenticationManager로 넘어온다.
- 인증이 실패하면,
- SecurityContextHolder가 비워진다.
- RememberMeServices.loginFail이 호출된다. remember me가 설정되지 않았다면 동작하지 않는다.
- AuthenticationFailureHandler가 호출된다.
- 인증이 성공하면,
- SessionAuthenticationStrategy에 새로운 로그인이 알려진다.
- Authentication이 SecurityContextHolder에 설정된다. 나중에, SecuityContext를 저장해 미래 요청에 대해 자동적으로 설정되게 할 필요가 있다면, SecurityContextRepository#saveContext가 명시적으로 호출돼야 한다.
- RememberMeServices.loginSuccess가 호출된다. remember me가 설정되지 않았다면 동작하지 않는다.
- ApplicationEventPublisher가 InteractiveAuthenticationSuccessEvent를 날린다.
- AuthenticationSuccessHandler가 호출된다.
코드로 보면 아래처럼 되어 있는 걸 확인할 수 있다.
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
if (!this.requiresAuthentication(request, response)) {
chain.doFilter(request, response);
} else {
try {
Authentication authenticationResult = this.attemptAuthentication(request, response);
if (authenticationResult == null) {
return;
}
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
this.successfulAuthentication(request, response, chain, authenticationResult);
} catch (InternalAuthenticationServiceException var5) {
InternalAuthenticationServiceException failed = var5;
this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
this.unsuccessfulAuthentication(request, response, failed);
} catch (AuthenticationException var6) {
AuthenticationException ex = var6;
this.unsuccessfulAuthentication(request, response, ex);
}
}
}
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authResult);
this.securityContextHolderStrategy.setContext(context);
this.securityContextRepository.saveContext(context, request, response);
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
}
this.rememberMeServices.loginSuccess(request, response, authResult);
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
}
this.successHandler.onAuthenticationSuccess(request, response, authResult);
}
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
this.securityContextHolderStrategy.clearContext();
this.logger.trace("Failed to process authentication request", failed);
this.logger.trace("Cleared SecurityContextHolder");
this.logger.trace("Handling authentication failure");
this.rememberMeServices.loginFail(request, response);
this.failureHandler.onAuthenticationFailure(request, response, failed);
}