[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中
相关推荐
lxyzcm25 分钟前
C++23新特性解析:[[assume]]属性
java·c++·spring boot·c++23
迷糊的『迷』2 小时前
vue-axios+springboot实现文件流下载
vue.js·spring boot
小池先生3 小时前
springboot启动不了 因一个spring-boot-starter-web底下的tomcat-embed-core依赖丢失
java·spring boot·后端
苹果醋34 小时前
2020重新出发,MySql基础,MySql表数据操作
java·运维·spring boot·mysql·nginx
小蜗牛慢慢爬行4 小时前
如何在 Spring Boot 微服务中设置和管理多个数据库
java·数据库·spring boot·后端·微服务·架构·hibernate
azhou的代码园4 小时前
基于JAVA+SpringBoot+Vue的制造装备物联及生产管理ERP系统
java·spring boot·制造
wm10434 小时前
java web springboot
java·spring boot·后端
路在脚下@12 小时前
spring boot的配置文件属性注入到类的静态属性
java·spring boot·sql
啦啦右一12 小时前
Spring Boot | (一)Spring开发环境构建
spring boot·后端·spring
森屿Serien12 小时前
Spring Boot常用注解
java·spring boot·后端