remember-me与Session
remember-me
保存到Cookie
在很多应用中,用户不必每次都输入用户名密码才能访问,而是通过勾选记住我(remember-me)
来让浏览器Cookie记住用户的登录状态,以便下次无需登录就能访问应用。
Spring Security实现这个功能很简单,只需要对WebSecurityConfiguration
做一点小改动即可。
......
@Autowired
private CustomAccessDeniedHandler accessDeniedHandler;
......
// 控制逻辑
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
......
// 使用Cookie实现记住我(remember-me)功能
.and().rememberMe()
......
}
在Postman的参数中增加remember-me
参数,并设为true
。

可以看到Cookie
中多了remember-me
的键值对。

保存到MySQL
虽然用Cookie实现remember-me
很方便,但其实是不安全的。因此Spring Security提供了另外一种实现机制:保存到数据库。
当自动登录时,将Cookie加密串和数据库中保存的数据进行比对,如果通过,才算登录成功。
使用这种方式实现remember-me
也很简单。
首先,在数据中创建保存相关登录信息的表。
DROP TABLE IF EXISTS persistent_logins;
CREATE TABLE persistent_logins (
username varchar(64) NOT NULL,
series varchar(64) NOT NULL,
token varchar(64) NOT NULL,
last_used timestamp NOT NULL
);
然后,在WebSecurityConfiguration
中加入如下代码。
......
// 如果使用hikariCP这里就无法注入DataSource
@Autowired
private DataSource dataSource;
......
// MySQL方式实现记住我
@Bean
public PersistentTokenRepository persistentTokenRepository() {
// mysql方式
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
return tokenRepository;
}
......
// 控制逻辑
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
......
// 使用Cookie实现记住我(remember-me)功能
.and().rememberMe()
// 数据库保存,这种方式在关闭服务之后仍然有效
.tokenRepository(persistentTokenRepository())
// 默认的失效时间会从用户最后一次操作开始计算过期时间,过期时间最小值就是60秒,
// 如果设置的值小于60秒,也会被更改为60秒
.tokenValiditySeconds(30 * 24 * 60 * 60)
......
}
使用Postman测试后可以看到,由于是60秒失效,因此在第一次访问60秒后,再调用同样的接口时,名称为remember-me
的Cookie
消失了,而数据库的persistent_logins
表中也多了一条用户访问记录。
另外需要注意的是失效规律
:过期时间的最小值是60秒,如果设置的值小于60秒,也会被更改为60秒,而且默认的失效时间会从用户最后一次操作开始计算过期时间。
保存到MongoDB
使用MySQL保存Cookie记录虽然方便,但是目前更多的主流互联网应用都是用NoSQL来保存的,Spring Security也可以实现。
受JdbcTokenRepositoryImpl
的启发,查看其源码,发现JdbcDaoSupport
只是用来提供数据源,无实际意义,PersistentTokenRepository
才是要实现的接口。
而JdbcTokenRepositoryImpl
的源码非常简单,看懂了就能照着写出MongoDB的实现。
首先,Docker部署MongoDB。
然后,修改pom.xml
文件,引入MongoDB的依赖。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
接着,通过模仿JdbcDaoSupport
来自定义MongoDaoSupport
。
package com.xiangwang.vmall.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
import org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken;
import org.springframework.stereotype.Component;
import java.util.Date;
import java.util.Objects;
/**
* MongoDaoSupport
*
*/
@Component
public class MongoDaoSupport<T> {
@Autowired
private MongoTemplate mongoTemplate;
// 插入数据
public boolean insert(PersistentRememberMeToken token) {
if (Objects.isNull(token)) {
return false;
}
Object object = mongoTemplate.save(token);
if (Objects.nonNull(object)) {
return true;
}
return false;
}
// 查询数据
public PersistentRememberMeToken getTokenBySeries(String series) {
// // 如果是通过字符串方式诸葛插入字段值,那么通过mongoTemplate.findOne()得到的就是一个LinkedHashMap
// LinkedHashMap<String, String> map = (LinkedHashMap<String, String>) mongoTemplate
// .findOne(query, Object.class, "collectionName");
// return new PersistentRememberMeToken(map.get("username"), map.get("series"),
// map.get("tokenValue"), DateUtils.format()map.get("date"));
Query query = new Query(Criteria.where("series").is(series));
// // 这里原路返回PersistentRememberMeToken对象,不会是LinkedHashMap
// Object object = mongoTemplate.findOne(query, PersistentRememberMeToken.class);
// return (PersistentRememberMeToken) obejct;
return mongoTemplate.findOne(query, PersistentRememberMeToken.class);
}
// 更新数据
public boolean updateToken(String series, String tokenValue, Date lastUsed) {
Query query = new Query(Criteria.where("series").is(series));
Update update = new Update();
update.set("tokenValue", tokenValue);
update.set("date", lastUsed);
// // 这里不能用DateUtils.parse(new Date()),否则getTokenBySeries()方法会抛出非法参数异常
// update.set("date", DateUtils.parse(new Date()));
Object object = mongoTemplate.updateMulti(query, update, PersistentRememberMeToken.class);
if (Objects.nonNull(object)) {
return true;
}
return false;
}
}
接下来,定义实际操作MongoDB数据的MongoTokenRepositoryImpl
。
package com.xiangwang.vmall.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.web.authentication.rememberme.PersistentRememberMeToken;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import java.util.Date;
/**
* 自定义实现token持久化到mongodb
*
*/
public class MongoTokenRepositoryImpl implements PersistentTokenRepository {
@Autowired
private MongoDaoSupport<PersistentRememberMeToken> mongoDaoSupport;
@Override
public void createNewToken(PersistentRememberMeToken token) {
mongoDaoSupport.insert(token);
}
@Override
public void updateToken(String series, String tokenValue, Date lastUsed) {
mongoDaoSupport.updateToken(series, tokenValue, lastUsed);
}
@Override
public PersistentRememberMeToken getTokenForSeries(String series) {
return mongoDaoSupport.getTokenBySeries(series);
}
@Override
public void removeUserTokens(String username) {
}
}
最后,修改WebSecurityConfiguration
中的configure()
方法,用自定义的MongoTokenRepositoryImpl
代替JdbcTokenRepositoryImpl
(记得注释掉用MySQL实现的记住我
)。
......
// NoSQL方式实现记住我
@Bean
public PersistentTokenRepository persistentTokenRepository() {
// 自定义mongo方式实现
MongoTokenRepositoryImpl mongoTokenRepository = new MongoTokenRepositoryImpl();
return mongoTokenRepository;
}
......
// 控制逻辑
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
......
// 使用Cookie实现记住我(remember-me)功能
.and().rememberMe()
// 数据库保存,这种方式在关闭服务之后仍然有效
.tokenRepository(persistentTokenRepository())
// 默认的失效时间会从用户最后一次操作开始计算过期时间,过期时间最小值就是60秒,
// 如果设置的值小于60秒,也会被更改为60秒
.tokenValiditySeconds(30 * 24 * 60 * 60)
......
}
或者也可以直接在tokenRepository()
方法中使用注入的MongoTokenRepositoryImpl
。
......
// 记住我
.and().rememberMe()
// 数据库保存,这种方式在关闭服务之后仍然有效
// 这里的mongoTokenRepository需要通过@Service注解声明并以@Autowired方式注入
.tokenRepository(mongoTokenRepository)
// 默认的失效时间会从用户最后一次操作开始计算过期时间,过期时间最小值就是60秒,
// 如果设置的值小于60秒,也会被更改为60秒
.tokenValiditySeconds(30 * 24 * 60 * 60)
......
为了消除MongoDB中多出的_class
字段,可以加上额外的配置。
package com.xiangwang.vmall.configuration;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.mongodb.core.convert.DefaultMongoTypeMapper;
import org.springframework.data.mongodb.core.convert.MappingMongoConverter;
/**
* 去除_class字段
*
*/
@Configuration
public class MongoConfiguration implements InitializingBean {
@Autowired
@Lazy
private MappingMongoConverter mappingMongoConverter;
@Override
public void afterPropertiesSet() {
mappingMongoConverter.setTypeMapper(new DefaultMongoTypeMapper(null));
}
}
用Postman测试,可以看到已经通过MongoDB实现了对Cookie
信息的存储与修改。


同一用户会有多条访问记录。
如果每次都明确执行
login
方法,那么每次都会产生不同的记录,否则只会更新同一条记录的tokenValue
和date
值。若
token
有效且未执行login
方法,那么将更新最后一次产生的记录的tokenValue和
date`值。
这说明token
条数是与login
方法执行次数一一对应的,只要token
不失效,仅更新同一条记录series
的token
值。
另外,可以通过自定义CustomPersistentRememberMeToken
来继承PersistentRememberMeToken
,PersistentRememberMeToken
的特性如下。
由于
PersistentRememberMeToken
没有setter
方法,所以只能通过mongoTemplate.save(token)
保存。如果以字符串方式保存属性值,那么
getTokenBySeries()
将返回LinkedHashMap
而不是PersistentRememberMeToken
。不能用
update.set("date", DateUtils.parse(new Date()))
,只能update.set("date", new Date())
,否则getTokenBySeries()
时会抛非法参数异常。
Session管理
因为HTTP是无状态的,所以如果用户每次都要输入用户名密码来访问应用,会很麻烦。
针对这个问题,服务端会授予客户端一个标识,当客户端第二次登录时,只要出示这个标识,就可以直接验证通过。
Session是有状态的标识,需要存储,如果有多个服务端就会有多个Session。由于Session存有完整的登录信息,可能会引起CSRF问题。
Token是无状态的标识(通过算法自包含),无需存储,多个服务端可以只有一个Token。Token存储简单、无跨域问题、适合于具体应用,每个第三方应用都可以自定义。
目前应用中使用Token的占大多数。
Springboot支持Session超时机制,可以通过配置文件修改。
server.servlet.session.timeout=60
而Spring Security则通过两种方式管理Session超时。
在类中处理。
在URL页面中处理。
首先,自定义超时处理策略。
package com.xiangwang.vmall.security;
import org.springframework.http.HttpStatus;
import org.springframework.security.web.session.InvalidSessionStrategy;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 自定义超时处理策略
*
*/
@Component
public class CustomInvalidSessionStrategy implements InvalidSessionStrategy {
@Override
public void onInvalidSessionDetected(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
System.out.println("session expired");
// 前后端分离的调用方式
response.setStatus(HttpStatus.OK.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("session expired");
}
}
修改WebSecurityConfiguration
中的configure()
方法。
......
@Autowired
private CustomInvalidSessionStrategy sessionStrategy;
......
// 控制逻辑
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
......
// Spring Security配置了两种session过期退出处理逻辑
.and().sessionManagement()
// 第一种:在类中进行处理
.invalidSessionStrategy(sessionStrategy)
// 第二种:直接跳转到url页面进行处理
// .invalidSessionUrl("/login/invalid")
......
}
通过Postman测试,结果不管是未登录,还是Session失效,都会调用Session超时策略。
感谢支持
更多内容,请移步《超级个体》。