[Spring Security] Authentication 이후 첫 요청이 메인 페이지로의 redirect를 강제하는 문제

lsjbh45·2022년 7월 10일
4

문제의 발견

우리 팀이 개발했던 웹 어플리케이션들의 사용자 인증(Authentication)은 SSO(Single Sign-On) 인증 방식을 기반으로, 개발된 웹 어플리케이션을 운영 서버에 배포하면 인증 토큰으로 권한 인증을 수행하는 방식이었다. 하지만, 개발 단계의 로컬 서버 상에서는 실제 인증 토큰을 사용할 수 없었기 때문에 주로 mock sso token을 만들어 backdoor를 통해 발급해 사용하곤 했다. 인증 없이 접근이 허용되어 있는 backdoor 요청을 통해 cookie를 생성해주고, 실제 요청이 오면 해당 cookie를 기반으로 authentication filter를 지나며 인증을 수행하는 방식이다.

public void configure(WebSecurity web) throws Exception {
	/* ... */
    web
    	.ignoring()
        .antMatchers("/backdoor", "/api/backdoor");
    /* ... */
}
@PostMapping("/backdoor")
public ModelAndView enter(@RequestParam("userid") String userid, HttpServletResponse response) throws Exception {
	/* ... */
    
	String token;
	try {
    	token = AESUtils.encrypt("mockSSOCookieKey", userid);
    } catch (/* ... */) {
    	/* ... */
    }

	response.addCookie(this.createCookie(SSOType.MOCK.getKey(), token, -1));
    
    ModelAndView mav = new ModelAndView("redirect:");
    return mav
    /* ... */

문제를 발견한 것은 개발 도중 SPA(Single Page Application) 방식을 적용하기 위해서 mvc pattern으로 만들어져 있던 기존 backdoor를 API call을 보내는 방식으로 변경한 뒤였다. backdoor 요청 후 바로 메인 페이지로의 redirect가 수행되며 자연스럽게 바로 인증 과정으로 이어졌던 기존 방식에 가려진 문제가 드러난 것이다. 바로 backdoor API call 이후 실제로 요청하는 첫 번째 API call에서 요청에 대한 정보를 응답으로 보내는 것이 아니라, 메인 페이지로의 redirect가 강제로 일어난다는 점이다.

@GetMapping("/api/backdoor")
public ResponseEntity<Void> signIn(@RequestParam("userid") String userid, HttpServletResponse response) {
	String token;
	try {
    	token = AESUtils.encrypt("mockSSOCookieKey", userid);
    } catch (/* ... */) {
    	/* ... */
    }

	response.addCookie(this.createCookie(SSOType.MOCK.getKey(), token, -1));
    
    return new ResponseEntity<>(HttpStatus.OK);

원인에 대한 고민

이러한 문제에 대해 몇 가지 원인으로 보일 수 있는 부분들에 대해 확인해 보았다.

  • mvc pattern이 아니라 API call 방식을 사용했기 때문이다: mvc pattern에서도 항상 redirect가 발생하는 것을 확인할 수 있었다. 실제로 기존에 사용했던 방식에서도 backdoor page에 대한 post 요청이 main servlet path("/")에 대한 redirect가 발생시키고, 인증이 수행되면서 다시 한 번 main servlet path에 대한 redirect가 발생하는 것을 확인할 수 있었다.
  • mock sso token 사용의 문제이다: 실제 인증 토큰을 사용하는 운영 서버에서 구동되고 있는 실제 서비스들을 확인해 보았을 때, 동일한 현상이 발생(로그인 후 첫 요청으로 메인 페이지가 아닌 다른 path로 접근했음에도 메인 페이지로 이동하는 경우)하는 서비스가 존재했다.
  • Spring Security 설정의 문제이다: debug를 통해 Spring Security 의 내부 구현을 확인해 보니, default 설정이 메인 페이지로의 redirect를 발생시킨다는 것을 확인할 수 있었다.

Spring Security 내부 구현과 메인 페이지로의 redirect

org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter

package org.springframework.security.web.authentication;

public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
		implements ApplicationEventPublisherAware, MessageSourceAware {
	/* ... */
    
    private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
	
    /* ... */

    @Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
	}

	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);
		}
	}
  
	/* ... */

	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);
	}
    
    /* ... */
}

Spring Security에서는 인증 과정을 구현하기 위해 abstract class인 AbstractAuthenticationProcessingFilter를 상속받아 실제 인증 수행 과정인 attemptAuthentication method를 구현한 구현체를 bean으로 filter chain에 등록해 사용한다. 이 filter의 doFilter method에서는 requiresAuthentication을 확인한 뒤에 인증이 필요한 경우 더 이상 filter chain을 넘어가지 않고 attemptAuthentication method를 호출해 인증을 수행한다. 주목해야 할 점은 인증을 수행한 뒤 successfulAuthentication method를 호출한다는 점이다. successfulAuthentication method는 다시 successHandleronAuthenticationSuccess method를 호출하게 된다. 이때 successHandler는 기본적으로 SavedRequestAwareAuthenticationSuccessHandler instance로 지정되어 있다.

org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler

package org.springframework.security.web.authentication

public class SavedRequestAwareAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
	/* ... */
   	
    @Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws ServletException, IOException {
		SavedRequest savedRequest = this.requestCache.getRequest(request, response);
		if (savedRequest == null) {
			super.onAuthenticationSuccess(request, response, authentication);
			return;
		}
		String targetUrlParameter = getTargetUrlParameter();
		if (isAlwaysUseDefaultTargetUrl()
				|| (targetUrlParameter != null && StringUtils.hasText(request.getParameter(targetUrlParameter)))) {
			this.requestCache.removeRequest(request, response);
			super.onAuthenticationSuccess(request, response, authentication);
			return;
		}
		clearAuthenticationAttributes(request);
		// Use the DefaultSavedRequest URL
		String targetUrl = savedRequest.getRedirectUrl();
		getRedirectStrategy().sendRedirect(request, response, targetUrl);
	}
    
    /* ... */
}

default successHandler인 SavedRequestAwareAuthenticationSuccessHandleronAuthenticationSuccess method에서는 savedRequest가 없는 경우 super class인 SimpleUrlAuthenticationSuccessHandleronAuthenticationSuccess method를 호출한다.

org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler

package org.springframework.security.web.authentication;

public class SimpleUrlAuthenticationSuccessHandler extends AbstractAuthenticationTargetUrlRequestHandler
		implements AuthenticationSuccessHandler {
    /* ... */
    
    @Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) throws IOException, ServletException {
		handle(request, response, authentication);
		clearAuthenticationAttributes(request);
	}
    
    /* ... */
}

SavedRequestAwareAuthenticationSuccessHandler에 의해 호출된 SimpleUrlAuthenticationSuccessHandleronAuthenticationSuccess method에서는 다시 super class인 AbstractAuthenticationTargetUrlRequestHandlerhandle method를 호출한다.

org.springframework.security.web.authentication.AbstractAuthenticationTargetUrlRequestHandler

package org.springframework.security.web.authentication

public abstract class AbstractAuthenticationTargetUrlRequestHandler {
    /* ... */
    
    private String defaultTargetUrl = "/";
    
    /* ... */
	
    protected void handle(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
			throws IOException, ServletException {
		String targetUrl = determineTargetUrl(request, response, authentication);
		if (response.isCommitted()) {
			this.logger.debug(LogMessage.format("Did not redirect to %s since response already committed.", targetUrl));
			return;
		}
		this.redirectStrategy.sendRedirect(request, response, targetUrl);
	}
	
    /* ... */
    
	protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response,
			Authentication authentication) {
		if (isAlwaysUseDefaultTargetUrl()) {
			return this.defaultTargetUrl;
		}
		// Check for the parameter and use that if available
		String targetUrl = null;
		if (this.targetUrlParameter != null) {
			targetUrl = request.getParameter(this.targetUrlParameter);
			if (StringUtils.hasText(targetUrl)) {
				if (this.logger.isTraceEnabled()) {
					this.logger.trace(LogMessage.format("Using url %s from request parameter %s", targetUrl,
							this.targetUrlParameter));
				}
				return targetUrl;
			}
		}
		if (this.useReferer && !StringUtils.hasLength(targetUrl)) {
			targetUrl = request.getHeader("Referer");
			if (this.logger.isTraceEnabled()) {
				this.logger.trace(LogMessage.format("Using url %s from Referer header", targetUrl));
			}
		}
		if (!StringUtils.hasText(targetUrl)) {
			targetUrl = this.defaultTargetUrl;
			if (this.logger.isTraceEnabled()) {
				this.logger.trace(LogMessage.format("Using default url %s", targetUrl));
			}
		}
		return targetUrl;
	}

	/* ... */
}

AbstractAuthenticationTargetUrlRequestHandlerhandle method에서는 determineTargetUrl method를 호출해서 target url을 결정한 뒤 해당 url로 redirect를 진행한다. 이때 특별한 설정이 없다면 determineTargetUrl method는 target url로 defaultTargetUrl/를 반환하게 된다.

이상의 내부 로직을 토대로, Spring Security의 기본 설정에 따라 인증에 성공하게 되면 메인 페이지로의 redirect가 발생하게 됨을 알 수 있다. 그렇다면 Spring Security의 설정을 변경해서 메인 페이지로의 redirect를 강제하지 않고 요청에 대해 인증 이후 원래 보내야 하는 response를 보낼 수 있을까? 글이 상당히 길어진 관계로 다음 글에서 내용을 이어가도록 하겠다.

profile
개발을 공부하며 깊게 고민했던 트러블슈팅 과정을 공유하고자 합니다.

0개의 댓글