RemembermeAuthenticationFilter쿠키 생성 흐름(RememberMeServices 가 담당함)쿠키 인증 과정(자동 로그인)RememberMe 설정 방법Remember me 설정 apiRememberMeServicesTokenBasedRememberMeServicesPersistentTokenBasedRememberMeServices
RemembermeAuthenticationFilter
- 인증 정보를 세션에서 관리하는 경우, 세션 timeout이 발생하게 되면 remember- me 쿠키를 이용해 로그인을 기억했다가 자동으로 재로그인 시켜주는 기능임
- 세션이 만료되고 나면 세션을 통해서는 인증을 할 수 없으므로, remember-me 쿠키를 통해서 인증이 가능하고 해당 쿠키를 통해서 인증이 완료되면 또한 그 세션이 유지되는 동안은 인증이 세션통해서 가능해 지게 됨
- 명시적인 로그인 아이디/비밀번호 기반 인증 사용와 권한 구분
- remember-me 기반 인증과 로그인 아이디/비밀번호 기반 인증 결과가 명백히 다르것에 주목
- remember-me 기반 인증 결과 — RememberMeAuthenticationToken
- 로그인 아이디/비밀번호 기반 인증 결과 — UsernamePasswordAuthenticationToken
- remember-me 기반 인증은 로그인 기반 인증 보다 보안상 다소 약한 인증
- 따라서, 모두 동일하게 인증된 사용자라 하더라도 권한을 분리할 수 있음
- isFullyAuthenticated — 명시적인 로그인 아이디/비밀번호 기반으로 인증된 사용자만 접근 가능
@Override public final boolean isFullyAuthenticated() { return !this.trustResolver.isAnonymous(this.authentication) && !this.trustResolver.isRememberMe(this.authentication); }
쿠키 생성 흐름(RememberMeServices 가 담당함)
// AbstractAuthenticationProcessingFilter private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { if (!requiresAuthentication(request, response)) { // POST /login일 시 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); /* ... */ } protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(authResult); SecurityContextHolder.setContext(context); 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); } // AbstractRememberMeServices @Override public final void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { if (!rememberMeRequested(request, this.parameter)) { this.logger.debug("Remember-me login not requested."); return; } onLoginSuccess(request, response, successfulAuthentication); } // TokenbasedRememberMeServices @Override public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) { String username = retrieveUserName(successfulAuthentication); String password = retrievePassword(successfulAuthentication); // If unable to find a username and password, just abort as // TokenBasedRememberMeServices is // unable to construct a valid token in this case. if (!StringUtils.hasLength(username)) { this.logger.debug("Unable to retrieve username"); return; } if (!StringUtils.hasLength(password)) { UserDetails user = getUserDetailsService().loadUserByUsername(username); password = user.getPassword(); if (!StringUtils.hasLength(password)) { this.logger.debug("Unable to obtain password for user: " + username); return; } } int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication); long expiryTime = System.currentTimeMillis(); // SEC-949 expiryTime += 1000L * ((tokenLifetime < 0) ? TWO_WEEKS_S : tokenLifetime); String signatureValue = makeTokenSignature(expiryTime, username, password); setCookie(new String[] { username, Long.toString(expiryTime), signatureValue }, tokenLifetime, request, response); if (this.logger.isDebugEnabled()) { this.logger.debug( "Added remember-me cookie for user '" + username + "', expiry: '" + new Date(expiryTime) + "'"); } }
- AbstractAuthenticationProcessingFilter.doFilter() : AbstractAuthenticationProcessingFilter는 UsernamePasswordAuthenticationFilter의 상위 클래스임. Form login으로 할 시, 해당 필터의 doFilter가 호출
- AbstractRememberMeServices.loginSuccess()
- 실제로는 TokenBasedRememberMeServices임. AbstractRememberMeServices를 상속하는

- TokenBasedRememberMeServices.onLoginSuccess() : 로그인이 성공했을 때, 쿠키 만들어서 보내줌
- RememberMeServices ←(구현 or 상속) AbstractRememberMeServices
- ← TokenBasedRememberMeServices
- ← PersistenceTokenBasedRememberMeServices
쿠키 인증 과정(자동 로그인)
[아래 페이지 참고]
세션과 쿠키
//RememberMeAuthenticationFilter private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { if (SecurityContextHolder.getContext().getAuthentication() != null) { this.logger.debug(LogMessage .of(() -> "SecurityContextHolder not populated with remember-me token, as it already contained: '" + SecurityContextHolder.getContext().getAuthentication() + "'")); chain.doFilter(request, response); return; } Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response); if (rememberMeAuth != null) { // Attempt authenticaton via AuthenticationManager try { rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth); // Store to SecurityContextHolder SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(rememberMeAuth); /*... */ } // AbstractRememberMeServices.java @Override public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) { String rememberMeCookie = extractRememberMeCookie(request); if (rememberMeCookie == null) { return null; } this.logger.debug("Remember-me cookie detected"); if (rememberMeCookie.length() == 0) { this.logger.debug("Cookie was empty"); cancelCookie(request, response); return null; } try { String[] cookieTokens = decodeCookie(rememberMeCookie); /* processAutoLoginCookie 에서 Tokenbased와 PersistenceTokenBased 로 로직이 나뉘어지게 됨*/ UserDetails user = processAutoLoginCookie(cookieTokens, request, response); this.userDetailsChecker.check(user); this.logger.debug("Remember-me cookie accepted"); return createSuccessfulAuthentication(request, user); } } /* ProviderManger 에서 RememberMeAuthenticationProvider 통해 authenticate 호출 RememberMeAuthenticationProvider.authenticate() */ @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { if (!supports(authentication.getClass())) { return null; } if (this.key.hashCode() != ((RememberMeAuthenticationToken) authentication).getKeyHash()) { throw new BadCredentialsException(this.messages.getMessage("RememberMeAuthenticationProvider.incorrectKey", "The presented RememberMeAuthenticationToken does not contain the expected key")); } return authentication; }
- PersistenceTokenBasedRememberMeServices : 서버에 토큰을 저장해 놓고 씀 ( 이편이 더 안전함 )
- 포맷 : series:token
- db 가 필요함
- session만료 전에는 SecurityContextPersistenceFilter를 통해 인증을 진행하나, session이 만료되면 RememberMeAuthenticationFilter에서 인증 진행함
- processAutoLoginCookie 부터 TokenBasedRememberMeServices와 PersistenceTokenBasedRememberMeServices의 로직이 달라짐
RememberMe 설정 방법
@EnableWebSecurity(debug =true) @EnableGlobalMethodSecurity(prePostEnabled =true) public class SecurityConfig extends WebSecurityConfigurerAdapter { private final SpUserService spUserService; private final DataSource dataSource; public SecurityConfig(SpUserService spUserService, DataSource dataSource) { this.spUserService = spUserService; this.dataSource = dataSource; @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests(request-> request.antMatchers("/").permitAll() .anyRequest().authenticated() ) .formLogin(login-> login.loginPage("/login") .loginProcessingUrl("/loginprocess") .permitAll() .defaultSuccessUrl("/", false) .failureUrl("/login-error") ) .logout(logout-> logout.logoutSuccessUrl("/")) .exceptionHandling(error-> error.accessDeniedPage("/access-denied") ) .rememberMe(r -> r.rememberMeServices(rememberMeServices())) ; } @Bean PersistentTokenRepository tokenRepository(){ JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl(); repository.setDataSource(dataSource); return repository; } @Bean PersistentTokenBasedRememberMeServices rememberMeServices(){ return new PersistentTokenBasedRememberMeServices("hello", spUserService, tokenRepository()); } }
- rememberMe 안에 어떤 방식으로 processAutoLoginCookie를 진행할것인지를 명시할 수 있음
- 위와 같이 명시하면 PersistentTokenBasedRememberMeServices 를 이용하는 것이고 token이 발급될 때마다 db에 저장이 됨
- .rememberMe() 만 하게 되면 TokenBased 를 사용하게 됨
Remember me 설정 api
protected void configure(HttpSecurity http) throws Exception { http.rememberMe() .key("my-remember-me") .rememberMeParameter(“remember”) // html 상에서 checkbox의 name 설정 .tokenValiditySeconds(3600) .alwaysRemember(true) .userDetailsService(userDetailsService) }
key
: remember-me 쿠키에 대한 고유 식별 키. 미입력 시 자동으로 랜덤 텍스트가 입력 됨
protected Authentication createSuccessfulAuthentication(HttpServletRequest request, UserDetails user) { RememberMeAuthenticationToken auth = new RememberMeAuthenticationToken( this.key, user, this.authoritiesMapper.mapAuthorities(user.getAuthorities())); auth.setDetails(this.authenticationDetailsSource.buildDetails(request)); return auth; }
rememberMeParameter(“remember”)
: remember me의 파라미터 명을 설정하는 api입니다. default 값은 remember-me입니다.
rememberMeParameter(“remember”)
: remember me의 파라미터 명을 설정하는 api입니다. default 값은 remember-me입니다.
tokenValiditySeconds(3600)
: remember me쿠키의 만료 시간을 설정하는 api로 default값은 14일 입니다.
alwaysRemember(true)
: remember me 기능이 활성화되지 않아도 항상 실행하도록 설정하는 기능입니다. (일반적으로는 false)
userDetailsService(userDetailsService)
: remember me기능을 수행할 때 시스템의 사용자 계정을 조회할 때 처리를 설정하는 api입니다.
RememberMeServices
- remember-me 쿠키를 만들어주는 곳
- 사용되는 곳
RememberMeAuthenticationFilter
AbstractAuthenticationProcessingFilter
TokenBasedRememberMeServices
- TokenBasedRememberMeServices : 서버에는 남기지 않음
- 토큰 포맷이
아이디:만료시간:Md5Hex(아이디:만료시간:비밀번호:인증키)
이와 같고, 비밀번호를 바꾸지 않는 한 이 토큰이 탈취되면 이 토큰을 통해서 인증이 가능해짐 - 토큰을 무효화 시키는 방법은 패스워드를 바꾸는 방법 밖에 없음
PersistentTokenBasedRememberMeServices
- 해당 방식의 포맷 :
series:token
- series값이 키가 되어 DB에서 해당 토큰을 찾아서 반환해주는 식으로 진행됨
- 진행과정(remember me 쿠키 이미 가지고 있을 때)
- 브라우저에서 request를 보낼 때 remember-me 쿠키를 같이 보냄
- 서버에서는 해당 쿠키를 decode 해서 series와 token으로 분리를 하고 해당 series에 맞는 token 값을 찾아서 token 값 끼리 비교함
- expire되었는지 확인
- 만료되지 않았다면 해당 series에 대한 토큰 값을 바꾸어서 client에게 다시 내려주게 됨(토큰 값이 계속해서 갱신이 됨)
// PersistenceTokenBasedRememberMeServices.java @Override protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) { if (cookieTokens.length != 2) { throw new InvalidCookieException("Cookie token did not contain " + 2 + " tokens, but contained '" + Arrays.asList(cookieTokens) + "'"); } String presentedSeries = cookieTokens[0]; String presentedToken = cookieTokens[1]; PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries); if (token == null) { // No series match, so we can't authenticate using this cookie throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries); } // We have a match for this user/series combination if (!presentedToken.equals(token.getTokenValue())) { // Token doesn't match series value. Delete all logins for this user and throw // an exception to warn them. this.tokenRepository.removeUserTokens(token.getUsername()); throw new CookieTheftException(this.messages.getMessage( "PersistentTokenBasedRememberMeServices.cookieStolen", "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack.")); } if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) { throw new RememberMeAuthenticationException("Remember-me login has expired"); } // Token also matches, so login is valid. Update the token value, keeping the // *same* series number. this.logger.debug(LogMessage.format("Refreshing persistent login token for user '%s', series '%s'", token.getUsername(), token.getSeries())); PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), generateTokenData(), new Date()); try { this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate()); addCookie(newToken, request, response); } catch (Exception ex) { this.logger.error("Failed to update token: ", ex); throw new RememberMeAuthenticationException("Autologin failed due to data access problem"); } return getUserDetailsService().loadUserByUsername(token.getUsername()); }
- 만약 다른 악의적인 사용자가 해당 토큰을 탈취하여 요청을 한다면, 서버에서 해당 series에 대한 token값은 변경이 되게 됨 ⇒ 선량한 사용자가 기존의 토큰으로 요청을 하게 되면 token불일치가 일어남!
- 이러면 서버에서는 해당 rememberMe 토큰은 다 삭제하고 CookieTheftException을 던지게 됨
- 그럼 기존 사용자는 로그인을 다시 해야 함 → 쿠키 다시 발급됨