[SpringSecurity5.6.2源码分析二十二]:RememberMeAuthenticationFilter

前言

  • SpringSecurity支持记住我登录,点击下面的复选框即可完成记住我认证

1. RememberMeConfigurer

  • RememberMeConfigurer作为RememberMeAuthenticationFilter的过滤器
  • 在SpringSecurity中默认不会填充,通过以下代码开启基础配置
java 复制代码
    @Override
    protected void configure(HttpSecurity http) throws Exception {
      ...
      http.rememberMe();
      ...
   }
  • 接下来只介绍配置类中新出现的类和比较重要的方法

1.1 rememberMeServices(...)

  • RememberMeServices(...):填充RememberMeServices
java 复制代码
public RememberMeConfigurer<H> rememberMeServices(RememberMeServices rememberMeServices) {
   this.rememberMeServices = rememberMeServices;
   return this;
}
  • RememberMeServices:SpringSecurity中的负责认证的过滤器将调用其实现类来完成记住我机制
java 复制代码
public interface RememberMeServices {

   /**
    * 将记住我令牌转为认证对象
    */
   Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);

   /**
    * 记住我认证失败调用的方法
    * <ul>
    *     <li>
    *         比如说执行autoLogin方法创建的认证对象,认证失败后,清除记住我令牌
    *     </li>
    * </ul>
    */
   void loginFail(HttpServletRequest request, HttpServletResponse response);

   /**
    * 认证成功调用的方法
    * <ul>
    *     <li>
    *         比如说使用表单或者基本认证,认证成功后,可能需要创建一个记住我令牌
    *     </li>
    * </ul>
    */
   void loginSuccess(HttpServletRequest request, HttpServletResponse response,
         Authentication successfulAuthentication);

}
  • 其中有两个重要的实现
    • TokenBasedRememberMeServices:不依赖于外部数据库
    • PersistentTokenBasedRememberMeServices:支持持久化(数据库)
  • 最后再介绍这两个类

1.2 tokenRepository(...)

  • tokenRepository(...):使用持久化方式来保持记住我令牌
    • 这个只有在PersistentTokenBasedRememberMeServices中会用到
java 复制代码
public RememberMeConfigurer<H> tokenRepository(PersistentTokenRepository tokenRepository) {
   this.tokenRepository = tokenRepository;
   return this;
}
  • 然后再看PersistentTokenRepository的源码:很明显是一个支持增删改查的类
java 复制代码
public interface PersistentTokenRepository {

   void createNewToken(PersistentRememberMeToken token);

   void updateToken(String series, String tokenValue, Date lastUsed);

   PersistentRememberMeToken getTokenForSeries(String seriesId);

   void removeUserTokens(String username);

}
  • 我们就看他的一个基于内存的实现类:
    • JdbcTokenRepositoryImpl:基于JDBC的持久登录令牌
    • InMemoryTokenRepositoryImpl:由Map支持的简单PersistentTokenRepository实现。仅用于测试
  • 这两个类也是最后再来介绍

1.3 init(...)

  • init(...):这里都是填充记住我机制的必要参数
    • 但是这里为认证管理器注册了一个新的类:RememberMeAuthenticationProvider
    • 因为记住我认证对象和用 用户名密码认证的对象不一样,需要特殊的认证提供者
java 复制代码
public void init(H http) throws Exception {
   validateInput();
   //获取秘钥
   String key = getKey();
   //获得记住我服务
   RememberMeServices rememberMeServices = getRememberMeServices(http, key);
   //将记住我服务放入SharedObject中,这样表单登录时候就能够创建记住我令牌了
   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);
   //添加到httpSecurity中
   http.authenticationProvider(authenticationProvider);
   //如果有登录页的话,给他设置开启记住我登录的参数名
   initDefaultLoginFilter(http);
}

1.3.1 RememberMeAuthenticationProvider

  • RememberMeAuthenticationProvider是SpringSecurity其中的一种认证方式
  • 记住我的认证规则很简单,只比较了秘钥
java 复制代码
public class RememberMeAuthenticationProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware {

   ...
   /**
    * 记住我的认证规则很简单,只比较了秘钥
    * <p>我理解是因为在通过记住我过滤器生成记住我认证对象的时候,已经比较过签名了</p>
    */
   @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;
   }
   ...
}

1.4 configure(...)

  • configure(...):创建过滤器
java 复制代码
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);
}

2. RememberMeAuthenticationFilter

  • 直接看过滤器的核心方法
java 复制代码
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws IOException, ServletException {
   //如果HttpSession级别的安全上下文中有认证对象的话,那就说明已经认证过了,就不需要进行记住我方式认证了
   //通常情况是因为Session过期了
   //注意:匿名认证过滤器在这个过滤器的后面
   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) {
      try {
         //通过局部认证管理器进行认证操作
         //局部认证管理器通常有匿名和记住我的认证提供者,而全局认证管理器才是表单的认证提供者
         rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);

         //将认证对象保存到线程级别的安全上下文策略中
         SecurityContext context = SecurityContextHolder.createEmptyContext();
         context.setAuthentication(rememberMeAuth);
         SecurityContextHolder.setContext(context);

         //执行认证成功的方法,默认是空方法
         onSuccessfulAuthentication(request, response, rememberMeAuth);
         this.logger.debug(LogMessage.of(() -> "SecurityContextHolder populated with remember-me token: '"
               + SecurityContextHolder.getContext().getAuthentication() + "'"));

         //推送交互式认证成功事件
         if (this.eventPublisher != null) {
            this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
                  SecurityContextHolder.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);
}
  • 大部分代码我已经加入了注释,并且都是以前介绍过的类,这里唯一的陌生代码就是第十四行
  • 这里是通过RememberMeServices获取认证对象,我们就看看他的两个实现是怎么操作的
    • TokenBasedRememberMeServices
    • PersistentTokenBasedRememberMeServices

3. RememberMeServices

3.1 TokenBasedRememberMeServices

  • TokenBasedRememberMeServices和PersistentTokenBasedRememberMeServices都有相同的父类:AbstractRememberMeServices
  • 我们接下来就看看RememberMeServices中的三大方法在TokenBasedRememberMeServices中是如何实现的

3.1.1 loginSuccess(...)

  • 此方法是当认证完成后才会被调用,下面是他的调用情况
    • BasicAuthenticationFilter.doFilterInternal(...)
    • UsernamePasswordAuthenticationFilter.successfulAuthentication(...)
  • 接下来我们直接看其源码
java 复制代码
    public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
          Authentication successfulAuthentication) {
       //获得用户名和密码
       String username = retrieveUserName(successfulAuthentication);
       String password = retrievePassword(successfulAuthentication);

       //如若无法找到用户名和密码就终止创建记住我令牌
       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();
       //过期时间 = 令牌有效时间 + 当前时间
       expiryTime += 1000L * ((tokenLifetime < 0) ? TWO_WEEKS_S : tokenLifetime);
       //生成签名
       String signatureValue = makeTokenSignature(expiryTime, username, password);
       //将记住我令牌添加到Cookie中
       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) + "'");
       }
}
  • 别看源码很长,其实重点就在于如何生成的令牌,我们看makeTokenSignature(...)方法
java 复制代码
/**
 * 生成签名,并通过MD5进行加密
 */
protected String makeTokenSignature(long tokenExpiryTime, String username, String password) {
   String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
   try {
      MessageDigest digest = MessageDigest.getInstance("MD5");
      return new String(Hex.encode(digest.digest(data.getBytes())));
   }
   catch (NoSuchAlgorithmException ex) {
      throw new IllegalStateException("No MD5 algorithm available!");
   }
}
  • 很明显就是通过 (过期时间 + 用户名 + 密码 + key) 再通过MD5加密为签名
  • 最后将用户名、过期时间、签名通过Base64加密保存到Cookie中

3.1.2 autoLogin(...)

  • autoLogin(...)是在通过记住我认证后SecurityContext中没有认证对象才会被调用的方法
  • 两个实现类并没有重写核心方法autoLogin(...),而是在其父类中有代码
java 复制代码
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");
      //将生存时间设置为0,以禁用记住我认证
      cancelCookie(request, response);
      return null;
   }
   try {
      //将记住我令牌进行Base64解码
      String[] cookieTokens = decodeCookie(rememberMeCookie);
      //记住我令牌转换为用户对象
      UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
      //进行检查
      this.userDetailsChecker.check(user);
      this.logger.debug("Remember-me cookie accepted");
      //创建记住我认证对象
      return createSuccessfulAuthentication(request, user);
   }
   catch (CookieTheftException ex) {
      cancelCookie(request, response);
      throw ex;
   }
   catch (UsernameNotFoundException ex) {
      this.logger.debug("Remember-me login was valid but corresponding user not found.", ex);
   }
   catch (InvalidCookieException ex) {
      this.logger.debug("Invalid remember-me cookie: " + ex.getMessage());
   }
   catch (AccountStatusException ex) {
      this.logger.debug("Invalid UserDetails: " + ex.getMessage());
   }
   catch (RememberMeAuthenticationException ex) {
      this.logger.debug(ex.getMessage());
   }
   cancelCookie(request, response);
   return null;
}
  • autoLogin(...)方法的核心就在于通过processAutoLoginCookie(...)获取了UserDetails,而这个方法两个实现类都重写了
java 复制代码
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
      HttpServletResponse response) {
   //使用当前记住我服务只会生成长度为3的记住我令牌
   if (cookieTokens.length != 3) {
      throw new InvalidCookieException(
            "Cookie token did not contain 3" + " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
   }
   //获得过期时间
   long tokenExpiryTime = getTokenExpiryTime(cookieTokens);
   //判断记住我令牌是否已经过期
   if (isTokenExpired(tokenExpiryTime)) {
      throw new InvalidCookieException("Cookie token[1] has expired (expired on '" + new Date(tokenExpiryTime)
            + "'; current time is '" + new Date() + "')");
   }

   //通过用户名加载UserDetails
   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;
}
  • 分析源码就能得出结论:
    • 由于记住我令牌最外面一层是通过Base64加密的所以说可以直接进行解密,变成下面的样子
    • 然后判断是否过期
    • 未过期就通过用户名获取用户对象
    • 在用 用户名 + 密码 + 过期时间 + key 重新生成签名
    • 只有Cookie中的令牌和重新生成的一样才认为此令牌有效

3.1.3 loginFail(...)

  • 此方法是退出登录才会被调用的,其代码很简单,就是清除记住我令牌而已
java 复制代码
public final void loginFail(HttpServletRequest request, HttpServletResponse response) {
   this.logger.debug("Interactive login attempt was unsuccessful.");
   cancelCookie(request, response);
   onLoginFail(request, response);
}

/**
 * 将生存时间设置为0,以禁用记住我认证
 */
protected void cancelCookie(HttpServletRequest request, HttpServletResponse response) {
   this.logger.debug("Cancelling cookie");
   Cookie cookie = new Cookie(this.cookieName, null);
   cookie.setMaxAge(0);
   cookie.setPath(getCookiePath(request));
   if (this.cookieDomain != null) {
      cookie.setDomain(this.cookieDomain);
   }
   cookie.setSecure((this.useSecureCookie != null) ? this.useSecureCookie : request.isSecure());
   response.addCookie(cookie);
}

3.2 PersistentTokenBasedRememberMeServices

  • 与TokenBasedRememberMeServices不一样,此类支持持久化,并且令牌的生成方式不一样

3.2.1 loginSuccess(...)

  • 此方法就两个重点
    • 令牌的格式
    • 为什么要保存令牌
java 复制代码
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
      Authentication successfulAuthentication) {
   String username = successfulAuthentication.getName();
   this.logger.debug(LogMessage.format("Creating new persistent login for user %s", username));

   PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, generateSeriesData(),
         generateTokenData(), new Date());
   try {
      //保存起来,一般情况是数据库
      this.tokenRepository.createNewToken(persistentToken);
      //添加记住我令牌到响应的Cookie中
      addCookie(persistentToken, request, response);
   }
   catch (Exception ex) {
      this.logger.error("Failed to save persistent token ", ex);
   }
}
  • 这里生成的令牌就和TokenBasedRememberMeServices不一样了
    • 其中的series和tokenValue都是随机数
java 复制代码
public class PersistentRememberMeToken {

   private final String username;

   private final String series;

   private final String tokenValue;

   /**
    * 创建日期
    */
   private final Date date;
}
  • 这里保存令牌是通过PersistentTokenRepository的实现类,我们就看个简单的例子
java 复制代码
public class InMemoryTokenRepositoryImpl implements PersistentTokenRepository {

   private final Map<String, PersistentRememberMeToken> seriesTokens = new HashMap<>();

   @Override
   public synchronized void createNewToken(PersistentRememberMeToken token) {
      PersistentRememberMeToken current = this.seriesTokens.get(token.getSeries());
      if (current != null) {
         throw new DataIntegrityViolationException("Series Id '" + token.getSeries() + "' already exists!");
      }
      this.seriesTokens.put(token.getSeries(), token);
   }

   @Override
   public synchronized void updateToken(String series, String tokenValue, Date lastUsed) {
      PersistentRememberMeToken token = getTokenForSeries(series);
      PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), series, tokenValue,
            new Date());
      // Store it, overwriting the existing one.
      this.seriesTokens.put(series, newToken);
   }
   ...
}
  • 这里保存和更新都是以series作为键,这里是重点

3.2.2 processAutoLoginCookie(...)

  • 此方法的重点就在于一个令牌的Series是固定的,而TokenValue是会随着请求不断的更新的,一旦发现TokenValue值不对就说明此令牌已经发生了泄露
java 复制代码
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
      HttpServletResponse response) {
   //使用当前记住我服务只会生成长度为2的记住我令牌
   if (cookieTokens.length != 2) {
      throw new InvalidCookieException("Cookie token did not contain " + 2 + " tokens, but contained '"
            + Arrays.asList(cookieTokens) + "'");
   }
   //生成记住我令牌的时候就已经固定了第一位是Series,第二位是Token
   String presentedSeries = cookieTokens[0];
   String presentedToken = cookieTokens[1];
   //先通过持久化策略获得 保存的持久化记住我令牌
   PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
   if (token == null) {
      //没有保存,不能使用此cookie进行身份认证
      throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
   }
   //当token值不等的时候,说明此记住我令牌已经泄露了
   if (!presentedToken.equals(token.getTokenValue())) {
      //删除用此用户名登录的所有 持久化记住我令牌
      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");
   }


   this.logger.debug(LogMessage.format("Refreshing persistent login token for user '%s', series '%s'",
         token.getUsername(), token.getSeries()));

   //此记住我令牌是有效的,更新token值和时间
   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());
}

3.2.3 loginFail(...)

  • 登出的时候就和TokenBasedRememberMeServices一样了
java 复制代码
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
   super.logout(request, response, authentication);
   if (authentication != null) {
      this.tokenRepository.removeUserTokens(authentication.getName());
   }
}

4. 总结

  • 最后总结下记住我的认证逻辑:
    • 以其他表单认证或者基本认证等认证方式进行认证后会通过RememberMeServices创建记住我令牌,并添加在Cookie中
    • 等过一段时间后HttpSession过期了,SecurityContextRepository中的认证对象为空了
    • 此时就来到了RememberMeAuthenticationFilter,解析出记住我令牌
    • 通过RememberMeServices校验记住我令牌,然后进行认证
    • 认证完成后创建记住我认证对象,并将其放入SecurityContext中
相关推荐
工业甲酰苯胺1 小时前
Spring Boot 整合 MyBatis 的详细步骤(两种方式)
spring boot·后端·mybatis
bjzhang753 小时前
SpringBoot开发——集成Tess4j实现OCR图像文字识别
spring boot·ocr·tess4j
flying jiang3 小时前
Spring Boot 入门面试五道题
spring boot
小菜yh3 小时前
关于Redis
java·数据库·spring boot·redis·spring·缓存
爱上语文5 小时前
Springboot的三层架构
java·开发语言·spring boot·后端·spring
荆州克莱5 小时前
springcloud整合nacos、sentinal、springcloud-gateway,springboot security、oauth2总结
spring boot·spring·spring cloud·css3·技术
serve the people5 小时前
springboot 单独新建一个文件实时写数据,当文件大于100M时按照日期时间做文件名进行归档
java·spring boot·后端
罗政10 小时前
[附源码]超简洁个人博客网站搭建+SpringBoot+Vue前后端分离
vue.js·spring boot·后端
Java小白笔记13 小时前
关于使用Mybatis-Plus 自动填充功能失效问题
spring boot·后端·mybatis
小哇66613 小时前
Spring Boot,在应用程序启动后执行某些 SQL 语句
数据库·spring boot·sql