2.1 自动登录
2.1.1 实现思路
- 用户勾选记住我登录成功;
- Spring Security 生成一个 token 标识,然后将该 token 标识持久化到数据库;
- 同时生成一个与该 token 相对应的 cookie 返回给浏览器;
- 当用户过段时间再次访问系统时,如果该 cookie 没有过期,Spring Security 便会根据 cookie 包含的信息从数据库中获取相应的 token 信息;
从 token 信息中获取用户身份,帮用户自动完成登录操作。
2.1.2 源码分析
PersistentRememberMeToken
该类用来存储记住我用户的 token 信息,配合 PersistentTokenRepository 持久化接口,将 token 信息存入内存或持久化到数据库。
源码如下:public class PersistentRememberMeToken {
// 登录用户的用户名
private final String username;
// token 的唯一标识
private final String series;
// token 的内容
private final String tokenValue;
// token 的最后访问时间
private final Date date;
public PersistentRememberMeToken(String username, String series, String tokenValue, Date date) {
this.username = username;
this.series = series;
this.tokenValue = tokenValue;
this.date = date;
}
public String getUsername() {
return this.username;
}
public String getSeries() {
return this.series;
}
public String getTokenValue() {
return this.tokenValue;
}
public Date getDate() {
return this.date;
}
}
PersistentTokenRepository
PersistentTokenRepository 是 Spring Security 提供的持久化记住我 token 的接口。接口源码如下:
public interface PersistentTokenRepository {
/**
* 创建 token
* @param token
*/
void createNewToken(PersistentRememberMeToken token);
/**
* 更新 token
* @param series token 的唯一表示
* @param tokenValue token 的内容
* @param lastUsed token 的最后使用时间
*/
void updateToken(String series, String tokenValue, Date lastUsed);
/**
* 根据 token 唯一表示获取 token
* @param series
* @return
*/
PersistentRememberMeToken getTokenForSeries(String series);
/**
* 删除 token
* @param username 登录用户的用户名
*/
void removeUserTokens(String username);
}
该接口有两个实现类:JdbcTokenRepositoryImpl 和 InMemoryTokenRepositoryImpl。
- JdbcTokenRepositoryImpl 使用 JdbcTemplate 模板操作 token 持久化到数据库。
- InMemoryTokenRepositoryImpl 是基于内存的 token 持久化方案,该方案服务器重启,记住的用户会丢失。
我们不使用以上两种实现,我们扩展 PersistentTokenRepository 接口,将 token 信息持久化到 Redis 数据库。
2.1.3 具体实现
RedisTemplate
定义一个 Redis 的操作模版,来完成对 PersistentRememberMeToken 的操作:
@Bean
public RedisTemplate<String, PersistentRememberMeToken> redisTemplate(
RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, PersistentRememberMeToken>
redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
return redisTemplate;
}
RedisConnectionFactory 不需要再创建,spring-boot-starter-data-redis 已经帮助我们创建了 Redis 连接工厂对象并注入到了 Spring IOC 容器。
实现 PersistentTokenRepository 接口
实现 PersistentTokenRepository 接口:public class RedisTokenRepositoryImpl implements PersistentTokenRepository { private static final String REMEBERME_TOKEN_KEY_PREFIX = "remember-me:"; @Autowired private RedisTemplate<String, PersistentRememberMeToken> redisTemplate; /** * 创建 token * @param token */ @Override public void createNewToken(PersistentRememberMeToken token) { this.removeUserTokens(token.getUsername()); redisTemplate.opsForValue().set(REMEBERME_TOKEN_KEY_PREFIX + token.getSeries(), token); } /** * 根据 token 唯一表示获取 token * @param series * @return */ @Override public PersistentRememberMeToken getTokenForSeries(String series) { return redisTemplate.opsForValue().get(REMEBERME_TOKEN_KEY_PREFIX + series); } /** * 更新 token * @param series token 的唯一表示 * @param tokenValue token 的内容 * @param lastUsed token 的最后使用时间 */ @Override public synchronized void updateToken(String series, String tokenValue, Date lastUsed) { PersistentRememberMeToken token = this.getTokenForSeries(series); PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), series, tokenValue, new Date()); this.createNewToken(newToken); } /** * 删除 token * @param username 登录用户的用户名 */ @Override public synchronized void removeUserTokens(String username) { Set<String> keys = redisTemplate.keys(REMEBERME_TOKEN_KEY_PREFIX + "*"); for (String key : keys) { PersistentRememberMeToken token = redisTemplate.opsForValue().get(key); if (username.equals(token.getUsername())) { redisTemplate.delete(key); } } } }
配置 rememberMe
@Configuration @EnableGlobalMethodSecurity(prePostEnabled = true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Resource private UserServiceImpl userDetailsService; @Override protected void configure(HttpSecurity http) throws Exception { http ...... .and() // 记住我配置 .rememberMe() .tokenRepository(rememberMeTokenRepository()) // 配置token持久化仓库 .tokenValiditySeconds(60 * 60 * 24 * 7) // remember 过期时间7天内有效单为秒 .userDetailsService(userDetailsService) // 自动登录调用 .and() // 禁用跨域请求伪造防护 .csrf().disable(); } @Bean public RedisTemplate<String, PersistentRememberMeToken> redisTemplate( RedisConnectionFactory redisConnectionFactory) { RedisTemplate<String, PersistentRememberMeToken> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory); return redisTemplate; } @Bean public RedisTokenRepositoryImpl rememberMeTokenRepository() { return new RedisTokenRepositoryImpl(); } }
注入 UserServiceImpl 对象,配置到记住我的配置里,记住我功能要读取用户信息。
- 将 RedisTokenRepositoryImpl 对象交给 Spring 容器管理,并注入给记住我功能使用。
登录页面
在登录页面添加一个记住我的复选框表单,该表单名称为 remember-me。<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>系统登录</title> </head> <body> <h1>登录页面</h1> <form method="post" th:action="@{/login}"> 用户名:<input type="text" name="username"/><br/> 密 码:<input type="password" name="password"/><br/> <input type="checkbox" name="remember-me"/> 记住我 <br> <input type="submit" value="登录"/> </form> </body> </html>
记住我的表单名称必须是 remember-me
测试验证
登录的时候,选择记住我测试
后台报序列化异常错误:
org.springframework.data.redis.serializer.SerializationException:
Cannot serialize; nested exception is org.springframework.core.serializer.support.SerializationFailedException: Failed to serialize object using DefaultSerializer; nested exception is java.lang.IllegalArgumentException: DefaultSerializer requires a Serializable payload but received an object of type [org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken]
at org.springframework.data.redis.serializer.JdkSerializationRedisSerializer.serialize(JdkSerializationRedisSerializer.java:96) ~[spring-data-redis-2.4.3.jar:2.4.3]
错误分析
我们定义的 RedisTemplate key 和 value 默认使用的是 JDK 的序列化,JDK 序列化需要被序列化的类实现 Serializable 接口。
查看 PersistentRememberMeToken 源码:
发现该类没有实现 Serializable 接口。并且该类也没有提供无参构造方法。这样反序列化的过程也会有问题。
解决方案
- 自己再实现一个 PersistentRememberMeToken 类,提供无参构造和实现序列化接口。
- 使用 JSON 序列化和反序列化
- 解决了 PersistentRememberMeToken 没有实现 Serializable 接口的问题;
- 反序列化可能不成功,反序列化过程创建对象可能会用到无参构造;
- 使用 Jackson 类库的 MixIn(混入)可以解决该问题。
2.1.4 解决序列化问题
创建 MixIn 类
package com.imcode.rememberme;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.Date;
public class RedisTokeMixIn {
public RedisTokeMixIn(
@JsonProperty("username") String username,
@JsonProperty("series") String series,
@JsonProperty("tokenValue") String tokenValue,
@JsonProperty("date") Date date) {
}
}
配置
@Bean
public RedisTemplate<String, PersistentRememberMeToken> redisTemplate(
RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, PersistentRememberMeToken>
redisTemplate = new RedisTemplate<>();
Jackson2JsonRedisSerializer<PersistentRememberMeToken>
jsonSerializer = new Jackson2JsonRedisSerializer<>(PersistentRememberMeToken.class);
ObjectMapper objectMapper = new ObjectMapper();
// 使用MixIn
objectMapper.addMixIn(PersistentRememberMeToken.class, RedisTokeMixIn.class);
jsonSerializer.setObjectMapper(objectMapper);
// value 使用 Jackson 序列化成 json
redisTemplate.setValueSerializer(jsonSerializer);
// key 使用 String 序列化,方便查看和维护
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory);
return redisTemplate;
}
测试
登录成功以后,浏览器cookie中增加了名称为 remember-me 的cookie,有效期7天。
查看 Redis 数据库:
登录成功以后,浏览器 cookie 中增加了名称为 remember-me 的 cookie,有效期7天。
查看数据库: