跳到主要内容

16、Spring Security 实战 - 基于持久化令牌方案实现自动登录

前言

在上一章节中, 带各位基于散列加密方案实现了自动登录,并且给各位介绍了散列加密算法,其实还有另一种自动登录的实现方案,也就是基于持久化令牌方案来进行实现。接下来请跟 学习这种方案该怎么实现吧。

一. 持久化令牌方案简介

1. 持久化令牌方案

有的小伙伴会问,既然我们要基于持久化令牌来实现自动登录,那啥是持久化令牌啊?所以 先给大家做个概念解释。

所谓的持久化令牌的实现方案,其实在交互上与我们前面章节讲的散列加密方案一致,只不过是在用户勾选Remember-me之后,将生成的token令牌发送到用户浏览器,并在用户下次访问系统时读取该令牌进行认证。不同的是,持久化令牌方案采用了更加严谨的安全性设计,也就是安全性更好一些

在持久化令牌方案中,最核心的是series和token两个值,这两个值都是用MD5散列计算生成的随机字符串。不同的是,series仅在用户使用密码重新登录时更新,而 token 会在每一个新的session会话中都重新生成

2. 持久化令牌方案优点

我们前面已经学习了基于散列算法的自动登录方案了,为啥还要再学习持久化方案呢?肯定是因为它有独特之处吧。

首先,持久化令牌方案 避免了散列加密方案中,一个令牌可以同时在多端登录的问题,这是因为每个session会话都会引发token的更新,即每个token仅支持单实例登录

其次,自动登录不会导致series变更,但每次自动登录都需要同时验证 series和 token两个值,所以这样的设计会更安全。因为当该令牌还未使用过自动登录就被盗取时,系统会在非法用户验证通过后刷新 token 值,此时在合法用户的浏览器中,该token值已经失效。当合法用户使用自动登录时,由于该series对应的 token 不同,系统可以推断该令牌可能已被盗用,从而做一些处理。例如,清理该用户的所有自动登录令牌,并通知该用户可能已被盗号等。

了解了持久化令牌的概念和优点之后,接下来就跟着 进行代码实现吧。

二. 持久化令牌方案的代码实现

1. 创建persistent_logins表

在持久化令牌方案中,是要把令牌进行持久化保存的,那么把令牌持久化到哪里去呢?我们首选数据库!

所以我们首先创建一张persistent_logins表,用来存储我们自动登录时生成的持久化令牌信息,该表SQL脚本如下:

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

在该表中,series是主键,可以根据series进行令牌信息的查询等操作。

2. 配置SecurityConfig类

请按之前的方式创建一个新的项目module,具体过程略。

首先我们创建SucurityConfig配置类,在configure(HttpSecurity http)方法中通过tokenRepository()方法关联JdbcTokenRepositoryImpl,进而对persistent_logins表进行增删改查。

@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Value("${spring.security.remember-me.key}")
    private String rememberKey;

    @Autowired
    private DataSource dataSource;

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //配置数据源
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);

        http.authorizeRequests()
                .antMatchers("/admin/**")
                .hasRole("ADMIN")
                .antMatchers("/user/**")
                .hasRole("USER")
                .antMatchers("/app/**")
                .permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .formLogin()
                .permitAll()
                .and()
                //开启记住我功能
                .rememberMe()
                .userDetailsService(userDetailsService)
                //1.设置加密的key
                .key(rememberKey)
                //2.持久化令牌方案
                .tokenRepository(tokenRepository)
                //设置令牌有效期,为7天有效期
                .tokenValiditySeconds(60 * 60 * 24 * 7)
                .and()
                .csrf()
                .disable();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
    
}

3. 配置application.yml文件

创建applicaiton.yml文件并在其中关联配置自己的数据库,我是在该数据库中创建了persistent_logins表。

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/db-security?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=GMT
    username: root
    password: syc
  security:
    remember-me:
      key: yyg

4. 启动项目测试

创建一个项目入口类(代码略),然后把项目启动起来。

这时候,我们只需要在登录页面中输入 用户名和密码,勾选“记住我”功能之后,Spring Security就会生成一个持久化令牌,在这个令牌中就保存了当前登陆的用户信息,该令牌信息会被自动持久化存储到persistent_logins表中。

 

在我们的persistent_logins表中,你会看到保存的自动登录的用户信息。

 

这样我们就实现了基于持久化令牌的自动登录方案。

5. 代码结构

各位可以参考下图,结合前面的章节,自行实现。

 

三. 持久化令牌源码解析

现在 已经带各位实现了基于持久化令牌方案的自动登录,你可能很好奇,Spring Security内部到底是怎么进行实现的呢?接下来请跟着我一起分析其实现原理和底层源码吧。

1. 持久化令牌的实现原理

我先给大家分析一下持久化令牌的实现原理。

当我们经过自动登录之后,就可以在persistent_logins表的series和token字段中看到,分别保存了对应的信息。

 

在自动登录成功后,也会在remember-me中保存用户的cookie信息,我们可以利用Base64在线编解码工具对该cookie信息进行解码。Base64在线编解码工具直接百度即可找到。

 

我们看到解码出来的结果以 “:” 分割为前后两部分,冒号前面的部分是我们保存在数据库里的series字段的内容,冒号后面的部分是token字段存储的内容

另外在persistent_logins表中还存储了token的过期时间,以后用户每次登陆成功后,都会通过用户名确认该令牌的身份,通过对比token可以得知该令牌是否有效。并且通过上一次自动登录的时间也可以知道该令牌是否已过期,并在完整校验通过之后生成新的token

2. PersistentRememberMeToken令牌类

我给大家介绍完持久化令牌的基本实现原理后,再给各位剖析一下该方案的底层源码。

Spring Security中是使用 PersistentRememberMeToken类来封装持久化令牌对象的,源码如下。

/**
 * @author Luke Taylor
 */
public class PersistentRememberMeToken {
	private final String username;
	private final String series;
	private final String tokenValue;
	private final Date date;

	......
    getter & setter方法略    
}

会发现在该源码中,有series和tokenValue字段,可以分别存储persistent_logins表中的series和token字段内容。

3. PersistentTokenRepository

Spring Security之所以可以实现令牌的持久化存储,主要是基于PersistentTokenRepository接口,该接口的父子类关系图如下:

 

**在PersistentTokenRepository接口中,定义了对令牌进行增删改查的4个方法,**源码定义如下:

public interface PersistentTokenRepository {

	void createNewToken(PersistentRememberMeToken token);

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

	PersistentRememberMeToken getTokenForSeries(String seriesId);

	void removeUserTokens(String username);

}    

4. JdbcTokenRepositoryImpl实现类

JdbcTokenRepositoryImpl是对PersistentTokenRepository接口的具体实现,该实现类的实现方法其实很简单,就是定义了5个SQL语句,分别是建表语句,以及对持久化令牌表的增删改查操作的SQL语句,源码如下:

public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements
		PersistentTokenRepository {
	// ~ Static fields/initializers
	// =====================================================================================

	/** Default SQL for creating the database table to store the tokens */
	public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, "
			+ "token varchar(64) not null, last_used timestamp not null)";
	/** The default SQL used by the <tt>getTokenBySeries</tt> query */
	public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?";
	/** The default SQL used by <tt>createNewToken</tt> */
	public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
	/** The default SQL used by <tt>updateToken</tt> */
	public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?";
	/** The default SQL used by <tt>removeUserTokens</tt> */
	public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?";
    
    ......
    

然后就是利用Spring自带的JdbcTemplate来实现对“persistent_logins”表的增删改查操作。

5. PersistentTokenBasedRememberMeServices类

我们再看看另一个带有记住我功能的持久化令牌服务类PersistentTokenBasedRememberMeServices,在该类中有一个处理自动登录的重要方法processAutoLoginCookie(),源码如下:

protected UserDetails processAutoLoginCookie(String[] cookieTokens,
			HttpServletRequest request, HttpServletResponse response) {

		if (cookieTokens.length != 2) {
			throw new InvalidCookieException("Cookie token did not contain " + 2
					+ " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
		}

		final String presentedSeries = cookieTokens[0];
		final String presentedToken = cookieTokens[1];

		PersistentRememberMeToken token = tokenRepository
				.getTokenForSeries(presentedSeries);

		if (token == null) {
			// No series match, so we can't authenticate using this cookie
			throw new RememberMeAuthenticationException(
					"No persistent token found for series id: " + presentedSeries);
		}

		// We have a match for this user/series combination
		if (!presentedToken.equals(token.getTokenValue())) {
			// Token doesn't match series value. Delete all logins for this user and throw
			// an exception to warn them.
			tokenRepository.removeUserTokens(token.getUsername());

			throw new CookieTheftException(
					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");
		}

		// Token also matches, so login is valid. Update the token value, keeping the
		// *same* series number.
		if (logger.isDebugEnabled()) {
			logger.debug("Refreshing persistent login token for user '"
					+ token.getUsername() + "', series '" + token.getSeries() + "'");
		}

		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());
	}

以上源码的实现逻辑如下:

1、首先从前端传来的 cookie 中解析出 series 和 token;

2、根据 series 从数据库中查询出一个 PersistentRememberMeToken 实例;

3、如果查出来的 token 和前端传来的 token 不相同,说明账号可能被人盗用(别人用你的令牌登录之后,token 会变)。此时根据用户名移除相关的 token,相当于必须要重新输入用户名密码登录才能获取新的自动登录权限。

4、接下来校验 token 是否过期;

5、构造新的 PersistentRememberMeToken 对象,并且更新数据库中的 token(这就是我们文章开头说的,新的会话都会对应一个新的 token);

6、将新的令牌重新添加到 cookie 中返回;

7、根据用户名查询用户信息,再走一波登录流程。

四. 两种自动登录实现方案对比

至此,我已经给大家详细讲解了基于散列加密方案和持久化令牌方案的自动化登录实现,这里我对两种方案做一个简单对比。

散列加密方案和持久化令牌方案,这两种方案都是把信息存储在cookie中,所以都有被盗取用户身份信息的可能性,当然持久化令牌方案的安全性更高一些。但是如果要你追求最安全的方式,那就尽量不要实现自动登录功能,所以我们要在用户体验和提高安全性之间选择平衡点。

如果我们一定要实现自动登录功能,可以限制以cookie身份登录时的部分执行权限**,比如在修改密码、修改邮箱(防止找回密码)、查看隐私信息(如完整的手机号码、银行卡号等)时,我们可以进一步校验用户的登录密码,或者设置独立密码来做二次校验,以提高安全性******。

五. 二次校验功能的实现

我们上面虽然讲解了2种自动登录的实现方案,但是依然存在用户身份被盗用的问题,这个问题其实是很难完美解决。那么我们能做的,只能是当发生用户身份被盗用这样的事情时,将损失降低到最小。因此,我们采用二次校验来增强项目的安全性。

1. 定义新的测试接口

我们在上面项目的基础上,添加一个新的测试接口/remember。

@RestController
public class UserController {

    @GetMapping("/admin/hello")
    public String helloAdmin() {

        return "hello, admin";
    }

    @GetMapping("/user/hello")
    public String helloUser() {

        return "hello, user";
    }

    @GetMapping("/visitor/hello")
    public String helloVisitor() {

        return "hello, visitor";
    }

    @GetMapping("/remember/hello")
    public String remember() {

        return "hello, remember-me功能";
    }

}

2. 配置二次校验权限

在SecurityConfig类中对/remember/hello接口配置二次校验,主要是对该接口利用rememberMe()方法进行配置。

@EnableWebSecurity(debug = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Value("${spring.security.remember-me.key}")
    private String rememberKey;

    @Autowired
    private DataSource dataSource;

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
        tokenRepository.setDataSource(dataSource);

        http.authorizeRequests()
                .antMatchers("/admin/**").fullyAuthenticated()
                //.hasRole("ADMIN")
                .antMatchers("/user/**")
                .hasRole("USER")
                //需要开启remember-me功能才能访问
                .antMatchers("/remember/**")
            	.rememberMe()
                .antMatchers("/visitor/**")
                .permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .formLogin()
                .permitAll()
                .and()
                //开启记住我功能
                .rememberMe()
                .userDetailsService(userDetailsService)
                //1.散列加密方案
                .key(rememberKey)
                //2.持久化令牌方案
                .tokenRepository(tokenRepository)
                //7天有效期
                .tokenValiditySeconds(60 * 60 * 24 * 7)
                .and()
                .csrf()
                .disable();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }
    
}

当我们访问"/remember/hello"接口时,需要二次校验才能访问,即该接口需要开启remember-me功能才能访问:

 

至此,就结合着源码和底层原理,给大家讲解了基于持久化令牌方案实现了自动登录,并且在本案例中给大家介绍了持久化令牌的概念及实现原理,你掌握的怎么样呢?请在评论区给 留言,说说你的感受吧!下一篇文章中, 会给各位讲解 如何在Spring Security环境下实现注销登录,敬请期待哦!