前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Spring Security 实现 Remember Me

Spring Security 实现 Remember Me

作者头像
阿宝哥
发布2019-11-19 19:52:57
2.6K0
发布2019-11-19 19:52:57
举报
文章被收录于专栏:全栈修仙之路

一、什么是 Remember Me

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

gitee
gitee

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

二、Remember Me 处理流程

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

remember-me-flow
remember-me-flow

三、Remember Me 实战

3.1 配置数据源
代码语言:javascript
复制
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 添加项目依赖
代码语言:javascript
复制
<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 对象
代码语言:javascript
复制
@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 数据表
代码语言:javascript
复制
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 复选框:

代码语言:javascript
复制
<div class="form-field">
   Remember Me:<input type="checkbox" name="remember-me" value="true"/>
</div>

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

3.6 新增 remember me 配置项
代码语言:javascript
复制
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 方法的定义如下:

代码语言:javascript
复制
//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 方法,即执行认证成功回调函数:

代码语言:javascript
复制
// 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 所对应的实例,该实现类的定义如下:

代码语言:javascript
复制
// 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 进行持久化处理。

代码语言:javascript
复制
@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 数据表中:

代码语言:javascript
复制
// 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());
  }
}

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

代码语言:javascript
复制
insert into persistent_logins (username, series, token, last_used) values(?,?,?,?);

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

persistent-logins-record
persistent-logins-record

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

remember-me-cookie
remember-me-cookie

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

4.2 Remember Me Cookie 校验流程

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

request-remember-me-cookie
request-remember-me-cookie

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

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

代码语言:javascript
复制
// 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 方法来实现:

代码语言:javascript
复制
// 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 字符串数组,我本机的解码结果如下:

split-remember-me-cookies
split-remember-me-cookies

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

  1. 使用 presentedSeries(series) 作为参数调用 tokenRepository 对象的 getTokenForSeries 方法获取 token (PersistentRememberMeToken) 对象,然后对返回的 token 执行校验,比如判空或有效期验证;
  2. 验证通过后重新生成新的 newToken (PersistentRememberMeToken)并更新数据库中相应的记录值;
  3. 使用前面从数据库中获得的 token 对象,并以 token 的用户名作为参数调用 UserDetailsService 对象的 loadUserByUsername 方法加载用户的详细信息。
代码语言:javascript
复制
// 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

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2019/11/19,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、什么是 Remember Me
  • 二、Remember Me 处理流程
  • 三、Remember Me 实战
    • 3.1 配置数据源
      • 3.2 添加项目依赖
        • 3.3 配置 PersistentTokenRepository 对象
          • 3.4 创建 persistent_logins 数据表
            • 3.5 添加 remember me 复选框
              • 3.6 新增 remember me 配置项
              • 四、Remember Me 原理分析
                • 4.1 首次登录过程
                  • 4.2 Remember Me Cookie 校验流程
                  相关产品与服务
                  数据库
                  云数据库为企业提供了完善的关系型数据库、非关系型数据库、分析型数据库和数据库生态工具。您可以通过产品选择和组合搭建,轻松实现高可靠、高可用性、高性能等数据库需求。云数据库服务也可大幅减少您的运维工作量,更专注于业务发展,让企业一站式享受数据上云及分布式架构的技术红利!
                  领券
                  问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档