Spring Security 实现 Remember Me

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

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

二、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 添加项目依赖

<dependency>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
   <groupId>mysql</groupId>
   <artifactId>mysql-connector-java</artifactId>
</dependency>

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 复选框:

<div class="form-field">
   Remember Me:<input type="checkbox" name="remember-me" value="true"/>
</div>

注意: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

全栈修仙之路,及时阅读 Angular、TypeScript、Node.js/Java和Spring技术栈最新文章。

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章