세세한 걸 알아보기에 앞서, 전체적인 동작을 확인해 보자.
우선, Spring Security는 Servlet의 Filter 기반으로 작동한다.
문제는 여기에 있다. Filter는 Spring Context 밖에 있는 녀석이다.
그 말은, Bean을 인식하지 못한다는 거다.
그렇기에 Servlet의 FilterChain에 DelegatingFilterProxy
라는 이름만 들어도 껍데기 냄새가 폴폴나는 필터를 하나 집어넣는 트릭을 쓴다.
// Servlet의 FilterChain
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
// do something before the rest of the application
chain.doFilter(request, response); // invoke the rest of the application
// do something after the rest of the application
}
// DelegatingFilterProxy의 SecurityFilterChain
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
// Lazily get Filter that was registered as a Spring Bean
// For the example in DelegatingFilterProxy delegate is an instance of Bean Filter0
Filter delegate = getFilterBean(someBeanName);
// delegate work to the Spring Bean
delegate.doFilter(request, response);
}
DelegatingFilterProxy
는 Bean으로 등록된 Filter들을 찾아내 받는 것이 일이다.
이 녀석은 Spring Context에 속해있기 때문에 당연히 그게 가능하다.
이런 구조를 취함으로써 얻는 이득이 하나 더 있다.
Bean으로 등록된 Filter들을 찾는 시점을 늦출 수 있다는 것이다.
이게 왜 중요하냐면, 컨테이너는 기본적으로 컨테이너로써 동작하기 전에 Filter 구성을 끝마친다. 그런데 Spring은 ContextLoaderListener
를 사용해 빈을 등록한다. 문제는 이게 한참 뒤에 실행되기 때문에 Bean으로 등록된 Filter들은 정작 Servlet의 Filter들이 구성될 때에 로드가 되지 않은 상태이기 때문이다.
간단히 비유하자면,
'여기 치킨 배달 되죠?' -> '네, 됩니다. 바로 나갑니다.' 해놓고는 뒤에서 생닭 사러 갔다는 거다.
DelegatingFilterProxy
에 의해 찾아내진 Bean들은 이 FilterChainProxy
에 등록된다.
그림으로 표현하자면 이렇다.
이 녀석도 이름따라 껍데기인데, 실질적인 필터링은 등록된 SecurityFilterChain
내부의 필터들에게 위임하는 역할을 한다.
왜 프록시에 프록시를 넣는 이따위 구조를 쓰냐?
장점이 몇 개 있다.
FilterChainProxy
에 등록해버릴 수 있다. SecurityContext
를 지운다던가, HttpFirewall
을 적용하여 특정 유형의 공격으로부터 애플리케이션을 보호한다던가.SecurityFilterChain
을 호출 시기를 결정하는 데에 있어 더 많은 유연성을 제공한다.RequestMatcher
인터페이스를 이용해 HttpServletRequest
의 값을 기반으로 호출을 결정할 수 있다.SecurityFilterChain
이 있을 때, 어떤 것을 사용해야 할 지도 정할 수 있다.아무튼, Bean으로 등록된 Filter들은 SecurityFilterChain이라는 내부의 필터 체인에서 돌아가게 된다.
만약 Filter를 제대로 통과하지 못한다면 인증이 요구된다.
Spring Security를 적용하고나서 어플리케이션을 실행했을 때, 로그인 화면으로 가는 것이 이 때문.
성공적으로 로그인을 해 인증을 하면, 해당 정보는
이렇게 SecurityContextHolder
내부의 SecurityContext
안의 Authentication
에 저장된다.
Principal
은 유저 ID, Credentials
은 비밀번호, Authorities
는 권한으로 보면 된다.
필터의 종류가 정말 많다.
일단 공식 사이트에 있는 것만 32개인데, 이런 사이트 특성 상 업데이트가 잘 안된다는 걸 생각해보면 더 많을 수도 있지 않을까?
그런고로, 모든 필터가 다 알고 싶다면 밑에 References에서 첫 번째 사이트로 간 다음 9.5 항목을 살펴보자.
여기서는 주로 사용되는 필터 위주로 알아보도록 하겠다.
DefaultLoginPageGeneratingFilter : 기본 로그인 페이지를 제공한다.
DefaultLogoutPageGeneratingFilter : 기본 로그아웃 페이지를 제공한다.
CorsFilter : CORS (Cross-origin resource sharing)에 대한 검사를 한다.
CsrfFilter : CSRF (Cross-site request forgery)에 대한 검사를 한다.
LogoutFilter : logout request인지에 대한 검사를 한다.
SecurityContextPersistenceFilter :
SecurityContext
를 여러 요청에 걸쳐 공유하게 해 준다.SecurityContextRepository
인터페이스를 이용하는데 디폴트 구현체는 HttpSessionSecurityContextRepository
이다.httpSession
에 SecurityContext
를 넣어두고 있다가, 다음 요청이 오면 SecurityContextHolder
에서 SecurityContext
를 꺼낸다.SecurityContextHolder
를 비운다.SecurityContext
를 SecurityContextRepository
에 저장한다.SecurityContextHolder
는 쓰레드별로 SecurityContext
를 관리하기 때문. Servlet은 요청마다 쓰레드를 새로 생성하기 때문에, 새로운 요청에선 기존 인증 정보에 접근을 못한다.RememberMeAuthenticationFilter :
RemberMeServices
인터페이스를 활용한다.AbstractRemeberMeServices
가 있으며, 이를 상속한 TokenBaseRememberMeServicese
, PersisenceTokenBasedRememberMeServicese
가 있다.TokenBaseRememberMeServicese
는 토큰을 브라우저 쿠키에 저장하고 서버에는 저장하지 않는다. ID와 만료시간 변환된 암호값이 그대로 토큰에 들어가기 때문에, 만약 토큰이 탈취되면 만료시간이 끝나거나 비밀번호가 변경되지 않는 한 ID가 공공재가 되어버린다.PersisenceTokenBasedRememberMeServicese
이다. ID와 만료시간 대신 토큰의 시리즈 값을 넘겨준다. 대신 서버에서는 PersistentTokenRepository
인터페이스의 구현체인 JdbcTokenRepositoryImpl
를 통해 DB로 토큰을 관리한다.CookieTheftException
이 발생되며, 해당 유저에게 발급되었던 토큰은 DB에서 모두 제거되어 버린다. 그렇기에 탈취 된 토큰은 더이상 유효하지 않게 된다.AnonymousAuthenticationFilter : Security Filter 처리를 위해서는 어쨌거나 Authentication이 null이어선 안된다. 그렇기 때문에 인증 정보가 아직 존재하지 않는다면, 익명 사용자용으로 아무것도 인증하지 못하는 빈 껍데기 같은 걸 하나 내준다.
UsernamePasswordAuthenticationFilter : username과 password를 이용한 Form 로그인 방식을 지원한다.
OAuth2AuthorizationRequestRedirectFilter :
OAuth2AuthorizationRequestResolver
인터페이스의 구현체인 DefaultOAuth2AuthorizationRequestResolver
를 이용해 어느 사이트의 인증이 필요한지를 알아낸다.OAuth2AuthorizationRequest
객체를 만들어 해당 사이트로 redirect 요청을 보낸다.OAuth2LoginAuthenticationFilter :
ClientRegistration
을 통해 인증의 결과를 받는다. 이후, OAuth2AuthorizationResponse
객체로 authorization code를 생성해서 OAuth2LoginAuthenticationToken
을 얻는다.OAuth2LoginAuthenticationProvider
라고 하는 ProviderManager
의 일종이 일을 한다.DefaultAuthorizationCodeTokenResponseClient
구현체를 이용해 OAuth2 server에서 토큰을 요청한다.BasicAuthenticationFilter : SPA이나 모바일 환경에서 요청 헤더에 토큰을 담아서 로그인하는 Ajax 로그인을 지원한다.
ConcurrentSessionFilter : 매 요청마다 현재 사용자의 세션 만료 여부를 체크한다. 만료 여부는 SessionManagementFilter에서 설정한 값을 따른다. 세션이 만료되었을 경우, 로그아웃을 시키고 세션 만료에 관한 처리를 한다.
SessionManagementFilter :
http.sessionManagement.maximumSessions(1)
과 같은 식으로 최대 세션 개수를 정한다. 즉, 중복 로그인을 막는다는 것. 2나 3 등으로 해도 된다.IF_REQUIRED
, ALWAYS
, NEVER
, STATELESS
가 있다.ALWAYS
는 언제나 Spring Security가 세션을 생성, NEVER
는 하지 않지만 이미 존재한다면 사용한다. IF_REQUIRED
는 기본값
이며 필요하다면 생성한다. STATELESS
는 생성하지 않고 존재해도 사용하지 않는다. STATELESS
는 주로 JWT
인증 방식일 때 사용된다.FilterSecurityInterceptor : 사용자의 request에 대해 인증과 권한을 확인한다. 통과 못 할 경우에 Exception이 던져지고, ExceptionTranslationFilter
에 의해 처리된다.
ExceptionTranslationFilter : FilterSecurityInterceptor
가 AccessDecisionManager
를 통해 AuthenticationException
혹은 AccessDeniedException
을 발생시킨 경우, Exception을 받아 각각 AuthenticationEntryPoint
, AccessDeniedHandler
로 보낸다.
EnableWebSecurity(debug = true)
로 하고 security filter chain을 통과하게 만들면 볼 수 있다.
아래의 순서를 따른다.
HttpSecurity.httpBasic().disable()
따위의 설정으로 인해 적용 해제 된 필터도 있다.
각 과정에 대해 살펴보자.
ExceptionTranslationFilter
가 필터 체인을 호출한다.AuthenticationException
예외가 던져졌다면, 인증 절차를 시작한다.SecurityContextHolder
를 비운다.HttpServletRequest
가 RequestCache
에 저장된다. 유저가 성공적으로 인증을 했다면, RequestCache
는 원본 요청을 다시 불러오는데 쓰일 수 있다.AuthenticationEntryPoint
는 사용자를 로그인 페이지로 다시 보내거나 하여 credential
을 요구한다. AccessDeniedException
이 발생한 경우, AccessDeniedHandler
에게 제어가 넘어가며 접근이 거부된다.아래는 ExceptionTranslationFilter
의 수도 코드이다.
주석이 있는 부분이 위 그림의 1, 2, 3번과 일치한다.
try {
filterChain.doFilter(request, response); // 1
} catch (AccessDeniedException | AuthenticationException ex) {
if (!authenticated || ex instanceof AuthenticationException) {
startAuthentication(); // 2
} else {
accessDenied(); // 3
}
}