Yan-英杰的主页
悟已往之不谏 知来者之可追
C++程序员,2024届电子信息研究生
目录
前言
原理
基础版
搭建
初始化sql
依赖引入
配置类
验证
源码分析
进阶版
集成
源码分析
疑问1
疑问2
鉴权
升级版
集成
初始化sql
配置类
验证
源码分析
鉴权
流程
扩展版
前言
如图就是博客园登陆时的“记住我”选项,在实际开发登陆接口以前,我一直认为这个“记住我”就是把我的用户名和密码保存到浏览器的 cookie 中,当下次登陆时浏览器会自动显示我的用户名和密码,就不用我再次输入了。
直到我看了 Spring Security
中 Remember Me
相关的源码,我才意识到之前的理解全错了,它的作用其实是让用户在关闭浏览器之后再次访问时不需要重新登陆。
原理
如果用户勾选了 “记住我” 选项,Spring Security
将在用户登录时创建一个持久的安全令牌,并将令牌存储在 cookie 中或者数据库中。当用户关闭浏览器并再次打开时,Spring Security 可以根据该令牌自动验证用户身份。
先来张图感受下,然后跟着阿Q从简单的Spring Security
登陆样例开始慢慢搭建吧!
基础版
搭建
初始化sql
//用户表
CREATE TABLE `sys_user_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(255) DEFAULT NULL,
`password` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8
//插入用户数据
INSERT INTO sys_user_info
(id, username, password)
VALUES(1, 'cheetah', '$2a$10$N.zJIQtKLyFe62/.wL17Oue4YFXUYmbWICsMiB7c0Q.sF/yMn5i3q');
//产品表
CREATE TABLE `product_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`price` decimal(10,4) DEFAULT NULL,
`create_date` datetime DEFAULT NULL,
`update_date` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8
//插入产品数据
INSERT INTO product_info
(id, name, price, create_date, update_date)
VALUES(1, '从你的全世界路过', 32.0000, '2020-11-21 21:26:12', '2021-03-27 22:17:39');
INSERT INTO product_info
(id, name, price, create_date, update_date)
VALUES(2, '乔布斯传', 25.0000, '2020-11-21 21:26:42', '2021-03-27 22:17:42');
INSERT INTO product_info
(id, name, price, create_date, update_date)
VALUES(3, 'java开发', 87.0000, '2021-03-27 22:43:31', '2021-03-27 22:43:34');
依赖引入
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
配置类
自定义 SecurityConfig 类继承 WebSecurityConfigurerAdapter 类,并实现里边的 configure(HttpSecurity httpSecurity)
方法。
/**
* 安全认证及授权规则配置
**/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.authorizeRequests()
.anyRequest()
//除上面外的所有请求全部需要鉴权认证
.authenticated()
.and()
//登陆成功之后的跳转页面
.formLogin().defaultSuccessUrl("/productInfo/index").permitAll()
.and()
//CSRF禁用
.csrf().disable();
}
另外还需要指定认证对象的来源和密码加密方式
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userInfoService).passwordEncoder(passwordEncoder());
}
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
验证
启动程序,浏览器打开http://127.0.0.1:8080/login
输入用户名密码登陆成功
我们就可以拿着 JSESSIONID 去请求需要登陆的资源了。
源码分析
方框中的是类和方法名,方框外是类中的方法具体执行到的代码。
首先会按照图中箭头的方向来执行,最终会执行到我们自定义的实现了 UserDetailsService 接口的 UserInfoServiceImpl 类中的查询用户的方法 loadUserByUsername()
。
当认证通过之后会在SecurityContext
中设置Authentication
对象
org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter#successfulAuthentication`中的方法`SecurityContextHolder.getContext().setAuthentication(authResult);
最后调用onAuthenticationSuccess
方法跳转链接。
进阶版
集成
接下来我们就要开始进入正题了,快速接入“记住我”功能。
在配置类 SecurityConfig 的 configure() 方法中加入两行代码,如下所示
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.authorizeRequests()
.anyRequest()
//除上面外的所有请求全部需要鉴权认证
.authenticated()
.and()
//开启 rememberMe 功能
.rememberMe()
.and()
//登陆成功之后的跳转页面
.formLogin().defaultSuccessUrl("/productInfo/index").permitAll()
.and()
//CSRF禁用
.csrf().disable();
}
重启应用页面上会出现单选框“Remember me on this computer”
可以查看下页面的属性,该单选框的名字为“remember-me”
点击登陆,在 cookie 中会出现一个属性为 remember-me 的值,在以后的每次发送请求都会携带这个值到后台
然后我们直接输入http://127.0.0.1:8080/productInfo/getProductList
获取产品信息
当我们把 cookie 中的 JSESSIONID 删除之后重新获取产品信息,发现会生成一个新的 JSESSIONID。
源码分析
认证通过的流程和基础版本一致,我们着重来分析身份认证通过之后,跳转链接之前的逻辑。
疑问1
图中1处为啥是 AbstractRememberMeServices 类呢?
我们发现在项目启动时,在类 AbstractAuthenticationFilterConfigurer 的 configure() 方法中有如下代码
RememberMeServices rememberMeServices = http.getSharedObject(RememberMeServices.class);
if (rememberMeServices != null) {
this.authFilter.setRememberMeServices(rememberMeServices);
}
AbstractRememberMeServices 类型就是在此处设置完成的,是不是一目了然了?
疑问2
当代码执行到图中2和3处时
@Override
public final void loginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
if (!rememberMeRequested(request, this.parameter)) {
this.logger.debug("Remember-me login not requested.");
return;
}
onLoginSuccess(request, response, successfulAuthentication);
}
因为我们勾选了“记住我”,所以此时的值为“on”,即rememberMeRequested(request, this.parameter)
返回 true,然后加非返回 false,最后一步就是设置 cookie 的值。
鉴权
此处的讲解一定要对照着代码来看,要不然很容易错位,没有类标记的方法都属于
RememberMeAuthenticationFilter#doFilter
当直接调用http://127.0.0.1:8080/productInfo/index
接口时,会走RememberMeAuthenticationFilter#doFilter
的代码
//此处存放的是登陆的用户信息,可以理解为对应的cookie中的 JSESSIONID
if (SecurityContextHolder.getContext().getAuthentication() != null) {
this.logger.debug(LogMessage
.of(() -> "SecurityContextHolder not populated with remember-me token, as it already contained: '"
+ SecurityContextHolder.getContext().getAuthentication() + "'"));
chain.doFilter(request, response);
return;
}
因为SecurityContextHolder.getContext().getAuthentication()
中有用户信息,所以直接返回商品信息。
当删掉 JSESSIONID 后重新发起请求,发现SecurityContextHolder.getContext().getAuthentication()
为 null ,即用户未登录,会往下走Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
代码,即自动登陆的逻辑
@Override
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
//该方法的this.cookieName 的值为"remember-me",所以该处返回的是 cookie中remember-me的值
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
this.logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
this.logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
}
try {
//对rememberMeCookie进行解码:
String[] cookieTokens = decodeCookie(rememberMeCookie);
//重点:执行TokenBasedRememberMeServices#processAutoLoginCookie下的 UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]);
//就又回到我们自定义的 UserInfoServiceImpl 类中执行代码,返回user
UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
this.userDetailsChecker.check(user);
this.logger.debug("Remember-me cookie accepted");
return createSuccessfulAuthentication(request, user);
}
catch (CookieTheftException ex) {
cancelCookie(request, response);
throw ex;
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Remember-me login was valid but corresponding user not found.", ex);
}
catch (InvalidCookieException ex) {
this.logger.debug("Invalid remember-me cookie: " + ex.getMessage());
}
catch (AccountStatusException ex) {
this.logger.debug("Invalid UserDetails: " + ex.getMessage());
}
catch (RememberMeAuthenticationException ex) {
this.logger.debug(ex.getMessage());
}
cancelCookie(request, response);
return null;
}
执行完之后接着执行RememberMeAuthenticationFilter#doFilter
中的rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
当执行到ProviderManager#authenticate
中的result = provider.authenticate(authentication);
时,会走RememberMeAuthenticationProvider 中的方法返回 Authentication 对象。
SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
将登录成功信息保存到 SecurityContextHolder 对象中,然后返回商品信息。
升级版
如果记录在服务器 session 中的 token 因为服务重启而失效,就会导致前端用户明明勾选了“记住我”的功能,但是仍然提示需要登陆。
这就需要我们对 session 中的 token 做持久化处理,接下来我们就对他进行升级。
集成
初始化sql
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL COMMENT '用户名',
`series` varchar(64) NOT NULL COMMENT '主键',
`token` varchar(64) NOT NULL COMMENT 'token',
`last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后一次使用的时间',
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
配置类
//在SecurityConfig的configure方法中增加一行
.rememberMe().tokenRepository(persistentTokenRepository());
//引入依赖,注入bean
@Autowired
private DataSource dataSource;
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
return tokenRepository;
}
验证
重启项目,访问http://127.0.0.1:8080/login
之后返回数据,查看表中数据,完美。
源码分析
前边的流程和升级版是相同的,区别就在于创建 token 之后是保存到 session 中还是持久化到数据库中,接下来我们从源码分析一波。
定位到AbstractRememberMeServices#loginSuccess
中的 onLoginSuccess()
方法,实际执行的是PersistentTokenBasedRememberMeServices#onLoginSuccess
方法。
/**
* 使用新的序列号创建新的永久登录令牌,并将数据存储在
* 持久令牌存储库,并将相应的 cookie 添加到响应中。
*
*/
@Override
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
......
try {
//重点代码创建token并保存到数据库中
this.tokenRepository.createNewToken(persistentToken);
addCookie(persistentToken, request, response);
}
......
}
因为我们在配置类中定义的是JdbcTokenRepositoryImpl
,所以进入改类的createNewToken
方法。
@Override
public void createNewToken(PersistentRememberMeToken token) {
getJdbcTemplate().update(this.insertTokenSql, token.getUsername(), token.getSeries(), token.getTokenValue(),
token.getDate());
}
此时我们发现他就是做了插入数据库的操作,并且this.insertTokenSql
为
insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)
同时我们看到了熟悉的建表语句
create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, "
+ "token varchar(64) not null, last_used timestamp not null)
这样是不是就决解了上边的疑惑了呢。
执行完PersistentTokenBasedRememberMeServices#onLoginSuccess
方法之后又进入到RememberMeAuthenticationFilter#doFilter()
方法中结束。
有了持久化之后就不用担心服务重启了,接着我们重启服务,继续访问获取商品接口,成功返回商品信息。
鉴权
鉴权的逻辑也是和进阶版相似的,区别在于删除浏览器的 JSESSIONID 之后的逻辑。
定位到AbstractRememberMeServices#autoLogin
中的UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
执行的是PersistentTokenBasedRememberMeServices#processAutoLoginCookie
。
//删减版代码
@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
HttpServletResponse response) {
......
String presentedSeries = cookieTokens[0];
String presentedToken = cookieTokens[1];
PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
if (token == null) {
throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
}
if (!presentedToken.equals(token.getTokenValue())) {
this.tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(this.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");
}
PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(),
generateTokenData(), new Date());
try {
this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
addCookie(newToken, request, response);
}
......
return getUserDetailsService().loadUserByUsername(token.getUsername());
}
流程
-
通过数据库中的 series 字段找到对应的记录;
-
记录是否为空判断以及记录中的 token 是否和传入的相同;
-
记录中的 last_used 加上默认的两周后是否大于当前时间,即是否 token 失效;
-
更新该记录并将新生成的 token 放到 cookie 中;
后续的逻辑和进阶版一致。
扩展版
看到这有的小伙伴肯定会问了,如果我不用默认的登录页面,想用自己的登录页需要注意些什么呢?
首先要注意的就是“记住我”勾选框参数名必须为“remember-me”。如果你想自定义的话也是可以的,需要将自定义的名字例如:remember-me-new
配置到配置类中。
.rememberMe().rememberMeParameter("remember-me-new")
token 的有效期也是可以自定义的,例如设置有效期为2天
.rememberMe().tokenValiditySeconds(2*24*60*60)
我们还可以自定义保存在浏览器中的 cookie 的名称
.rememberMe().rememberMeCookieName("remember-me-cookie")
文末送书
内容简介
本书是一本全面介绍在PyTorch环境下学习机器学习和深度学习的综合指南,可以作为初学者的入门教程,也可以作为读者开发机器学习项目时的参考书。
本书讲解清晰、示例生动,深入介绍了机器学习方法的基础知识,不仅提供了构建机器学习模型的说明,而且提供了构建机器学习模型和解决实际问题的基本准则。本书添加了基于PyTorch的深度学习内容,介绍了新版Scikit-Learn。本书涵盖了多种用于文本和图像分类的机器学习与深度学习方法,介绍了用于生成新数据的生成对抗网络(GAN)和用于训练智能体的强化学习。最后,本书还介绍了深度学习的新动态,包括图神经网络和用于自然语言处理(NLP)的大型transformer。无论是机器学习入门新手,还是计划跟踪机器学习进展的研发人员,都可以将本书作为使用Python进行机器学习的不二之选。
作者简介
塞巴斯蒂安·拉施卡(Sebastian Raschka)
获密歇根州立大学博士学位,现在是威斯康星-麦迪逊大学统计学助理教授,从事机器学习和深度学习研究。他的研究方向是数据受限的小样本学习和构建预测有序目标值的深度神经网络。他还是一位开源贡献者,担任Grid.ai的首席AI教育家,热衷于传播机器学习和AI领域知识。
刘玉溪(海登)[ Yuxi (Hayden) Liu ]
在谷歌公司担任机器学习软件工程师,曾担任机器学习科学家。他是一系列机器学习书籍的作者。他的第一本书Python Machine Learning By Example在2017年和2018年亚马逊同类产品中排名第一,已被翻译成多种语言。
瓦希德·米尔贾利利(Vahid Mirjalili)
获密歇根州立大学机械工程和计算机科学双博士学位,是一名专注于计算机视觉和深度学习的科研工作者。
作者Sebastian Raschka很擅长用易于理解的方式解释复杂的方法和概念。随着深度学习革命深入到各个领域,Sebastian Raschka和他的团队不断升级、完善书的内容,陆续出版了第2版和第3版。本书在前3个版本的基础上新增了某些章节,包含了PyTorch相关的内容,覆盖了Transformer和图神经网络。这些是目前深度学习领域的前沿方法,在过去两年中席卷了文本理解和分子结构等领域。
作者拥有专业知识和解决实际问题的经验,因此出色地平衡了书中的理论知识和动手实践内容。Sebastian Raschka和Vahid Mirjalili在计算机视觉和计算生物学领域拥有丰富的科研经验。Yuxi Liu擅长解决机器学习领域的实际问题,例如将机器学习方法用于事件预测、推荐系统等。本书的作者都对教育有着满腔热忱,他们用浅显易懂的语言编写了本书以满足读者的需求。