【Spring Security】的RememberMe功能流程与源码详解,基础-进阶-升级-扩展,你学会了吗?

news2024/11/25 22:23:58

文章目录

    • 前言
      • 原理
    • 基础版
      • 搭建
        • 初始化sql
        • 依赖引入
        • 配置类
        • 验证
      • 源码分析
    • 进阶版
      • 集成
      • 源码分析
        • 疑问1
        • 疑问2
      • 鉴权
    • 升级版
      • 集成
        • 初始化sql
        • 配置类
        • 验证
      • 源码分析
      • 鉴权
        • 流程
    • 扩展版

前言

之前我已经写过好几篇权限认证相关的文章了,有想复习的同学可以查看【身份权限认证合集】。今天我们来聊一下登陆页面中“记住我”这个看似简单实则复杂的小功能。

如图就是博客园登陆时的“记住我”选项,在实际开发登陆接口以前,我一直认为这个“记住我”就是把我的用户名和密码保存到浏览器的 cookie 中,当下次登陆时浏览器会自动显示我的用户名和密码,就不用我再次输入了。

在这里插入图片描述

直到我看了 Spring SecurityRemember 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();
}

【阿Q说代码】后台回复“reme”获取项目源码。

验证

启动程序,浏览器打开http://127.0.0.1:8080/login

在这里插入图片描述

输入用户名密码登陆成功

在这里插入图片描述

我们就可以拿着 JSESSIONID 去请求需要登陆的资源了。

源码分析

方框中的是类和方法名,方框外是类中的方法具体执行到的代码。

在这里插入图片描述

首先会按照图中箭头的方向来执行,最终会执行到我们自定义的实现了 UserDetailsService 接口的 UserInfoServiceImpl 类中的查询用户的方法 loadUserByUsername()

该流程如果不清楚的话记得复习《实战篇:Security+JWT组合拳 | 附源码》

当认证通过之后会在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")

后台回复“reme”获取源码吧!

本文到这里就结束了,看看我为了写这篇文章又日渐稀少的头发,我忍不住哭出声来。可能只有给我点赞,才能平复我的心情吧。

好看的皮囊千篇一律,有趣的灵魂万里挑一,让我们在冷漠的城市里相互温暖,我是阿Q,我们下期再见!

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

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

相关文章

OpenAI官方提示词课(三)如何总结文章

现在是信息爆炸时代&#xff0c;打开手机&#xff0c;各种文章扑面而来。我们的精力是有限的。如果有人帮忙把文章总结好给我们&#xff0c;这不就节省了很多时间嘛&#xff01;我们也就可以阅读更多的文章了。 恰好大语言模型在总结文章方面非常有天赋。 下面来看看示例。 …

数学基础第二天

介绍 对于Hissian矩阵是正定的&#xff0c;在这一点是整个范围内的最小值&#xff0c;y在各个方向的二阶导数都是>0的 对于Hissian矩阵是负定的&#xff0c;在这一点是整个范围内的最大值&#xff0c;y在各个方向的二阶导数都是<0的, 对于Hissian矩阵是不定的&#xff…

有了这个工具,支付宝商家多个账号下的账单管理更方便了

大家好&#xff0c;我是小悟 为方便拥有多个支付宝账号的商家获取自身业务、资金数据及下载对账单的能力&#xff0c;为商家提供了商家账单产品&#xff0c;商家可以通过该产品系统化接入账单数据&#xff0c;实现支付宝商家多个账号账单管理的功能。 为拥有多个支付宝账号的…

华为OD机试真题 JavaScript 实现【求符合要求的结对方式】【2023Q1 100分】,附详细解题思路

一、题目描述 用一个数组A代表程序员的工作能力&#xff0c;公司想通过结对编程的方式提高员工的能力&#xff0c;假设结对后的能力为两个员工的能力之和&#xff0c;求一共有多少种结对方式使结对后能力为N。 二、输入描述 6 2 3 3 4 5 1 6 第一行为员工的总人数&#xff…

centos 7 安装git并配置ssh

一、安装 1、查看是否安装git <span style"color:#333333"><span style"background-color:#ffffff"><code class"language-perl">rpm -qa|<span style"color:#0000ff">grep</span> git </code>…

【白嫖系列】永久免费域名申请教程 eu.org

&#x1f951; Welcome to Aedream同学 s blog! &#x1f951; 文章目录 eu.org注册激活注册域名解析 eu.org eu.org 一个从1996开始提供免费域名的组织, 其官网地址是 https://nic.eu.org/ 他帮助学生、爱好者或者非营利组织不用花费购买域名就可能拥有自己的免费域名&#x…

2023.6.9小记——ARM的工作模式与状态

今天打算学一点就写一点&#xff0c;不然全部堆积到晚上压力太大了&#xff0c;有些东西写不完就要睡觉了&#x1f4a4; 1. 什么是numpy&#xff1f; 1.1 numpy简介 是Python中的用于科学计算的库&#xff0c;提供高性能的多维数组对象和对应的操作函数&#xff0c;用于处理大…

微信小程序——实现蓝牙设备搜索及连接功能

✅作者简介&#xff1a;2022年博客新星 第八。热爱国学的Java后端开发者&#xff0c;修心和技术同步精进。 &#x1f34e;个人主页&#xff1a;Java Fans的博客 &#x1f34a;个人信条&#xff1a;不迁怒&#xff0c;不贰过。小知识&#xff0c;大智慧。 &#x1f49e;当前专栏…

NewBing神器,让你在Chrome轻松使用ChatGPT4智能AI

ChatGPT4 相对于 3.5 优势 相较于 ChatGPT3.5&#xff0c;ChatGPT4 有以下优势&#xff1a; 更大的模型规模&#xff1a;ChatGPT4 有超过 16 亿个参数&#xff0c;是 ChatGPT3.5 的 4 倍之多&#xff0c;这意味着它可以处理更复杂的对话场景和更长的对话历史。更好的对话质量…

通过xfsdump和xfsrestore命令实现RHEL7 xfs文件系统误删除文件的恢复

在linux系统中&#xff0c;我们有时会“不小心”误删除一些文件&#xff0c;如果是自己是测试环境服务器可能“无所谓”。但是一旦发生在客户的生产环境&#xff0c;那就是“重大安全事故”。 我们能不能提前对一些重要的文件系统进行备份&#xff0c;以便当我们真的误删除一些…

2023最新最全面Java复习路线(含P5-P8),已收录 GitHub

小编整理出一篇 Java 进阶架构师之路的核心知识&#xff0c;同时也是面试时面试官必问的知识点&#xff0c;篇章也是包括了很多知识点&#xff0c;其中包括了有基础知识、Java 集合、JVM、多线程并发、spring 原理、微服务、Netty 与 RPC 、Kafka、日记、设计模式、Java 算法、…

SQLServer2022安装(Windows),已验证

一、SQLServer2022下载 1、官网下载地址 SQL Server 下载 | Microsoft 2、下载安装包 2.1、选择Developer版本&#xff0c;立即下载。 2.2、打开下载文件夹&#xff0c;双击运行SQL2022-SSEI-Dev.exe 尝试运行SQL2022-SSEI-Dev.exe&#xff0c;会收到以下信息&#xff1a;“…

3.Hive系列之docker-compose部署升级总结

1. 版本号修改 对于升级而言&#xff0c;我们最先考虑的是docker hub中有的较新的版本&#xff0c;然后我们需要简单了解下hadoop2与hadoop3的区别&#xff0c;首先明确的是端口号有所改变&#xff0c;如下图所示 2. Hive镜像构建 刚刚我们修改了Hive为bde2020/hive:3.1.2-po…

ESPG(European Petroleum Survey Group)

数据转换器&#xff08;栅格&#xff09; &#xff0c;数据转换器&#xff08;矢量&#xff09;转换数据时经常会看到EPSG的坐标系标识。那么什么是EPSG呢&#xff1f; ESPG&#xff08;European Petroleum Survey Group&#xff09;坐标系是一种用于地球表面测量和地理信息处…

Xubuntu之将rm删除内容移至回收站(一百七十七)

简介&#xff1a; CSDN博客专家&#xff0c;专注Android/Linux系统&#xff0c;分享多mic语音方案、音视频、编解码等技术&#xff0c;与大家一起成长&#xff01; 优质专栏&#xff1a;Audio工程师进阶系列【原创干货持续更新中……】&#x1f680; 人生格言&#xff1a; 人生…

【小沐学Unity3d】Unity3d导入3D模型

文章目录 1、简介1.1 标准文件格式1.2 专有文件格式1.3 从 Autodesk 3ds Max 导入对象1.4 从 Blender 导入对象 2、测试2.1 新建项目2.2 导入fbx2.2 手动调整相机2.3 脚本控制相机 结语 1、简介 Unity 支持多种标准和专有模型文件格式。 Unity 内部使用 .fbx 文件格式作为其导…

【前端 - CSS】第 9 课 - CSS 初体验

欢迎来到博主 Apeiron 的博客&#xff0c;祝您旅程愉快 &#xff01; 时止则止&#xff0c;时行则行。动静不失其时&#xff0c;其道光明。 目录 1、CSS 定义 2、基础选择器 3、文字控制属性 4、示例代码 5、总结 1、CSS 定义 层叠样式表&#xff08;Cascading Style …

IP组播6_PIM-SM(SSM)详解

目录 1.PIM-SM&#xff08;SSM&#xff09;简介 2.PIM-SM&#xff08;ASM&#xff09;工作原理 3.PIM-SM&#xff08;SSM&#xff09;实验 3.1 PIM-SM&#xff08;SSM&#xff09;常用配置命令 3.2 PIM-SM&#xff08;ASM&#xff09;实验配置步骤 3.3 PIM-SM&#xff08…

HTMLCSS Day01 功能元素与HTTP请求协议详解

文章目录 1.功能元素1.1.列表标签- HTML中列表标签的分类 1.1.1.无序列表:- 无序列表格式:- 无序列表样式- 注意点:- 无序列表应用场景: 1.1.2.有序列表- 有序列表格式:- 有序列表样式 1.1.3.定义列表- 定义列表的格式:- 定义列表的应用场景- 定义列表的注意点 1.2.表格标签- 什…

【算法】手写题

文章目录 画一个三角形实现三栏布局通过position和margin通过float和margin通过flex实现 变量提升题实现边框0.5px深拷贝快速排序手写发布订阅/事件总线 画一个三角形 .box1 {width: 0;height: 0;border: 10px solid;border-color: red transparent transparent transparent;}实…