이전 포스팅에서는 스프링 시큐리티의 인증 프로세스에 대해 살펴보았다. 인증에 성공한 요청은 리소스에 대한 권한을 부여받고, 디스패쳐 서블릿에 도달한다. 이번 글에서는 인증 프로세스를 포함한 스프링 시큐리티의 전체 동작흐름과 스프링 시큐리티를 구성하는 필터체인에 대해 알아보자.
스프링 시큐리티의 전체적인 구조는 아래와 같다.
가장 먼저 사용자가 어플리케이션으로 요청을 보내면 DelegatingFilterProxy에서 SecurityFilterChain으로 요청을 전달해준다. 이후 사용자의 요청은 SecurityFilterChain을 거치면서 인증되고 권한을 부여받는다. 이때 적용되는 필터체인은 WebSecurity의 HttpSecurity에서 결정된다.
@Configuration
@EnableWebSecurity // SpringSecurity 설정 활성화
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors().and()
.csrf().disable()
.headers().frameOptions().disable()
...
http.addFilterBefore(new JwtAuthFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
위의 코드와 같이 SecurityConfig클래스를 만들어 HttpSecurity에 여러 설정을 해준 뒤 SecurityFilterChain을 빈으로 등록하면 필터체인을 커스텀할 수 있다.
public final class HttpSecurity extends AbstractConfiguredSecurityBuilder<DefaultSecurityFilterChain, HttpSecurity>
implements SecurityBuilder<DefaultSecurityFilterChain>, HttpSecurityBuilder<HttpSecurity> {
...
private List<OrderedFilter> filters = new ArrayList<>();
private FilterOrderRegistration filterOrders = new FilterOrderRegistration();
...
public SessionManagementConfigurer<HttpSecurity> sessionManagement() throws Exception {
return getOrApply(new SessionManagementConfigurer<>());
}
...
@Override
public HttpSecurity addFilter(Filter filter) {
Integer order = this.filterOrders.getOrder(filter.getClass());
if (order == null) {
throw new IllegalArgumentException("The Filter class " + filter.getClass().getName()
+ " does not have a registered order and cannot be added without a specified order. Consider using addFilterBefore or addFilterAfter instead.");
}
this.filters.add(new OrderedFilter(filter, order));
return this;
}
}
HttpSecurity에는 필터 적용여부나 보안전략등을 설정하거나, 직접 만든 커스텀 필터를 등록할 수 있는 여러 메소드가 있기 때문에 여기에서 상황에 맞는 메소드를 찾아 사용하면 된다. 아래는 HttpSecurity를 커스텀하여 실제로 적용된 필터들의 목록이다.
2023-07-14T20:59:53.146+09:00 INFO 23688 --- [ main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with [
org.springframework.security.web.session.DisableEncodeUrlFilter@10664238,
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@2f4f5830,
org.springframework.security.web.context.SecurityContextHolderFilter@35db4a85,
org.springframework.security.web.header.HeaderWriterFilter@4aada1fe,
org.springframework.web.filter.CorsFilter@77625485,
org.springframework.security.web.authentication.logout.LogoutFilter@6d4fdab9,
org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter@de65e0a,
org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter@7b947c20,
ving.vingterview.auth.jwt.JwtAuthFilter@432338c7,
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@9fbf3ea,
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@6176c519,
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@670e85cf,
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@4022768e,
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@34ad9fdf,
org.springframework.security.web.session.SessionManagementFilter@7963db35,
org.springframework.security.web.access.ExceptionTranslationFilter@4c398655,
org.springframework.security.web.access.intercept.AuthorizationFilter@7df2aeb6]
HttpSecurity의 멤버필드인 filterOrders에는 기본적으로 등록된 필터들이 순서대로 저장되어있기 때문에 필터체인의 순서가 올바른지 확인하는데 사용된다.
final class FilterOrderRegistration {
...
FilterOrderRegistration() {
Step order = new Step(INITIAL_ORDER, ORDER_STEP);
put(DisableEncodeUrlFilter.class, order.next());
put(ForceEagerSessionCreationFilter.class, order.next());
put(ChannelProcessingFilter.class, order.next());
order.next(); // gh-8105
put(WebAsyncManagerIntegrationFilter.class, order.next());
put(SecurityContextHolderFilter.class, order.next());
put(SecurityContextPersistenceFilter.class, order.next());
put(HeaderWriterFilter.class, order.next());
put(CorsFilter.class, order.next());
put(CsrfFilter.class, order.next());
put(LogoutFilter.class, order.next());
put(X509AuthenticationFilter.class, order.next());
put(AbstractPreAuthenticatedProcessingFilter.class, order.next());
put(UsernamePasswordAuthenticationFilter.class, order.next());
order.next(); // gh-8105
put(DefaultLoginPageGeneratingFilter.class, order.next());
put(DefaultLogoutPageGeneratingFilter.class, order.next());
put(ConcurrentSessionFilter.class, order.next());
put(DigestAuthenticationFilter.class, order.next());
put(BasicAuthenticationFilter.class, order.next());
put(RequestCacheAwareFilter.class, order.next());
put(SecurityContextHolderAwareRequestFilter.class, order.next());
put(JaasApiIntegrationFilter.class, order.next());
put(RememberMeAuthenticationFilter.class, order.next());
put(AnonymousAuthenticationFilter.class, order.next());
put(SessionManagementFilter.class, order.next());
put(ExceptionTranslationFilter.class, order.next());
put(FilterSecurityInterceptor.class, order.next());
put(AuthorizationFilter.class, order.next());
put(SwitchUserFilter.class, order.next());
}
...
}
이 목록에 따라 SecurityFilterChain의 주요 필터의 역할에 대해 알아보자.
SecurityContextRepository에서 SecurityContext를 로드하여 세션에 저장한다. SpringSecurity 5.7버전 이후부터는 SecurityContextPersistenceFilter가 사용되지 않고 SecurityContextHolderFilter로 변경되었다. 차이점은 SecurityContextRepository에서 SecurityContext를 로드할 때 loadContext()대신 loadDeferredContext()를 사용하여 SecurityContext를 로드하는 시점을 어플리케이션에서 이를 사용하는 시점까지 미뤄 효율성과 유연성을 높였다
public class SecurityContextHolderFilter extends GenericFilterBean {
private final SecurityContextRepository securityContextRepository;
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
.getContextHolderStrategy();
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
if (request.getAttribute(FILTER_APPLIED) != null) { //이미 필터가 적용되었으면 건너뛰기
chain.doFilter(request, response);
return;
}
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
Supplier<SecurityContext> deferredContext = this.securityContextRepository.loadDeferredContext(request); // SecurityContext를 로드
try {
this.securityContextHolderStrategy.setDeferredContext(deferredContext); // SecurityContextHolder에 SecurityContext를 보관
chain.doFilter(request, response);
}
finally {
this.securityContextHolderStrategy.clearContext(); // 인증/인가 과정에서 사용한 리소스(SecurityContext) 정리(모든 필터가 적용된 후 실행)
request.removeAttribute(FILTER_APPLIED);
}
}
}
CORS(Cross Origin Resource Sharing)정책을 체크하는 필터로, CorsProcessor를 호출하여 처리한다.
public class CorsFilter extends OncePerRequestFilter {
private CorsProcessor processor = new DefaultCorsProcessor();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
CorsConfiguration corsConfiguration = this.configSource.getCorsConfiguration(request);
boolean isValid = this.processor.processRequest(corsConfiguration, request, response); //CORS처리
if (!isValid || CorsUtils.isPreFlightRequest(request)) {
return;
}
filterChain.doFilter(request, response);
}
}
CSRF공격(Cross Site Request Forgery)에 대응하는 필터로, request의 csrf토큰이 유효하면 필터를 통과할 수 있다.
public final class CsrfFilter extends OncePerRequestFilter {
private final CsrfTokenRepository tokenRepository;
private AccessDeniedHandler accessDeniedHandler = new AccessDeniedHandlerImpl();
private CsrfTokenRequestHandler requestHandler = new XorCsrfTokenRequestAttributeHandler();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
DeferredCsrfToken deferredCsrfToken = this.tokenRepository.loadDeferredToken(request, response); // csrf토큰 로드
request.setAttribute(DeferredCsrfToken.class.getName(), deferredCsrfToken);
this.requestHandler.handle(request, response, deferredCsrfToken::get);
if (!this.requireCsrfProtectionMatcher.matches(request)) {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Did not protect against CSRF since request did not match "
+ this.requireCsrfProtectionMatcher);
}
filterChain.doFilter(request, response);
return;
}
CsrfToken csrfToken = deferredCsrfToken.get();
String actualToken = this.requestHandler.resolveCsrfTokenValue(request, csrfToken); // request에서 csrf토큰 추출
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) { // 토큰이 유효하지 않으면 reject
boolean missingToken = deferredCsrfToken.isGenerated();
this.logger.debug(
LogMessage.of(() -> "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request)));
AccessDeniedException exception = (!missingToken) ? new InvalidCsrfTokenException(csrfToken, actualToken)
: new MissingCsrfTokenException(actualToken);
this.accessDeniedHandler.handle(request, response, exception);
return;
}
filterChain.doFilter(request, response);
}
}
로그아웃 요청을 처리하는 필터로, logoutRequestMatcher에 매칭되는 url로 요청이 들어오면 logoutHandler를 통해 사용자를 로그아웃 시키고 로그아웃에 성공하면 logoutSuccessHandler가 실행된다.
public class LogoutFilter extends GenericFilterBean {
private RequestMatcher logoutRequestMatcher;
private final LogoutHandler handler;
private final LogoutSuccessHandler logoutSuccessHandler;
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (requiresLogout(request, response)) {
Authentication auth = this.securityContextHolderStrategy.getContext().getAuthentication();
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Logging out [%s]", auth));
}
this.handler.logout(request, response, auth); // 로그아웃
this.logoutSuccessHandler.onLogoutSuccess(request, response, auth); // 로그아웃 성공 핸들러 호출
return;
}
chain.doFilter(request, response);
}
}
이전 포스트에서 살펴본 인증과정의 시작점이다. Form기반의 로그인 요청으로 사용자의 아이디와 비밀번호를 받아 인증 전 Authentication객체를 생성하고 AuthenticationManager에게 인증을 위임한다.
public class UsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (this.postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
username = (username != null) ? username.trim() : "";
String password = obtainPassword(request);
password = (password != null) ? password : "";
UsernamePasswordAuthenticationToken authRequest
= UsernamePasswordAuthenticationToken.unauthenticated(username, password); // 인증 전 Authentication객체 생성
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest); // 인증 요청 위임
}
}
상위 클래스인 AbstractAuthenticationProcessingFilter에서는 loginRequestMatcher에 매칭되는 url로 요청이 들어오면 attemptAuthentication메소드를 통해 인증을 수행하고 인증에 성공하면 loginSuccessHandler를, 실패하면 loginFailureHandler를 호출한다.
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
implements ApplicationEventPublisherAware, MessageSourceAware {
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
try {
Authentication authenticationResult = attemptAuthentication(request, response); // 인증 시도
if (authenticationResult == null) {
// return immediately as subclass has indicated that it hasn't completed
return;
}
this.sessionStrategy.onAuthentication(authenticationResult, request, response); // 세션에 인증정보를 등록
// Authentication success
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, chain, authenticationResult); // 로그인 성공 핸들러 호출
}
catch (InternalAuthenticationServiceException failed) {
this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
unsuccessfulAuthentication(request, response, failed); // 로그인 실패 핸들러 호출
}
catch (AuthenticationException ex) {
// Authentication failed
unsuccessfulAuthentication(request, response, ex);
}
}
}
동시세션을 처리하는 필터로, sessionRegistry에 같은 세션이 있는지 조회하여 동시세션을 찾고 세션을 갱신하거나 만료된 세션의 사용자를 로그아웃시킨다.
public class ConcurrentSessionFilter extends GenericFilterBean {
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpSession session = request.getSession(false);
if (session != null) {
SessionInformation info = this.sessionRegistry.getSessionInformation(session.getId()); // 동시세션 조회
if (info != null) {
if (info.isExpired()) {
// Expired - abort processing
this.logger.debug(LogMessage
.of(() -> "Requested session ID " + request.getRequestedSessionId() + " has expired."));
doLogout(request, response); // 만료된 세션 로그아웃
this.sessionInformationExpiredStrategy
.onExpiredSessionDetected(new SessionInformationExpiredEvent(info, request, response));
return;
}
// Non-expired - update last request date/time
this.sessionRegistry.refreshLastRequest(info.getSessionId()); // 세션 갱신
}
}
chain.doFilter(request, response);
}
}
세션이 만료되더라도 요청헤더에 포함된 remember-me토큰을 바탕으로 인증을 수행. 세션이 만료되어 SecurityContext 안의 Authentication객체가 null이 되면 필터가 동작한다.
public class RememberMeAuthenticationFilter extends GenericFilterBean implements ApplicationEventPublisherAware {
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (this.securityContextHolderStrategy.getContext().getAuthentication() != null) {
this.logger.debug(LogMessage
.of(() -> "SecurityContextHolder not populated with remember-me token, as it already contained: '"
+ this.securityContextHolderStrategy.getContext().getAuthentication() + "'"));
chain.doFilter(request, response);
return;
}
Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response); // remember-me 토큰 기반 자동로그인
if (rememberMeAuth != null) {
// Attempt authenticaton via AuthenticationManager
try {
rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth); // 인증객체 생성
// Store to SecurityContextHolder
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(rememberMeAuth);
this.securityContextHolderStrategy.setContext(context); // SecurityContextHolder에 인증객체를 담은 SecurityContext저장
onSuccessfulAuthentication(request, response, rememberMeAuth);
this.logger.debug(LogMessage.of(() -> "SecurityContextHolder populated with remember-me token: '"
+ this.securityContextHolderStrategy.getContext().getAuthentication() + "'"));
this.securityContextRepository.saveContext(context, request, response); // 세션에 SecurityContext저장
if (this.eventPublisher != null) {
this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
this.securityContextHolderStrategy.getContext().getAuthentication(), this.getClass()));
}
if (this.successHandler != null) {
this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
return;
}
}
catch (AuthenticationException ex) {
this.logger.debug(LogMessage
.format("SecurityContextHolder not populated with remember-me token, as AuthenticationManager "
+ "rejected Authentication returned by RememberMeServices: '%s'; "
+ "invalidating remember-me token", rememberMeAuth),
ex);
this.rememberMeServices.loginFail(request, response);
onUnsuccessfulAuthentication(request, response, ex);
}
}
chain.doFilter(request, response);
}
}
SecurityFilterChain의 마지막 인증필터로, 이 시점까지 인증을 시도하지 않았다면 익명 사용자 인증객체를 만들어 SecurityContext에 담아 SecurityContextHolder에 저장한다.
public class AnonymousAuthenticationFilter extends GenericFilterBean implements InitializingBean {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
Supplier<SecurityContext> deferredContext = this.securityContextHolderStrategy.getDeferredContext();
this.securityContextHolderStrategy
.setDeferredContext(defaultWithAnonymous((HttpServletRequest) req, deferredContext)); // 익명 사용자 인증객체 생성
chain.doFilter(req, res);
}
}
다중 세션을 처리하거나 세션ID를 변경하는 등 세션 정책에 따라 세션을 처리하는 필터. SecurityContextHolderStrategy의 구현체에 따라 세션정책이 달라진다.
public class SessionManagementFilter extends GenericFilterBean {
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (request.getAttribute(FILTER_APPLIED) != null) {
chain.doFilter(request, response);
return;
}
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
if (!this.securityContextRepository.containsContext(request)) {
Authentication authentication = this.securityContextHolderStrategy.getContext().getAuthentication();
if (authentication != null && !this.trustResolver.isAnonymous(authentication)) {
// The user has been authenticated during the current request, so call the
// session strategy
try {
this.sessionAuthenticationStrategy.onAuthentication(authentication, request, response); // 정책에 따라 세션 처리
}
catch (SessionAuthenticationException ex) {
// The session strategy can reject the authentication
this.logger.debug("SessionAuthenticationStrategy rejected the authentication object", ex);
this.securityContextHolderStrategy.clearContext();
this.failureHandler.onAuthenticationFailure(request, response, ex);
return;
}
// Eagerly save the security context to make it available for any possible
// re-entrant requests which may occur before the current request
// completes. SEC-1396.
this.securityContextRepository
.saveContext(this.securityContextHolderStrategy.getContext(), request, response);
}
else {
// No security context or authentication present. Check for a session
// timeout
if (request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid()) {
if (this.logger.isDebugEnabled()) {
this.logger.debug(LogMessage.format("Request requested invalid session id %s",
request.getRequestedSessionId()));
}
if (this.invalidSessionStrategy != null) {
this.invalidSessionStrategy.onInvalidSessionDetected(request, response); // 유효하지 않은 세션 처리
return;
}
}
}
}
chain.doFilter(request, response);
}
}
필터체인 내에서 발생한 예외를 잡아서 처리하는 필터. AuthenticationException(인증 실패)이 발생한 경우 AuthenticationEntryPoint로 넘어가 401 UNAUTHORIZED 오류를 던지거나 로그인 페이지로 리다이렉트한다. AccessDeniedException(비인가 접근)이 발생한 경우에는 만약 익명 사용자거나 remember-me요청인 경우 AuthenticationEntryPoint로 넘어가고, 인증된 사용자라면 AccessDeniedHandle를 통해 403 FORBIDDEN을 던진다.
public class ExceptionTranslationFilter extends GenericFilterBean implements MessageSourceAware {
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
chain.doFilter(request, response);
}
catch (IOException ex) {
throw ex;
}
catch (Exception ex) {
// Try to extract a SpringSecurityException from the stacktrace
Throwable[] causeChain = this.throwableAnalyzer.determineCauseChain(ex);
RuntimeException securityException = (AuthenticationException) this.throwableAnalyzer
.getFirstThrowableOfType(AuthenticationException.class, causeChain); // AuthenticationException을 런타임 예외로 변환
if (securityException == null) {
securityException = (AccessDeniedException) this.throwableAnalyzer
.getFirstThrowableOfType(AccessDeniedException.class, causeChain); // AccessDeniedException을 런타임 예외로 변환
}
if (securityException == null) {
rethrow(ex);
}
if (response.isCommitted()) {
throw new ServletException("Unable to handle the Spring Security Exception "
+ "because the response is already committed.", ex);
}
handleSpringSecurityException(request, response, chain, securityException); // 예외의 종류에 따라 예외 처리
}
}
}
리소스에 대한 접근권한을 부여하는 인가필터로, AuthorizationManager에게 인가 프로세스를 위임한다. SpringSecurity5.5 이후 FilterSecurityInterceptor가 삭제되고 AuthorizationFilter로 바뀌었다. 구체적인 인가 프로세스는 AuthorizationManager에 따라 다른데, 기본적으로는 isGranted()를 통해 사용자에게 부여된 권한을 확인하고 AuthorizationDecision을 만들어 요청한 리소스에 접근할 수 있는지 판단한 결과를 반환하는 과정으로 이루어진다.
public class AuthorizationFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain)
throws ServletException, IOException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
if (this.observeOncePerRequest && isApplied(request)) {
chain.doFilter(request, response);
return;
}
if (skipDispatch(request)) {
chain.doFilter(request, response);
return;
}
String alreadyFilteredAttributeName = getAlreadyFilteredAttributeName();
request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);
try {
AuthorizationDecision decision = this.authorizationManager.check(this::getAuthentication, request); // 인가 프로세스 위임
this.eventPublisher.publishAuthorizationEvent(this::getAuthentication, request, decision);
if (decision != null && !decision.isGranted()) {
throw new AccessDeniedException("Access Denied");
}
chain.doFilter(request, response);
}
finally {
request.removeAttribute(alreadyFilteredAttributeName);
}
}
}