Spring Security 实现“记住我”功能,即自动登录功能有两种方式:
- 将 token写入到浏览器的 Cookie中
- 将 token持久化到数据库
一、将 token写入到浏览器的 Cookie中
1、代码实现
1.1 后端
Spring Security默认是没有开启“记住我”功能,我们在 Spring Security配置类中开启它即可。
// 开启记住我功能
.rememberMe()
.key("rememberMeKey") // 默认 key为UUID,我们自定义 key
.tokenValiditySeconds(60) //设置token的过期时间,默认2周
注意:
key 默认值是一个 UUID 字符串,如果服务端重启,这个 key 会变,这样会导致之前所有 remember-me 自动登录令牌失效,所以,我们一般都指定 key值。
1.2 前端
前端需要注意:
- 记住我的字段名称 默认是
remember-me
- remember-me的值必须是
true | on | yes | "1"
这些字段名可以自定义,我们使用默认值就行。
1.3 测试
测试一下,登录认证通过后,关掉浏览器,再次打开页面,remember-me功能生效了,就这么简单。
我们将 remember-me的值,通过 Base64 转码后的字符串,得到:
可以看到,cookie 中 remember-me 的使用用 : 隔开,分成了三部分:
- 用户名。
- 时间戳,即 token的过期时间。
- 是使用 MD5 散列函数算出来的值,它的明文格式是 username + “:” + tokenExpiryTime + “:” + password + “:” + key,最后的 key 是一个散列盐值,可以用来防治令牌被修改。
2、源码分析
前面分析登录认证流程时,认证成功就会调用“记住我”功能。
- Spring Security登录认证源码分析:https://blog.csdn.net/qq_42402854/article/details/122295175
查看 UsernamePasswordAuthenticationFilter的父类 AbstractAuthenticationProcessingFilter过滤器的 doFIlter方法中,认证做了两个分支,
- 成功执行 successfulAuthentication,
- 失败执行 unsuccessfulAuthentication。
在 successfulAuthentication内部,将用户认证信息存储到了 SecurityContext中,并调用了 loginSuccess方法,这就是“记住我”功能的核心方法。
2.1 token的生成
查看 AbstractRememberMeServices类的 loginSuccess方法
。
private String parameter = "remember-me";
public final void loginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
// 判断是否勾选记住我
// 注意:这里this.parameter点进去是上面的private String parameter = "remember-me";
if (!this.rememberMeRequested(request, this.parameter)) {
this.logger.debug("Remember-me login not requested.");
} else {
//若勾选就调用onLoginSuccess方法
this.onLoginSuccess(request, response, successfulAuthentication);
}
}
1)“记住我”表单属性的名称和值
protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {
if (this.alwaysRemember) {
return true;
} else {
// "remember-me"属性名默认为"remember-me"
String paramValue = request.getParameter(parameter);
// 这属性值可以为:true,on,yes,1。
if (paramValue != null && (paramValue.equalsIgnoreCase("true") ||
paramValue.equalsIgnoreCase("on") || paramValue.equalsIgnoreCase("yes") ||
paramValue.equals("1"))) {
//满足上面条件才能返回true
return true;
} else {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Did not send remember-me cookie (principal did not set
parameter '" + parameter + "')");
}
return false;
}
}
}
2)查看 TokenBasedRememberMeServices类的 onLoginSuccess方法。
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
//1.获取用户信息
String username = this.retrieveUserName(successfulAuthentication);
String password = this.retrievePassword(successfulAuthentication);
if (!StringUtils.hasLength(username)) {
this.logger.debug("Unable to retrieve username");
} else {
if (!StringUtils.hasLength(password)) {
UserDetails user = this.getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
if (!StringUtils.hasLength(password)) {
this.logger.debug("Unable to obtain password for user: " + username);
return;
}
}
//2.获取token的有效期
int tokenLifetime = this.calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
expiryTime += 1000L * (long)(tokenLifetime < 0 ? 1209600 : tokenLifetime);
//3.生成MD5签名值
String signatureValue = this.makeTokenSignature(expiryTime, username, password);
//4.将信息添加到浏览器的 Cookie中
this.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) + "'");
}
}
}
方法的逻辑如下:
- 1、获取用户信息
从登录成功的 Authentication 中提取出用户名/密码。
由于登录成功之后,密码可能被擦除了,所以,如果一开始没有拿到密码,就再从 UserDetailsService 中重新加载用户并重新获取密码。 - 2、获取token的有效期,令牌有效期默认就是两周。
- 3、生成MD5签名值
调用 makeTokenSignature 方法去计算散列值,实际上就是根据 username、令牌有效期以及 password、key 一起计算一个散列值。如果我们没有自己去设置这个 key,默认是在 RememberMeConfigurer#getKey 方法中进行设置的,它的值是一个 UUID 字符串。 - 4、将用户名、令牌有效期以及计算得到的散列值,,生成 token值 并添加到浏览器的 Cookie中。
2.2 token解析
查看 RememberMeAuthenticationFilter
过滤器的 doFilter 方法。
如果从 SecurityContextHolder 中无法获取到当前登录用户实例,就调用 rememberMeServices.autoLogin重点方法进行自动登录逻辑。
查看 autoLogin方法:
逻辑如下:
- 1、提取出 cookie 信息,
- 2、对 cookie 信息进行解码,
- 3、调用 processAutoLoginCookie方法校验token,核心流程:首先获取用户名和过期时间,再根据用户名查询到用户密码,然后通过 MD5 散列函数计算出散列值,再将拿到的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效,进而确认登录是否有效。
- 4、用户状态判断
- 5、创建 RememberMeAuthenticationToken实例
二、将 token持久化到数据库
“记住我”功能将 token保存到浏览器中,很容易盗取,而且 cookie的值还与用户名、密码这些敏感数据相关,虽然加密了,但是将敏感信息存在客户端,还是不太安全。
上面源码我们也看到了,认证成功之后,有两个“记住我”功能实现方式。
所以,Spring Security还提供了 remember me的另一种相对更安全的实现机制:将 token持久化到数据库。
在客户端的 cookie中,仅保存一个无意义的加密串(与用户名、密码等敏感数据无关),
然后在数据库中保存该加密串与用户信息的对应关系,
自动登录时,用cookie中的加密串,到数据库中验证,如果通过,自动登录才算通过。
创建记录 remember me一张表来记录令牌信息:
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL,
`series` varchar(64) NOT NULL,
`token` varchar(64) NOT NULL,
`last_used` timestamp NOT NULL,
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
注意:
这张表的名称和字段都是固定的,不要修改,官方提供的。如果使用默认的 JDBC,即 JdbcTokenRepositoryImpl。
这张表我们也可以完全自定义,也可以使用系统提供的 JDBC 来操作。这里我们使用官方的表和默认的 JDBC来实现“记住我”功能。
1、代码实现
1.1 后端
在 Spring Security配置类中开启它即可。并默认的 JDBC,指定 JdbcTokenRepositoryImpl。
// 2.指定数据源
@Autowired
private DataSource dataSource;
@Bean
JdbcTokenRepositoryImpl jdbcTokenRepository() {
JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
jdbcTokenRepository.setDataSource(dataSource);
return jdbcTokenRepository;
}
//2. SpringSecurity配置相关信息
@Override
public void configure(HttpSecurity http) throws Exception {
// 释放静态资源,指定拦截规则,指定自定义的认证和退出页面,csrf配置等
http.authorizeRequests()
// 指定拦截规则
...
// 开启记住我功能
.rememberMe()
.key("rememberMeKey") // 默认 key为UUID,我们可以自定义 key
.tokenValiditySeconds(60) //设置token的过期时间,默认2周
.tokenRepository(jdbcTokenRepository())
;
}
1.2 测试
前端同上,登录认证通过后,关掉浏览器,再次打开页面,remember-me功能生效了,数据表多了一条记录。
可以看到,cookie 中 remember-me 的使用用 : 隔开,分成了两部分:
- 数据表中的 series字段值。
- 数据表中的 token字段值。
2、源码分析
分析过程同上,重点关注 PersistentTokenBasedRememberMeServices类
。
2.1 表记录生成
查看 PersistentTokenBasedRememberMeServices类的 loginSuccess方法。
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
//获取用户名
String username = successfulAuthentication.getName();
this.logger.debug("Creating new persistent login for user " + username);
//1.生成 series和token
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, this.generateSeriesData(), this.generateTokenData(), new Date());
try {
//2.入库
this.tokenRepository.createNewToken(persistentToken);
//3.添加token到浏览器的Cookie中
this.addCookie(persistentToken, request, response);
} catch (Exception var7) {
this.logger.error("Failed to save persistent token ", var7);
}
}
逻辑如下:
- 构造一个 PersistentRememberMeToken 实例,generateSeriesData 和 generateTokenData 方法分别用来获取 series 和 token,具体的生成过程实际上就是调用 SecureRandom 生成随机数再进行 Base64 编码,不同于 Math.random 或者 java.util.Random 这种伪随机数,SecureRandom 则采用的是类似于密码学的随机数生成规则,其输出结果较难预测,适合在登录这样的场景下使用。
- 调用 tokenRepository 实例(我们配置的 JdbcTokenRepositoryImpl)中的 createNewToken 方法,将 PersistentRememberMeToken 存入数据库中。
- 最后添加 series 和 token 到浏览器 Cookie中。
public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements PersistentTokenRepository {
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)";
public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?";
public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?";
public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?";
private String tokensBySeriesSql = "select username,series,token,last_used from persistent_logins where series = ?";
private String insertTokenSql = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
private String updateTokenSql = "update persistent_logins set token = ?, last_used = ? where series = ?";
private String removeUserTokensSql = "delete from persistent_logins where username = ?";
private boolean createTableOnStartup;
...
2.2 token解析
查看 rememberMeServices.autoLogin重点方法进行自动登录逻辑。
查看 PersistentTokenBasedRememberMeServices类的 processAutoLoginCookie方法。
逻辑如下:
- 从前端传来的 cookie 中解析出 series 和 token。
- 根据 series 从数据库中查询出一个 PersistentRememberMeToken 实例。
- 如果查出来的 token 和前端传来的 token 不相同,此时根据用户名移除相关的 token,相当于必须要重新输入用户名密码登录才能获取新的自动登录权限。
- 校验 token 是否过期。
- 构造新的 PersistentRememberMeToken 对象,并且更新数据库中的 token信息。
- 将新的令牌重新添加到 cookie 中返回。
- 根据用户名查询用户信息,再走一波登录流程。
– 求知若饥,虚心若愚。