[Spring Security] Spring Security Basic - RememberMeAuthenticationFilter

들어가며

RememberMeAuthenticationFilter 어떤 역할을 하는지 알아보자.

역할

  • 세션이 사라지거나 만료가 되더라도 쿠키 또는 DB를 사용하여 저장된 토큰 기반으로 인증을 지원하는 필터이다.

필터 등록 과정

http.rememberMe()
    .userDetailsService(userDetailsService);
  • WebSecurityConfigurerAdapter에서 http를 이용해서 rememberMe filter를 등록해야 한다.
  • UserDetailsService를 등록하지 않으면 에러가 발생하기에, UserDetailsService를 명시적으로 등록해줘야 한다.
  • rememberMe()를 하게 되면, RememberMeConfigurer에서 초기화가 발생하게 된다.
public void init(H http) throws Exception {
  validateInput();
  String key = getKey();
  RememberMeServices rememberMeServices = getRememberMeServices(http, key);
  http.setSharedObject(RememberMeServices.class, rememberMeServices);
  LogoutConfigurer<H> logoutConfigurer = http.getConfigurer(LogoutConfigurer.class);
  if (logoutConfigurer != null && this.logoutHandler != null) {
    logoutConfigurer.addLogoutHandler(this.logoutHandler);
  }

  RememberMeAuthenticationProvider authenticationProvider = new RememberMeAuthenticationProvider(
    key);
  authenticationProvider = postProcess(authenticationProvider);
  http.authenticationProvider(authenticationProvider);

  initDefaultLoginFilter(http);
}
  • RememberMeConfigurer에서 초기화 시에 RememberMeServices를 등록하게 되는데, 기본 값으로 TokenBasedRememberMeServices 가 등록이 된다.
  • SpringSecurity에서 제공하는 RememberMeServices 구현체는 TokenBasedRememberMeServicesPersistentTokenBasedRememberMeServices가 존재한다.
  • 둘다 cookie를 이용하지만, PersistentTokenBasedRememberMeServicesTokenRepository를 이용해서 저장소로부터 token이 존재하는지 확인하여 인증을 진행한다.
  • TokenBasedRememberMeServicesexpectedTokenSignature라는 값을 생성하여, cookie로부터 전달 받은 데이터가 해당 값과 동일한지 체크하는 부분으로 인증을 진행 한다.
@Override
public void configure(H http) {
  RememberMeAuthenticationFilter rememberMeFilter = new RememberMeAuthenticationFilter(
    http.getSharedObject(AuthenticationManager.class),
			this.rememberMeServices);
  if (this.authenticationSuccessHandler != null) {
    rememberMeFilter
				.setAuthenticationSuccessHandler(this.authenticationSuccessHandler);
  }
  rememberMeFilter = postProcess(rememberMeFilter);
  http.addFilter(rememberMeFilter);
}
  • 앞에서 초기화를 진행해두었으니, 이제 실제로 configure 단계에서 필터를 등록하게 된다.
  • RememberMeAuthenticationFilter는 기본으로 등록되는 Filter가 아니기에 필요시에 추가해서 사용하면 된다.

토큰(cookie) 생성 과정

public interface RememberMeServices {
  Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);
	
  void loginFail(HttpServletRequest request, HttpServletResponse response);

  void loginSuccess(HttpServletRequest request, HttpServletResponse response,
		Authentication successfulAuthentication);
}
  • RememberMeServices가 하는 역할은 3가지를 담당한다.
  • autoLogin은 cookie를 확인하여 해당 cookie로 부터 user 인증을 진행한다.
  • loginFail은 인증 실패하였을 때 해당 cookie의 값을 빈 값으로 전달하게 한다. 추가 동작 부분은 비어 있어서 추가적으로 구현을 해야 한다.
  • loginSuccess에서 인증에 성공하였으니 response에 cookie을 할당하게 된다.

autoLogin

@Override
public final Authentication autoLogin(HttpServletRequest request,
		HttpServletResponse response) {
  String rememberMeCookie = extractRememberMeCookie(request);

  if (rememberMeCookie == null) {
    return null;
  }

  logger.debug("Remember-me cookie detected");

  if (rememberMeCookie.length() == 0) {
    logger.debug("Cookie was empty");
    cancelCookie(request, response);
    return null;
  }

  UserDetails user = null;

  try {
    String[] cookieTokens = decodeCookie(rememberMeCookie);
    user = processAutoLoginCookie(cookieTokens, request, response);
    userDetailsChecker.check(user);

    logger.debug("Remember-me cookie accepted");

    return createSuccessfulAuthentication(request, user);
  }
  catch (CookieTheftException cte) {
    cancelCookie(request, response);
    throw cte;
  }
  catch (UsernameNotFoundException noUser) {
    logger.debug("Remember-me login was valid but corresponding user not found.",
				noUser);
  }
  catch (InvalidCookieException invalidCookie) {
    logger.debug("Invalid remember-me cookie: " + invalidCookie.getMessage());
  }
  catch (AccountStatusException statusInvalid) {
    logger.debug("Invalid UserDetails: " + statusInvalid.getMessage());
  }
  catch (RememberMeAuthenticationException e) {
    logger.debug(e.getMessage());
  }

  cancelCookie(request, response);
  return null;
}
  • TokenBasedRememberMeServicesAbstractRememberMeServices를 상속 받고 있어 일부 메소드만 추가 구현이 되어 있다.
  • cookie를 추출하고, 확인하는 부분은 AbstractRememberMeService에서 진행하고, cookie을 바탕으로 user를 가져오는 부분인 processAutoLoginCookie 부분은 TokenBasedRememberMeServices에서 진행하게 된다.
@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens,
		HttpServletRequest request, HttpServletResponse response) {

  if (cookieTokens.length != 3) {
    throw new InvalidCookieException("Cookie token did not contain 3"
				+ " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
  }

  long tokenExpiryTime;

  try {
    tokenExpiryTime = new Long(cookieTokens[1]);
  }
  catch (NumberFormatException nfe) {
    throw new InvalidCookieException(
      "Cookie token[1] did not contain a valid number (contained '"
					+ cookieTokens[1] + "')");
  }

  if (isTokenExpired(tokenExpiryTime)) {
    throw new InvalidCookieException("Cookie token[1] has expired (expired on '"
				+ new Date(tokenExpiryTime) + "'; current time is '" + new Date()
				+ "')");
  }

  
  UserDetails userDetails = getUserDetailsService().loadUserByUsername(
    cookieTokens[0]);

  Assert.notNull(userDetails, () -> "UserDetailsService " + getUserDetailsService()
			+ " returned null for username " + cookieTokens[0] + ". "
			+ "This is an interface contract violation");

  
  String expectedTokenSignature = makeTokenSignature(tokenExpiryTime,
			userDetails.getUsername(), userDetails.getPassword());

  if (!equals(expectedTokenSignature, cookieTokens[2])) {
    throw new InvalidCookieException("Cookie token[2] contained signature '"
				+ cookieTokens[2] + "' but expected '" + expectedTokenSignature + "'");
  }

  return userDetails;
}
  • cookie의 만료를 확인하고 cookie에 username을 추출하여 UserDetailsService에서 UserDetails인 AuthenticationToken을 가져오게 된다.

loginFail

@Override
public final void loginFail(HttpServletRequest request, HttpServletResponse response) {
  logger.debug("Interactive login attempt was unsuccessful.");
  cancelCookie(request, response);
  onLoginFail(request, response);
}

protected void onLoginFail(HttpServletRequest request, HttpServletResponse response) {
}
  • loginFail에는 cookie를 새롭게 만들어서 전달하게 된다.

loginSuccess

@Override
public final void loginSuccess(HttpServletRequest request,
		HttpServletResponse response, Authentication successfulAuthentication) {

  if (!rememberMeRequested(request, parameter)) {
    logger.debug("Remember-me login not requested.");
    return;
  }

  onLoginSuccess(request, response, successfulAuthentication);
}
  • loginSuccess 부분은 인증에 성공하는 filter에서 호출이 발생하게 된다.
  • 단 request에 remeber-me와 같이 remeberMe 기능을 사용할지에 대해서 request parameter로 전달되어야 한다. 기본값이 remember-me이다.
protected void successfulAuthentication(HttpServletRequest request,
	HttpServletResponse response, FilterChain chain, Authentication authResult)
	throws IOException, ServletException {

  if (logger.isDebugEnabled()) {
    logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
				+ authResult);
  }

  SecurityContextHolder.getContext().setAuthentication(authResult);

  rememberMeServices.loginSuccess(request, response, authResult);

  if (this.eventPublisher != null) {
    eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
    authResult, this.getClass()));
  }

  successHandler.onAuthenticationSuccess(request, response, authResult);
}
  • UsernamePasswordAuthenticationFilter에 abstract class인 AbstractAuthenticationProcessingFilter에서 successfulAuthentication할 때 rememberMeServices에 loginSuccess를 호출하게 된다.
@Override
protected void doFilterInternal(HttpServletRequest request,
		HttpServletResponse response, FilterChain chain)
				throws IOException, ServletException {
  final boolean debug = this.logger.isDebugEnabled();
  try {
    UsernamePasswordAuthenticationToken authRequest = authenticationConverter.convert(request);
    if (authRequest == null) {
      chain.doFilter(request, response);
      return;
    }
    String username = authRequest.getName();
    
    if (authenticationIsRequired(username)) {
      Authentication authResult = this.authenticationManager
					.authenticate(authRequest);

      if (debug) {
        this.logger.debug("Authentication success: " + authResult);
      }

      SecurityContextHolder.getContext().setAuthentication(authResult);

      this.rememberMeServices.loginSuccess(request, response, authResult);

      onSuccessfulAuthentication(request, response, authResult);
    }

  }
  catch (AuthenticationException failed) {
		...
	chain.doFilter(request, response);
  }
}
  • BasicAuthenticationFilter에서도 remeberMeServices에 loginSuccess를 호출하게 된다.

인증 과정

login-page

  1. login시에 remember me on this computer를 check하게 되면 request parameter로 remeber-me에 값이 서버로 전달되게 된다.
  2. 인증을 담당하는 AuthenticationFilter에서 success를 하게 되면 remeberMe 관련 parameter를 확인 후 cookie를 생성하게 된다.
  3. 추후에 유저가 다시 브라우저에 접근을 하였을 때 cookie가 남아 있고 인증이 되어 있지 않다면, remeberMe cookie를 이용해서 autoLogin이 되게 된다.

마치며

  • Session 방식의 인증을 사용하지 않고도, 유저가 여러번 로그인을 하지 않고 인증을 유지할 수 있는 방법에 대해서 공부하였다.
  • 많은 웹사이트들이 해당 기능을 지원해주고 있는데, 다들 SpringSecurity를 사용하고 있는지 궁금해졌다ㅎㅎ

Leave a comment