Spring Security 实现 Remember Me

一、什么是 Remember Me

Remember Me 即记住我,常用于 Web 应用的登录页目的是让用户选择是否记住用户的登录状态。当用户选择了 Remember Me 选项,则在有效期内若用户重新访问同一个 Web 应用,那么用户可以直接登录到系统中,而无需重新执行登录操作。相信国内很多开发者都使用过或听过一个 云端软件开发协作平台 —— 码云,下图是它的登录页:

由上图可知,登录页除了输入用户名和密码之外,还多了一个 记住我 的复选框,用于实现前面提到的 Remember Me 功能,接下来本文将重点介绍如何基于 Spring Security 实现 Remember Me 功能。

阅读更多关于 Angular、TypeScript、Node.js/Java 、Spring 等技术文章,欢迎访问我的个人博客 —— 全栈修仙之路

二、Remember Me 处理流程

在 Spring Security 中要实现 Remember Me 功能很简单,因为它内置的过滤器 RememberMeAuthenticationFilter 已经提供了该功能。在开始实战前,我们先来看一下 Remember Me 的运行流程。

三、Remember Me 实战

3.1 配置数据源

spring.datasource.url=jdbc:mysql://127.0.0.1:3306/security?useUnicode=yes&characterEncoding=UTF-8&useSSL=false
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=

3.2 添加项目依赖


   org.springframework.boot
   spring-boot-starter-jdbc


   mysql
   mysql-connector-java

3.3 配置 PersistentTokenRepository 对象

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {    
    @Autowired
    private DataSource dataSource;

    @Bean
    UserDetailsService myUserDetailService() {
        return new MyUserDetailsService();
    }

    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl persistentTokenRepository = new JdbcTokenRepositoryImpl();
        persistentTokenRepository.setDataSource(dataSource);
        return persistentTokenRepository;
    }
}

PersistentTokenRepository 为一个接口类,这里我们用的是数据库持久化,所以实际使用的 PersistentTokenRepository 实现类是 JdbcTokenRepositoryImpl,使用它的时候需要指定数据源,所以我们需要将已配置的 dataSource 对象注入到 JdbcTokenRepositoryImpldataSource 属性中。

3.4 创建 persistent_logins 数据表

create table persistent_logins (
  username varchar(64) not null, 
  series varchar(64) primary key, 
    token varchar(64) not null, 
  last_used timestamp not null
)

3.5 添加 remember me 复选框

打开 resources/templates 路径下的 login.html 登录页,添加 Remember Me 复选框:

Remember Me:
注意:Remember Me 复选框的 name 属性的值必须为 "remember-me"

3.6 新增 remember me 配置项

protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
            .loginPage("/login")
            .and()
            .authorizeRequests()
            .antMatchers("/authentication/require", "/login").permitAll()
            .anyRequest().authenticated()
            .and().csrf().disable()
            // 新增remember me配置信息
            .rememberMe()
            .tokenRepository(persistentTokenRepository()) // 配置token持久化仓库
            .tokenValiditySeconds(3600) // 过期时间,单位为秒
            .userDetailsService(myUserDetailService()); // 处理自动登录逻辑
}

四、Remember Me 原理分析

4.1 首次登录过程

当我们首次在登录页执行登录时,登录的请求会由 UsernamePasswordAuthenticationFilter 过滤器进行处理,对于过滤器来说,它核心功能会定义在 doFilter 方法中,但该方法并不是定义在 UsernamePasswordAuthenticationFilter 过滤器中,而是定义在它的父类 AbstractAuthenticationProcessingFilter 中,doFilter 方法的定义如下:

//org/springframework/security/web/authentication/
// AbstractAuthenticationProcessingFilter.java(已省略部分代码)
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;

    // 若不需要认证,则执行下一个过滤器
        if (!requiresAuthentication(request, response)) {
            chain.doFilter(request, response);
            return;
        }

        Authentication authResult;

        try {
      // 基于用户名和密码进行认证操作
            authResult = attemptAuthentication(request, response);
            if (authResult == null) {
                return;
            }
            sessionStrategy.onAuthentication(authResult, request, response);
        }
        catch (AuthenticationException failed) {
            // 处理认证失败的逻辑
            unsuccessfulAuthentication(request, response, failed);
            return;
        }
  
        successfulAuthentication(request, response, chain, authResult);
}

在认证成功后,会调用 successfulAuthentication 方法,即执行认证成功回调函数:

// org/springframework/security/web/authentication/
// AbstractAuthenticationProcessingFilter.java    
protected void successfulAuthentication(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain, Authentication authResult)
            throws IOException, ServletException {

    // 设置 SecurityContext 对象中的 authentication 属性
        SecurityContextHolder.getContext().setAuthentication(authResult); 
        rememberMeServices.loginSuccess(request, response, authResult);
        successHandler.onAuthenticationSuccess(request, response, authResult);
}

在 successfulAuthentication 方法中,除了设置 SecurityContext 对象中的 authentication 属性之外,还会调用 rememberMeServices 对象的 loginSuccess 方法。这里的 rememberMeServices 是 RememberMeServices 接口实现类 PersistentTokenBasedRememberMeServices 所对应的实例,该实现类的定义如下:

// org/springframework/security/web/authentication/rememberme/
// PersistentTokenBasedRememberMeServices.java
protected void onLoginSuccess(HttpServletRequest request,
            HttpServletResponse response, Authentication successfulAuthentication) {
        String username = successfulAuthentication.getName();

        PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
                username, generateSeriesData(), generateTokenData(), new Date());
        try {
      // 使用数据库持久化保存 persistentToken 并返回 remember-me Cookie
            tokenRepository.createNewToken(persistentToken);
            addCookie(persistentToken, request, response);
        }
        catch (Exception e) {
            logger.error("Failed to save persistent token ", e);
        }
}

在 onLoginSuccess 方法内部,会利用认证成功返回的对象创建 persistentToken,然后利用 tokenRepository 对象(在 Remember Me 实战部分中配置的 PersistentTokenRepository Bean 对象)对 token 进行持久化处理。

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {  
    // 已省略部分代码
    @Bean
    public PersistentTokenRepository persistentTokenRepository() {
        JdbcTokenRepositoryImpl persistentTokenRepository = new JdbcTokenRepositoryImpl();
        persistentTokenRepository.setDataSource(dataSource);
        return persistentTokenRepository;
    }
}

而 JdbcTokenRepositoryImpl 类中 createNewToken 方法的实现逻辑也很简单,就是利用 JdbcTemplate 把生成的 token 插入到 persistent_logins 数据表中:

// org/springframework/security/web/authentication/rememberme/JdbcTokenRepositoryImpl.java
public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements
        PersistentTokenRepository {
  public void createNewToken(PersistentRememberMeToken token) {
      getJdbcTemplate().update(insertTokenSql, token.getUsername(), token.getSeries(),
          token.getTokenValue(), token.getDate());
  }
}

相应的数据库插入语句如下:

insert into persistent_logins (username, series, token, last_used) values(?,?,?,?);

成功执行插入语句后,在数据库 persistent_logins 表中会新增一条记录:

除此之外,在 onLoginSuccess 方法中还会调用 addCookie 添加相应的 Cookie。为了更加直观的感受 addCookie 方法最终达到的效果,我们来看一下实战部分勾选 Remember Me 复选框后登录成功后返回的响应体:

通过上图可知,在勾选 Remember Me 复选框成功登录之后,除了设置常见的 JSESSIONID Cookie 之外,还会进一步设置 remember-me Cookie。

4.2 Remember Me Cookie 校验流程

在成功设置 remember-me Cookie 之后,当前站点下所发起的 HTTP 请求的请求头都会默认带上 Cookie 信息,它包含两部分信息,即 JSESSIONID 和 remember-me Cookie 信息。

这里 remember-me Cookie 的认证处理也会交由 Spring Security 内部的 RememberMeAuthenticationFilter

过滤器来处理。与分析 UsernamePasswordAuthenticationFilter 过滤器一样,我们也先来看一下该过滤器的 doFilter 方法:

// org/springframework/security/web/authentication/rememberme/
// RememberMeAuthenticationFilter.java(已省略部分代码)
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;

    // 若SecurityContext上下文对象的认证信息为null,则执行自动登录操作
        if (SecurityContextHolder.getContext().getAuthentication() == null) {
            Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
                    response);

            if (rememberMeAuth != null) {
                try {
          // 调用authenticationManager对象进行认证,最终调用RememberMeAuthenticationProvider
          // 对象的authenticate方法进行认证
                    rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
                    SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
                    onSuccessfulAuthentication(request, response, rememberMeAuth);

                    if (successHandler != null) {
                        successHandler.onAuthenticationSuccess(request, response,
                                rememberMeAuth);
                        return;
                    }
                }
                catch (AuthenticationException authenticationException) {
                    rememberMeServices.loginFail(request, response);
                    onUnsuccessfulAuthentication(request, response,
                            authenticationException);
                }
            }
            chain.doFilter(request, response);
        }
        else {
            chain.doFilter(request, response);
        }
}

在 doFilter 方法中,若发现 SecurityContext 上下文对象的认证信息为 null,则执行自动登录操作就是通过调用rememberMeServices 对象的 autoLogin 方法来实现:

// org/springframework/security/web/authentication/rememberme/
// AbstractRememberMeServices.java
public final Authentication autoLogin(HttpServletRequest request,
            HttpServletResponse response) {
    
    // 从请求中抽取remember-me Cookie
    // SPRING_SECURITY_REMEMBER_ME_COOKIE_KEY = "remember-me";
        String rememberMeCookie = extractRememberMeCookie(request);

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

    // 若remember-me Cookie长度为零,则在响应头中设置它的maxAge属性为0
    // 用于禁用持久化登录
        if (rememberMeCookie.length() == 0) {
            logger.debug("Cookie was empty");
            cancelCookie(request, response);
            return null;
        }

        UserDetails user = null;

        try {
      // 执行解码操作,使用":"分隔符进行切割,转换成token字符串数组
            String[] cookieTokens = decodeCookie(rememberMeCookie);
            user = processAutoLoginCookie(cookieTokens, request, response);
            userDetailsChecker.check(user);
            logger.debug("Remember-me cookie accepted");
      // 创建RememberMeAuthenticationToken对象
            return createSuccessfulAuthentication(request, user);
        }
        catch (CookieTheftException cte) {
            cancelCookie(request, response);
            throw cte;
        }
    // 省略UsernameNotFoundException、InvalidCookieException和AccountStatusException
    // 异常处理逻辑
        catch (RememberMeAuthenticationException e) {
            logger.debug(e.getMessage());
        }

        cancelCookie(request, response);
        return null;
}

在 autoLogin 方法中,会使用 decodeCookie 方法对 remember-me Cookie 执行解码操作,然后使用 : 分隔符进行切割拆分为 tokens 字符串数组,我本机的解码结果如下:

在完成 cookie 解码之后,会尝试使用该 cookie 进行自动登录,即调用内部的 processAutoLoginCookie 方法,该方法内部的执行流程如下:

  1. 使用 presentedSeries(series) 作为参数调用 tokenRepository 对象的 getTokenForSeries 方法获取 token (PersistentRememberMeToken) 对象,然后对返回的 token 执行校验,比如判空或有效期验证;
  2. 验证通过后重新生成新的 newToken (PersistentRememberMeToken)并更新数据库中相应的记录值;
  3. 使用前面从数据库中获得的 token 对象,并以 token 的用户名作为参数调用 UserDetailsService 对象的 loadUserByUsername 方法加载用户的详细信息。
// org/springframework/security/web/authentication/rememberme/
// PersistentTokenBasedRememberMeServices.java
protected UserDetails processAutoLoginCookie(String[] cookieTokens,
            HttpServletRequest request, HttpServletResponse response) {
        final String presentedSeries = cookieTokens[0];
        final String presentedToken = cookieTokens[1];

        PersistentRememberMeToken token = tokenRepository
                .getTokenForSeries(presentedSeries);
  
    // 省略token判空校验、presentedToken与数据库token相等校验和token有效期校验逻辑
        PersistentRememberMeToken newToken = new PersistentRememberMeToken(
                token.getUsername(), token.getSeries(), generateTokenData(), new Date());

        try {
            tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(),
                    newToken.getDate());
            addCookie(newToken, request, response);
        }
        catch (Exception e) {
            logger.error("Failed to update token: ", e);
            throw new RememberMeAuthenticationException(
                    "Autologin failed due to data access problem");
        }

        return getUserDetailsService().loadUserByUsername(token.getUsername());
}

rememberMeServices 对象的 autoLogin 方法,在登录成功后会返回 RememberMeAuthenticationToken 对象,之后 RememberMeAuthenticationFilter 过滤器会继续调用 authenticationManager 对象执行认证,而最终调用 RememberMeAuthenticationProvider 对象的 authenticate 方法进行认证,认证成功后会前往下一个过滤器进行处理。

本文项目地址: Github - remember-me

五、参考资源

  • MrBird - Spring Security添加记住我功能

你可能感兴趣的