우리 팀이 개발했던 웹 어플리케이션들의 사용자 인증(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);
이러한 문제에 대해 몇 가지 원인으로 보일 수 있는 부분들에 대해 확인해 보았다.
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는 다시 successHandler
의 onAuthenticationSuccess
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인 SavedRequestAwareAuthenticationSuccessHandler
의 onAuthenticationSuccess
method에서는 savedRequest
가 없는 경우 super class인 SimpleUrlAuthenticationSuccessHandler
의 onAuthenticationSuccess
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
에 의해 호출된 SimpleUrlAuthenticationSuccessHandler
의 onAuthenticationSuccess
method에서는 다시 super class인 AbstractAuthenticationTargetUrlRequestHandler
의 handle
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;
}
/* ... */
}
AbstractAuthenticationTargetUrlRequestHandler
의 handle
method에서는 determineTargetUrl
method를 호출해서 target url을 결정한 뒤 해당 url로 redirect를 진행한다. 이때 특별한 설정이 없다면 determineTargetUrl
method는 target url로 defaultTargetUrl
인 /
를 반환하게 된다.
이상의 내부 로직을 토대로, Spring Security의 기본 설정에 따라 인증에 성공하게 되면 메인 페이지로의 redirect가 발생하게 됨을 알 수 있다. 그렇다면 Spring Security의 설정을 변경해서 메인 페이지로의 redirect를 강제하지 않고 요청에 대해 인증 이후 원래 보내야 하는 response를 보낼 수 있을까? 글이 상당히 길어진 관계로 다음 글에서 내용을 이어가도록 하겠다.