오늘은 스프링 서큐리티의 동작과정에 대해 정리해볼까 합니다. 사실 내부동작을 모른채로 튜토리얼만 따라하다보면 비슷한 개념의 Jwt나 OAuth 동작 또한 새로운 것들 처럼 보입니다. 하지만 이들은 모두 스프링 서큐리티의 핵심 동작과정에서 크게 벗어나지 않습니다. 그럼 내부의 동작 원리를 살펴보도록 하겠습니다.
Authentication vs Authorization
먼저 용어정리를 하는 것이 좋습니다, 처음에 가장 헤깔렸던 개념이 Authentication과 Authorization입니다. 간단히 말하면 다음과 같습니다.
- Authentication : 로그인하는 유저의 아이디 비밀번호가 맞는지 확인
- Authorization : 로그인한 유저가 입장할 수 있는 권한이 있는지 확인
즉, Authorization은 Authentcation이 된 이후에 할 수 있는 작업입니다. Authentication은 아이디 비밀번호를 확인하여 로그인하는 작업이고 Authorization을 로그인한 유저가 올바른 입장 권한을 가지는지 확인하는 작업입니다. Authentication이 되어야 Authorization을 할 수 있습니다. 순서를 기억하면 구분하기 쉽습니다.
1. FilterChain
그럼 사용자가 여러분들의 어플리케이션으로 요청을 보냈을때 부터 살펴보겠습니다. 클라이언트가 요청을 보내면 Servlet에 도착할 때까지 여러 층의 Filter를 거칩니다. 즉 Servlet으로 가기전에 여러 필터들이 중간에 request를 가져가서 특정한 작업을 해줍니다. 스프링 내부에서는 Filter와 Servlet을 가지는 FilterChian을 생성합니다. FilterChain은 doFilter 메소드로 Filter와 다음 Filter에게 reqeust와 response를 전달하는 일을 합니다.
Servlet
- DispatcherServlet의 인스턴스
- HttpServletRequest 처리
- 하나이상의 HttpServetRequest와 HttpServletResponse를 처리
Filter
- HttpServletRequest 혹은 HttpServletResponse를 중간에서 수정
FilterChain
- request와 response를 다음 Filter로 전달
FilterChain이 가지고 있는 의 doFilter라는 메소드를 보겠습니다.
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
// 나머지 어플리케이션 작업 이전에 실행될 코드
chain.doFilter(request, response); // 다음 어플리케이션 코드 실행
// 나머지 어플리케이션 작업 이후에 실행될 코드
}
Filter의 내부를 보면 ServletRequest와 ServletResponse를 파라미터로 받습니다. 그리고 내부적으로 request와 response에 어떠한 처리를 한 이후, chain.doFilter라는 메소드가 다음 필터에 request와 response를 전송합니다. 즉, 하나의 필터는 특정한 처리를 하고 chain.doFilter라는 메소드를 통해 다음 필터에게 request와 response를 전달합니다. 궁극적으로는 Servlet에게 request와 response가 도착하게 됩니다.
2. DelegatingFilterProxy
스프링은 DelegatingFilterProxy라는 Filter의 구현 인스턴스를 제공합니다. 즉, Filter의 실제적인 동작을 DelegatingFilterProxy가 하게 됩니다. 해당 구현체는 스프링 컨테이너에 의해 Bean으로 등록되어 사용됩니다.
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
// 스프링 빈에 등록된 Filter를 Lazy하게 가져온다
// 예를들어 DelegatingFilterProxy라는 delegate 변수는 Filter0라는 Bean의 인스턴스이다
Filter delegate = getFilterBean(someBeanName);
delegate.doFilter(request, response);
}
다시 FilterChain의 내부를 살펴보면 Filter가 Bean에 등록되어 있는데, getFilterBean이라는 메소드를 통해 원하는 Filter 구현체(DelegatingFilterProxy와 같은)를 가져올 수 있습니다. 받아온 Filter 구현체는 doFilter 메소드로 다음 Filter 구현체에게 작업을 전달할 수 있습니다.
3. FilterChainProxy
FilterChainProxy는 SecurityFilterChain을 통해 많은 SecurityFilter의 인스턴스에게 request, response를 전달하는 작업을 합니다.
4. SecurityFilterChain
SecurityFilterChain은 FilterChain과 별개의 작업입니다. 혼동하시면 안됩니다. SecurityFilterChain은 FilterChainProxy에 의해 사용되며, request에 대해 어떤 Security Filter를 호출할지 정합니다. 앞의 내용은 솔직히 기억하지 않아도 됩니다. 지금부터나오는 SecurityFilterChain과 내부의 SecurityFilter들에 대해 잘 이해하시면 됩니다.
5. SecurityFilter
간단하게 Filter의 Bean입니다. FilterChainProxy에 의해 등록됩니다.
FilterChainProxy와의 관계
FilterchainProxy는 여러가지 장점이 있는데 그중에서 언제 SecurityFilterChain을 호출할지 정하는데 있어 좀 더 유연함을 가집니다. 서블릿 컨테이너 내에서 Filter는 URL에 의해서만 호출이 되지만 FilterChainProxy는 HttpServletRequest에 의해 호출할 수 있습니다.
6. Multi SecurityFilterChain
Multi SecurityFilterChain에서 FilterChainProxy는 어떤 SecurityFilterChain을 사용할지 결정할 수 있습니다. 그리고 오직 첫번째로 매칭되는 SecurityFilterChain이 먼저 호출됩니다. 만약 /api/message
라는 요청을 보냈다면 /api/**
패턴을 가진 SecurityFilterChain이 호출됩니다. 그림에서 보면 SecurityFilterChain(0)는 3개의 SecurityFilter 인스턴스를 SecurityFilterChain(n)는 4개의 SecurityFilter 인스턴스를 가집니다. 즉, 각각의 SecurityFilterChain은 유니크한 설정이 가능합니다.
이제부터 우리가 주목해야할 부분은
SecurityFilterChain
과SecurityFilter
입니다. 그 이외의 내용은 이해만 하시고 넘어가시면 됩니다.
7. SecurityFilters
Filter의 순서는 매우 중요합니다. 다 알 필요는 없지만 해당 필터를 찾아 문제를 해결하시면 좋을 것 같습니다.
- ChannelProcessingFilter
- ConcurrentSessionFilter
- WebAsyncManagerIntegrationFilter
- SecurityContextPersistenceFilter
- HeaderWriterFilter
- CorsFilter
- CsrfFilter
- LogoutFilter
- OAuth2AuthorizationRequestRedirectFilter
- Saml2WebSsoAuthenticationRequestFilter
- X509AuthenticationFilter
- AbstractPreAuthenticatedProcessingFilter
- CasAuthenticationFilter
- OAuth2LoginAuthenticationFilter
- Saml2WebSsoAuthenticationFilter
- UsernamePasswordAuthenticationFilter
- ConcurrentSessionFilter
- OpenIDAuthenticationFilter
- DefaultLoginPageGeneratingFilter
- DefaultLogoutPageGeneratingFilter
- DigestAuthenticationFilter
- BearerTokenAuthenticationFilter
- BasicAuthenticationFilter
- RequestCacheAwareFilter
- SecurityContextHolderAwareRequestFilter
- JaasApiIntegrationFilter
- RememberMeAuthenticationFilter
- AnonymousAuthenticationFilter
- OAuth2AuthorizationCodeGrantFilter
- SessionManagementFilter
- ExceptionTranslationFilter
- FilterSecurityInterceptor
- SwitchUserFilter
8. Handling Security Exception
ExceptionTranslationFilter는 사용자가 로그인 실패하였을 때, 로그인 페이지로 보내거나 접근을 거부하는 역할을 합니다.
사용자가 로그인에 실패하거나 접근을 거부당하면 Exception을 발생시킵니다. 이 Exception을 캐치하는 역할을 ExceptionTranslationFilter가 하게 됩니다. AccessDeniedException(접근 거부)과 AuthenticationExcpetion(로그인 실패)되면 ExceptionTranslationFilter가에 의해 사용자는 로그인 페이지로 보내지게 되거나 아에 접근 거부를 당하게 됩니다. 아래 그림을 보겠습니다.
- ExceptionTranslationFilter는 FilterChain.doFilter(request, response)를 실행합니다. ExceptionTranslationFilter는 Exception이 발생해야 실행되기 때문에 사실상 아무일도 하지 않고 다른 필터로 보내주는 단계입니다.
- 사용자가 Authenticate되지 않거나 AuthnetcationException을 발생하면 Authentication 작업을 시작합니다.
- 먼저 SecurtiyContext에 있는 로그인 정보를 비웁니다.
- HttpServletRequest는 RequestCache에 저장됩니다. 나중에 성공적으로 authenticate되면 ReqeustCache가 HttpServletRequest를 꺼내 원래 하던 일을 계속 시킵니다.
- AutheticationEntryPoint는 사용자를 로그인 페이지로 보내게 합니다
- 1,2 과정이 실패한다면 AccessDeniedException이 실행되고 접근은 거부됩니다. 이때 AccessDeniedHandler가 실행되고 접근거부에 대한 행동을 실행합니다.
ExceptionTranslationFilter를 코드로 한번 보겠습니다.
try {
filterChain.doFilter(request, response); // 1
} catch (AccessDeniedException | AuthenticationException e) { // 에러발생지점
if (!authenticated || e instanceof AuthenticationException) {
startAuthentication(); // 2
} else {
accessDenied(); // 3
}
}
- request가 필터를 일단은 거쳐갑니다. ExceptionTranslationFilter의 어플리케이션 나머지 부분에서 AuthenticationException이나 AccessDeniedException이 발생하였을 때, 에러발생지점으로 와서 Authentication 작업 혹은 accessDeny 작업을 처리하게 됩니다.
- 만약 사용자가 Authenticate 되지 않았을 경우 (로그인 실패 등) 혹은 AuthenticationException을 발생하였을 때, Authentication을 실행합니다. (로그인 페이지로 보내기 등)
- 그렇지 않다면 접근은 거부됩니다.
9. Authentication
이제 본격적으로 Authentication 과정에 대해 살펴보겠습니다. Authentication은 간단히 로그인 하는 과정입니다. 사용자의 아이디와 비밀번호가 저장된 아이디 비밀번호와 일치하는지 검증하는 과정입니다.
용어정리
1. Authentication, SecurityContext, SecurityContextHolder
Authentication
: 로그인에 성공한 사용자의 정보SecurityContext
: Authentication을 저장하는 장소SecurityContextHolder
: SecurityContext를 저장하는 장소
정리하면 사용자가 로그인하면 Authentication라는 객체에 사용자 정보를 담습니다. Authentication은 SecurityContext에 담겨지게 되고 다시 SecurityContext는 SecurityContextHolder에 담겨집니다.
2. ProviderManager, AuthenticationProvider
ProviderManger
: 로그인 이후 Authentication을 받아 AuthenticationProvider에게 전달AuthenticationProvider
: Authentication을 받아 DB에 저장된 사용자 정보와 비교하여 유효한 사용자인지 검증
여기서 ProviderManager
에서 Manager
는 Provider를 관리(manage)하는 역할입니다. 또한 AuthenticationProvider
에서 Provider
는 Authentication을 어디론가 제공(provide)하는 역할을 합니다. 제공되는 곳이 InMemory가 될 수도 있고 DB가 될 수도 있습니다. 제공된 Authentication은 DB나 InMemory에 기존에 회원가입한 내용과 비교를 해서 아이디와 비밀번호가 일치하면 로그인을 인증해주는 절차를 가집니다. Manager
와 Provider
이라는 개념은 OAuth2에서는 OAuth2ClientManager, OAuth2ClientProvider등 서큐리티 전체 영역에서 비슷한 역할로 사용되기 때문에 키워드를 잘 기억해주시면 좋습니다.
앞서 정리한 용어들을 모두 연결된 개념입니다.
- 사용자가 로그인을 한다.
- 사용자 정보를 기반으로 Authentication 객체 생성
- Authentication 객체를 SecurityContext에 넣는다
- SecurityContext를 SecurityContextHolder에 넣는다
- ProviderManager가 Authentication을 받는다
- ProviderManager가 Authentication을 AuhenticationProvider에게 넘긴다
- AuthenticationProvider는 DB 혹은 InMemory에서 회원가입했던 아이디와 Authentication의 정보와 비교한다.
- ProviderManager는 로그인이 허용되면 로그인 성공 페이지를, 실패하면 접근 거부 페이지로 보내준다.
이 큰 흐름에서 Spring Security, OAuth2, JWT 모두 벗어나지 않습니다. 흐름을 기억하시고 각각의 개념을 자세하게 살펴보겠습니다.
1. Authentication
2. ProviderManger
- AuthenticationManager의 구현 클래스
- AuthenticationProvider 리스트를 전달한다
- Authentication을 AuthenticationProvider에게서 받아 SecurityContext에 넣는다
3. AuthenticationProvider
- Authentication을 제공하는 객체
- ProviderManager에게 되돌려준다
- Authentication이 성공인지 실패인지 혹은 다른 AuthenticationProvider가 결정할지를 정한다
- 인증하는 방식에 따라 AuthenticationProvider 구현체가 존재한다
- username/password 기반으로 인증을 한다면
DaoAuthenticationProvider
- Jwt 토큰 기반으로 인증을 한다면
JwtAuthenticationProvider
- username/password 기반으로 인증을 한다면
4. AuthenticaationEntryPoint
- 로그인 페이지로 보낸다
- 로그인 할 때 사용한 사용자 정보로 credential을 생성한다.
4. AbstractAuthenticationProcessingFilter
- 사용자의 credential을 authenticationㅎ기 위한 베이스 Filter로 사용된다.
- 스프링 서큐리티는 AuthenticationEntryPoint를 이용하여 사용자를 로그인 페이지로 보내고 credential을 요청한다.
- AbstractAuthenticationProcessingFilter은 어떤 AuthenticationRequest를 Authenticate 할 수 있을지 정할 수 있다
- 사용자가 로그인을 하고 사용자 정보로 HttpServletRequest에 crendential을 담아 제출한다.
- AbstractAuthenticationProcessingFilter가 HttpServletRequest로 부터 Authentication을 생성한다.
- Authentication의 타입은 AbstractAuthenticationProcessingFilter의 서브 클래스에서 결정된다
- 예를들어 AbstractAuthenticationProcessingFilter의 서브클래스 중 하나인 UsernamePasswordAuthenticationFilter는 HttpServletRequest에서 username과 password를 바탕으로 Authentication 중 하나인 UsernamePasswordAuthenticationToken을 생성한다.
- Authentication이 AuthenticationManager로 Authentication되기 위해 전달된다.
- 만약 authentication에 실패하면
- SecurityContextHolder가 비워진다
- RememberMeServices.loginFail 이 호출된다
- AuthenticationFailureHandler가 호출되고 접근 실패 페이지로 사용자를 보내는 등의 후속작업을 한다.
- 만약 authentication에 성공하면
- SessionAuthenticationStrategy에게 성공 이벤트를 날린다
- Authentication이 SecurityContextHolder에 담겨진다
- AuthenticationSuccessHandler를 호출하여 로그인 성공 이후 화면으로 보내는 등의 후속작업을 한다.
5. Username/Password Authentication
- username과 password로 인증하는 방식
- username과 password를 읽는 방법은 여러가지가 있다
- Form 로그인
- BasicAuthentication
- DigestAuthentication
- 읽어온 username과 password를 저장하는 방법도 여러가지가 있다.
- InMemory Authentication
- JDBC Authentication
- UserDetailService
5.1 Form Login
- 사용자는 승인되지 않은(unauthenticate) request
/private
를 보낸다 - 스프링 서큐리티의 FilterSecurityInterceptor는 unauthenticate된 request라고 AccessDeniedException을 보낸다
- unauthenticate되었으므로 ExceptionTranslationFilter가 Start Authentication을 시작한다.
- AuthenticationEntryPoint가 로그인 페이지로 리다이렉트 시킨다
- 브라우저는 로그인 페이지로 이동하기 위해
/login
리퀘스트를 서버로 보낸다 - 서버는 브라우저에
login.html
을 응답한다 - login 페이지에서 username와 password를 입력하고 로그인 버튼을 누른다
- 사용자가 보낸 username과 password 데이터가 HttpServletRequest에 실어서 서버로 보내진다.
- UsernamePasswordAuthenticationFilter가 HttpServletRequest에서 해당 정보로 UseranmePasswordAuthenticationToken (Authentication의 구현객체)를 생성한다.
- UsernamePasswordAuthenticationToken이 authenticate하기 위해 AuthenticationManager로 보내진다.
- AuthenticationProvider가 UseranmePasswordAuthenticationToken을 받아 InMemory 혹은 DB에 저장된 username과 비교하여 같으면 로그인 성공, 다르다면 로그인 실패 메세지를 AuthenticationManager에게 보낸다
- 만약 성공한다면 AuthenticationManager는
- SessionAuthenticationStrategy에게 새로운 로그인이라고 알려준다
- Authentication이 SecurityContextHolder에 세팅된다.
- AuthenticationSuccessHandler가 호출되면서 사용자를 로그인 페이지로 보낸다
- SimpleUrlAuthenticationSuccessHandler는 사용자가 로그인 페이지로 이동할 때 ExcpetionTranslationFilter에 저장된 request를 보낸다
- 만약 실패한다면 AuthenticationManager는
- SecurityContext가 비워진다.
- AuthenticationFailureHandler가 호출되면서 접근 거부 페이지를 출력하는 등의 후속작업을 진행한다.