基础-进阶-升级~图解SpringSecurity的RememberMe流程|源码

news2024/12/22 23:58:26

前言

今天我们来聊一下登陆页面中“记住我”这个看似简单实则复杂的小功能。

如图就是博客园登陆时的“记住我”选项,在实际开发登陆接口以前,我一直认为这个“记住我”就是把我的用户名和密码保存到浏览器的 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")

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/676506.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

关于Synchronized的小结

目录 一.特性 1.既是乐观锁又是悲观锁 2.是轻量级锁,也是重量级锁 3.不是读写锁,是互斥锁 4.是可重入锁 5.非公平锁 6.加锁之后,运行完毕自动解锁释放资源 二:Synchronized使用 第一种:定义一个额外的变量来控制加锁和解锁(类似于吉祥物) 第一种:直接给类/方法上锁 三…

【Linux】MySQL备份与恢复

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 MySQL备份与恢复 一、数据备份的重要性1.数据库备份的分类2.常见的备份方法3.MySQL完全备份与分类 二、MySQL 日志管理三、MySQL 备份与恢复1.MySQL 完全备份1.物理冷备份与恢…

Linux 服务器以及各个系统下安装

Linux 服务器 Linux是操作系统&#xff1a; Linux作为自由软件和开放源代码软件发展中最著名的例子&#xff0c;背靠社区与商业化的使用&#xff0c;使其得到了广泛的应用与发展。 Linux无处不在&#xff1a; 世界上500个最快的超级计算机90&#xff05;以上运行Linux发行版…

【MyBatis学习】动态SQL的使用,MyBatis还能这样使用?惊掉了我的下巴 ,赶快带着好奇心一起畅游动态SQL的海洋吧! ! !

前言: 大家好,我是良辰丫,今天我们来学习一下MyBatis中动态sql的使用,带着疑惑走进我们今天的学习! ! !&#x1f48c;&#x1f48c;&#x1f48c; &#x1f9d1;个人主页&#xff1a;良辰针不戳 &#x1f4d6;所属专栏&#xff1a;javaEE进阶篇之框架学习 &#x1f34e;励志语句…

短视频矩阵源码开发部署技术解析

短视频矩阵源码开发涉及到多个方面的技术&#xff0c;包括但不限于以下几点&#xff1a; 抖yinAPI&#xff1a;需要通过API获取抖音的视频、用户、评论、点赞等数据。 抖yinSDK&#xff1a;如果需要开发一些原生的功能&#xff0c;比如分享、登录、支付等&#xff0c;需要使用…

深入解析大型语言模型:从训练到部署大模型

简介 随着数据科学领域的深入发展&#xff0c;大型语言模型—这种能够处理和生成复杂自然语言的精密人工智能系统—逐渐引发了更大的关注。 LLMs是自然语言处理&#xff08;NLP&#xff09;中最令人瞩目的突破之一。这些模型有潜力彻底改变从客服到科学研究等各种行业&#x…

【每天40分钟,我们一起用50天刷完 (剑指Offer)】第四天 4/50

专注 效率 记忆 预习 笔记 复习 做题 欢迎观看我的博客&#xff0c;如有问题交流&#xff0c;欢迎评论区留言&#xff0c;一定尽快回复&#xff01;&#xff08;大家可以去看我的专栏&#xff0c;是所有文章的目录&#xff09;   文章字体风格&#xff1a; 红色文字表示&#…

Jenkins集成Sonar与Gitlab代码质量检测

前提默认 安装docker19&#xff0c;与docker-compose 安装Jenkins 1、docker-compose.yaml配置 version: 3 services: jenkins: #network_mode: "host" #镜像 image: jenkins/jenkins:2.399-jdk11 #容器名称 container_name: jenkins #启动模式 res…

探索uniapp+vue3解析markdown语法|uniapp键盘撑起

最近正在尝试使用uniappvue3开发仿制chatgpt会话功能。 如上图&#xff1a;经过测试在h5/App端/小程序端 均支持markdown语法解析&#xff0c;键盘撑起后&#xff0c;整体页面和顶部自定义导航栏不会被顶起。 uniapp markdown解析及语法高亮 使用了markdown-it和highlight.js…

深入理解深度学习——BERT(Bidirectional Encoder Representations from Transform):输入表示

分类目录&#xff1a;《深入理解深度学习》总目录 BERT在预训练阶段使用了《深入理解深度学习——BERT&#xff08;Bidirectional Encoder Representations from Transform&#xff09;&#xff1a;MLM&#xff08;Masked Language Model&#xff09;》和《深入理解深度学习——…

C++——函数重载

目录 1. 函数重载的概念 2. 函数重载注意点 3. C可以函数重载的原因 4. 总结 1. 函数重载的概念 函数重载:是函数的一种特殊情况。C语言不支持函数重载&#xff0c;而C允许在同一作用域中声明几个功能类似的同名函数&#xff0c;这些同名函数的形参列表(参数个数 或 类型 …

【软件设计师暴击考点】软件工程知识高频考点【一】

&#x1f468;‍&#x1f4bb;个人主页&#xff1a;元宇宙-秩沅 &#x1f468;‍&#x1f4bb; hallo 欢迎 点赞&#x1f44d; 收藏⭐ 留言&#x1f4dd; 加关注✅! &#x1f468;‍&#x1f4bb; 本文由 秩沅 原创 &#x1f468;‍&#x1f4bb; 收录于专栏&#xff1a;软件…

基于Springboot+vue的汽车租赁系统设计与实现

博主介绍&#xff1a; 大家好&#xff0c;我是一名在Java圈混迹十余年的程序员&#xff0c;精通Java编程语言&#xff0c;同时也熟练掌握微信小程序、Python和Android等技术&#xff0c;能够为大家提供全方位的技术支持和交流。 我擅长在JavaWeb、SSH、SSM、SpringBoot等框架…

阿里云服务器的可用区和地域选择有哪些考虑因素?如何优化性能?

阿里云服务器的可用区和地域选择有哪些考虑因素&#xff1f;如何优化性能&#xff1f;   选择阿里云服务器时&#xff0c;可用区和地域选择是一个非常关键的环节。本文将为您详细解析在这个过程中需要考虑的因素以及如何优化性能。 一、阿里云服务器的可用区和地域选择的考虑…

PHP基础知识解析:探索PHP编程的核心概念和技巧

目录 PHP简介 什么是PHP 网站基本概念 网站 静态网站特点 动态网站特点 服务器概念 IP 域名 DNS 端口 PHP基础知识 PHP语法 PHP注释 PHP语句分隔符 变量 变量基本概念 变量的使用 命名的命名规则&#xff1a; 预定义变量&#xff1a; 可变变量 变量传值 …

【Linux操作系统】共享内存

文章目录 什么是共享内存&#xff1f;共享内存的原理共享内存的知识点构建创建共享内存的前提-key值共享内存的创建ipcs命令共享内存的释放共享内存的关联与解除代码演示共享内存的大小共享内存的特点 什么是共享内存&#xff1f;共享内存的原理 共享内存让不同进程看到同一份…

C++ --引用与函高级

引用 定义&#xff1a;给变量起别名。 语法&#xff1a;数据类型&别名原名。 int a10; int &ba; //(此时的a和b都指向了同一块空间&#xff0c;都可对里面的元素进行修改) 对这两个变量名字任意一个进行修改&#xff0c;两变量的结果都会发生变化 引用注意事项 1.引…

211大学,4个计算机应届生,真实求职情况~

每年6月&#xff0c;毕业就业是一个绕不开的永恒话题&#xff01;2024届秋招即将开始&#xff0c;你们都找到工作了吗&#xff1f;是否还在犹豫、焦虑、没有头绪…… 先知先觉的那一批人&#xff0c;总会未雨绸缪&#xff0c;从前辈那里汲取经验。 播妞采访了4位211计算机应届…

vue+leaflet笔记之地图放大镜

vueleaflet笔记之地图放大镜 文章目录 vueleaflet笔记之地图放大镜开发环境使用教程安装依赖库使用简介 详细源码(Vue3) 本文介绍了Web端使用 Leaflet开发库显示地图放大镜的方法 (底图来源:中科星图)&#xff0c;结合 leaflet.magnifyingglass插件可以为Leaflet图层添加“放…

SHAP显示原始特征

1.问题描述 SHAP用于特征解释&#xff0c;对于机器学习方法往往需要对原始特征进行编码&#xff0c;而SHAP在绘制单个样本时&#xff0c;会显示每个特征及其取值&#xff0c;而这个取值已经是编码后的&#xff0c;通常无法确定其含义。如&#xff1a; 下图所示的拍卖公司、城市…