标题
- Cookie + Session登录
- Token登录
-
- 生成token
- refresh token
- session 和 token
- SSO单点登录
-
- SSO 单点登录退出
- OAuth第三方登录
-
- 手机APP扫码登陆pc端
-
- 二维码
- app认证机制
- pc端获取二维码状态
- 流程
- 实现登录记住账号密码功能
Cookie + Session登录 HTTP 是一种无状态的协议,客户端每次发送请求时,首先要和服务器端建立一个连接,在请求完成后又会断开这个连接。这种方式可以节省传输时占用的连接资源,但同时也存在一个问题:每次请求都是独立的,服务器端无法判断本次请求和上一次请求是否来自同一个用户,进而也就无法判断用户的登录状态。
用户首次登录时:
文章图片
- 用户访问 a.com/pageA,并输入密码登录。
- 服务器验证密码无误后,会创建 SessionId,并将它保存起来。服务器端的SessionId可能存放在很多地方,例如:内存、文件、数据库等。
- 服务器端响应这个 HTTP 请求,并通过 Set-Cookie 头信息,将 SessionId 写入 Cookie 中。
文章图片
- 用户访问 a.com/pageB 页面时,会自动带上第一次登录时写入的 Cookie。
- 服务器端比对 Cookie 中的 SessionId 和保存在服务器端的 SessionId 是否一致。如果一致,则身份验证成功。
- 从「存储」角度,把session集中存储。如果我们用独立的Redis或普通数据库,就可以把session都存到一个库里。【推荐】
- 从「分布」角度,让相同IP的请求在负载均衡时都打到同一台机器上。以nginx为例,可以配置ip_hash来实现。这种方式相当于阉割了负载均衡,且仍没有解决「用户请求的机器宕机」的问题。
- 由于服务器端需要对接大量的客户端,也就需要存放大量的SessionId,这样会导致服务器压力过大。
- 如果服务器端是一个集群,为了同步登录态,需要将SessionId同步到每一台机器上,无形中增加了服务器端维护成本。
- 由于SessionId存放在Cookie 中,所以无法避免 CSRF 攻击。
- Redis(推荐):内存型数据库,redis中文官方网站。以key-value的形式存,正合sessionId-sessionData的场景;且访问快。
- 内存:直接放到变量里。一旦服务重启就没了
- 数据库:普通数据库。性能不高。
Token是服务端生成的一串字符串,以作为客户端请求的一个令牌。当第一次登录后,服务器会生成一个Token并返回给客户端,客户端后续访问时,只需带上这个Token即可完成身份认证。
用户首次登录时:
文章图片
- 用户输入账号密码,并点击登录。
- 服务器端验证账号密码无误,创建Token。
- 服务器端将Token返回给客户端,由客户端自由保存。
文章图片
- 用户访问a.com/pageB 时,带上第一次登录时获取的Token。
- 服务器端验证Token ,有效则身份验证成功。
- 服务器端不需要存放Token,所以不会对服务器端造成压力,即使是服务器集群,也不需要增加维护成本。
- Token可以存放在前端任何地方,可以不用保存在Cookie中,提升了页面的安全性。
- Token下发之后,只要在生效时间之内,就一直有效,如果服务器端想收回此Token的权限,并不容易。
其实Token是通过多种算法拼接组合而成的字符串
JWT算法主要分为3个部分:header(头信息),playload(消息体),signature(签名)。
- header部分指定了该JWT使用的签名算法:
header = '{"alg":"HS256","typ":"JWT"}' // HS256表示使用HMAC-SHA256来生成签名。
- playload部分表明了JWT的意图:
payload = '{"loggedInAs":"admin","iat":1422779638}' //iat 表示令牌生成的时间
- signature部分为JWT的签名,主要为了让JWT不能被随意篡改,签名的方法分为两个步骤:
(1)输入base64url编码的header 部分、 . 、base64url编码的playload 部分,输出 unsignedToken。
(2)输入服务器端私钥、unsignedToken,输出signature签名。
const base64Header = encodeBase64(header)
const base64Payload = encodeBase64(payload)
const unsignedToken = `${base64Header}.${base64Payload}`
const key = '服务器私钥'
signature = HMAC(key, unsignedToken)
最后的 Token 计算如下:
const base64Header = encodeBase64(header)
const base64Payload = encodeBase64(payload)
const base64Signature = encodeBase64(signature)token = `${base64Header}.${base64Payload}.${base64Signature}`
服务器在判断Token时:
const [base64Header, base64Payload, base64Signature] = token.split('.')const signature1 = decodeBase64(base64Signature)
const unsignedToken = `${base64Header}.${base64Payload}`
const signature2 = HMAC('服务器私钥', unsignedToken)if(signature1 === signature2) {
return '签名验证成功,token 没有被篡改'
}const payload =decodeBase64(base64Payload)
if(new Date() - payload.iat < 'token 有效期'){
return 'token 有效'
}
refresh token 业务接口用来鉴权的token,我们称之为access token。越是权限敏感的业务,我们越希望access token有效期足够短,以避免被盗用。但过短的有效期会造成access token经常过期,过期后怎么办呢?
- 一种办法是,让用户重新登录获取新token,显然不够友好,要知道有的access token过期时间可能只有几分钟。
- 另外一种办法是,再来一个专门生成access token的token,我们称为refresh token。
refresh token用来获取access token,有效期可以长一些,通过独立服务和严格的请求方式增加安全性;由于不常验证,也可以如前面的session一样处理
有了refresh token后,几种情况的请求流程变成这样:
文章图片
如果 refresh token 也过期了,就只能重新登录了。
session 和 token 狭义上,我们通常认为session是「种在cookie上、数据存在服务端」的认证方案,token 是「客户端存哪都行、数据存在token里」的认证方案。对 session 和 token 的对比本质上是「客户端存 cookie/存别地儿」、「服务端存数据/不存数据」的对比。
- 客户端存cookie/存别地儿
(1)存cookie固然方便不操心,但问题也很明显:在浏览器端,可以用cookie(实际上token就常用cookie),但出了浏览器端,没有cookie怎么办?另外,cookie是浏览器在域下自动携带的,这就容易引发CSRF攻击
(2)存别的地方,可以解决没有cookie的场景;通过参数等方式手动带,可以避免CSRF攻击。 - 服务端存数据/不存数据
(1)存数据:请求只需携带id,可以大幅缩短认证字符串长度,减小请求体积
(2)不存数据:不需要服务端整套的解决方案和分布式处理,降低硬件成本;避免查库带来的验证延迟
用户首次访问时,需要在认证中心登录:
文章图片
- 用户访问网站a.com下的pageA页面。
- 由于没有登录,则会重定向到认证中心,并带上回调地址
www.sso.com?return_uri=a.com/pageA
,以便登录后直接进入对应页面。 - 用户在认证中心输入账号密码,提交登录。
- 认证中心验证账号密码有效,然后重定向到
a.com?ticket=123
带上授权码ticket,并将认证中心sso.com的登录态写入Cookie。在a.com服务器中,拿着ticket向认证中心确认,授权码ticket真实有效。验证成功后,服务器将登录信息写入Cookie(此时客户端有2个Cookie分别存有a.com和sso.com的登录态)。
文章图片
这个时候,由于a.com存在已登录的Cookie信息,所以服务器端直接认证成功。
如果认证中心登录完成之后,访问b.com下的页面:
文章图片
这个时候,由于认证中心存在之前登录过的Cookie,所以也不用再次输入账号密码,直接返回第4步,下发ticket给b.com即可。
SSO 单点登录退出 目前我们已经完成了单点登录,在同一套认证中心的管理下,多个产品可以共享登录态。现在我们需要考虑退出了,即:在一个产品中退出了登录,怎么让其他的产品也都退出登录?回过头来看第 5 步,每一个产品在向认证中心验证ticket时,其实可以顺带将自己的退出登录 api 发送到认证中心。
当某个产品c.com退出登录时:
- 清空 c.com 中的登录态 Cookie。
- 请求认证中心sso.com中的退出api。
- 认证中心遍历下发过ticket的所有产品,并调用对应的退出api,完成退出。
文章图片
以微信开放平台的接入流程为例:
文章图片
- 首先,a.com 的运营者需要在微信开放平台注册账号,并向微信申请使用微信登录功能。
- 申请成功后,得到申请的appid、appsecret。
- 用户在a.com上选择使用微信登录。
- 这时会跳转微信的OAuth授权登录,并带上a.com的回调地址。
- 用户输入微信账号和密码,登录成功后,需要选择具体的授权范围,如:授权用户的头像、昵称等。
- 授权之后,微信会根据拉起a.com?code=123,这时带上了一个临时票据code。
- 获取code之后,a.com会拿着code、appid、appsecret,向微信服务器申请token,验证成功后,微信会下发一个token。有了token之后,a.com就可以凭借token拿到对应的微信用户头像,用户昵称等信息了。
- a.com 提示用户登录成功,并将登录状态写入Cooke,以作为后续访问的凭证。
二维码
二维码的内容不止可以存数字,还可以存任何的字符串。我们可以认为,它就是字符的另外一种表现形式。
手机扫码这个过程,其实是对二维码的解码,获取二维码中包含的数据。
二维码包含什么:服务端必须给数据生成惟一的标识作为二维码ID,同时还应该设置二维码过期的时间。服务端也应该保存二维码的一些状态:未扫描、已成功、已失效。PC端根据二维码ID等数据生成二维码。
文章图片
app认证机制
我们发现,只有装载APP,第一次登录的时候,才需要进行基于账号密码的登录,之后即使清理掉这个应用进程,甚至手机重启,都是不需要再次输入账号密码的,它可以自动登录。手机端不存储登录密码,他有一套基于token的认证机制。
文章图片
- APP登录认证的时候除了账号密码,还有设备信息
- 账号密码校验通过,服务端会把账号与设备进行一个绑定,进行持久化的保存,包含了账号ID,设备ID,设备类型等等。因为移动端的设备具备唯一性,可以为每个客户端生成专属token,这个token也不用过期,所以这就是我们可以一次登录,长久使用的原理。
这个token其实就是一串有着特殊意义的字符串,它的意义就在于,通过它可以找到对应的账号与设备信息
- APP每次请求除了携带token key,还需要携带设备信息。
- 服务端通过token找到与它绑定的账号与设备信息,然后把绑定的设备信息与客户端每次传来的设备信息进行比较, 如果相同,那么校验通过,返回API接口响应数据, 如果不同,那就是校验不通过拒绝访问
可能有些同学会想,这个token这么重要,万一被别人知道了怎么办。实际上,知道了也没有影响, 因为设备信息是唯一的,只要你的设备信息别人不知道, 别人拿其他设备来访问,验证也是不通过的。可以说,客户端登录的目的,就是获得属于自己的token。
pc端获取二维码状态
轮询:客户端定时向服务器发送Ajax请求,服务器接到请求后马上返回响应信息并关闭连接。
优点:后端程序编写比较容易。
缺点:请求中有大半是无用,浪费带宽和服务器资源。
实例:适于小型应用。
文章图片
长轮询:客户端向服务器发送Ajax请求,服务器接到请求后hold住连接,直到有新消息才返回响应信息并关闭连接,客户端处理完响应信息后再向服务器发送新的请求。
优点:在无消息的情况下不会频繁的请求,耗费资源小。
缺点:服务器hold连接会消耗资源,返回数据顺序无保证,难于管理维护。
实例:WebQQ、Hi网页版、Facebook IM。
文章图片
传统的轮询一般是由C向S询问:“有我的信件吗?”。S接到询问之后,会立即查询,并且把查询结果告诉C,不管有没有C的信件,要么回复:“嗯,你有X封信。”,要么回复:“没,没有你的信”。而长轮询更像是这样,C向S发出询问:“有我的信件吗?”,S开始查询,如果有则回复C:“嗯,有你x封信”。如果没有,则不作任何回复,而是让C等着,自己一遍一遍地查询是否有订阅者的信。换句话说:当S收到C的查询请求之后,轮询则只查询一次,并且把查询结果告诉C;而长轮询收到请求之后,则会一遍一遍地查询,直到有消息才会响应C,不然将此连接挂起。流程
文章图片
- 访问PC端二维码生成页面,PC端请求服务端获取二维码ID(uuid)
- 服务端生成相应的二维码ID,设置二维码的过期时间,状态等,保存在redis中。
- PC获取二维码ID,生成相应的二维码。
- 手机端扫描二维码,获取二维码ID。
- 手机端将手机端token、设备信息和二维码ID发送给服务端,确认登录。
- 服务端校验手机端token,根据手机端token和二维码ID生成PC端token
- PC端通过轮询方式请求服务端,通过二维码ID获取二维码状态,如果已成功,返回PC token,登录成功。
二、存储账号密码的方法:
localStorage
1). 除非主动清除localStorage里的信息,否则将永远存在,关闭浏览器窗口后下次启动任然存在
2). 存放数据大小一般为5MB
3). 不与服务器进行交互通信cookies
1). 可以手动设置过期时间,超过有效期则失效。未设置过期时间,关闭浏览器窗口后就被清除了
2). 存放数据大小一般为4K
3). 每次请求都会被传送到服务器- sessionStorage(不推荐)
1). 仅在当前会话下有效,关闭浏览器窗口后就被清除了
2). 存放数据大小一般为5MB
3). 不与服务器进行交互通信
记住我 忘记密码?
登录
四、加密算法选用base64
//安装
npm install --save js-base64
//引入
const Base64 = require("js-base64").Base64
五、localStorage实现
export default {
data() {
return {
loginForm: {
userId: "",
password: "",
},
checked: false,
};
},
mounted() {
let username = localStorage.getItem("userId");
if (username) {
this.loginForm.userId = localStorage.getItem("userId");
this.loginForm.password = Base64.decode(localStorage.getItem("password"));
// base64解密
this.checked = true;
}
},
methods: {
submitForm(formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
/* ------ 账号密码的存储 ------ */
if (this.checked) {
let password = Base64.encode(this.loginForm.password);
// base64加密
localStorage.setItem("userId", this.loginForm.userId);
localStorage.setItem("password", password);
} else {
localStorage.removeItem("userId");
localStorage.removeItem("password");
}
/* ------ http登录请求 ------ */
} else {
console.log("error submit!!");
return false;
}
});
},
},
};
【实践|前端登陆功能】六、cookie实现
export default {
data() {
return {
loginForm: {
userId: "",
password: "",
},
checked: false,
};
},
mounted() {
this.getCookie();
},
methods: {
submitForm(formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
/* ------ 账号密码的存储 ------ */
if (this.checked) {
let password = Base64.encode(this.loginForm.password);
// base64加密
this.setCookie(this.loginForm.userId, password, 7);
} else {
this.setCookie("", "", -1);
// 修改2值都为空,天数为负1天就好了
}
/* ------ http登录请求 ------ */
} else {
console.log("error submit!!");
return false;
}
});
},// 设置cookie
setCookie(userId, password, days) {
let date = new Date();
// 获取时间
date.setTime(date.getTime() + 24 * 60 * 60 * 1000 * days);
// 保存的天数
// 字符串拼接cookie
window.document.cookie =
"userId" + "=" + userId + ";
path=/;
expires=" + date.toGMTString();
window.document.cookie =
"password" + "=" + password + ";
path=/;
expires=" + date.toGMTString();
},// 读取cookie 将用户名和密码回显到input框中
getCookie() {
if (document.cookie.length > 0) {
let arr = document.cookie.split(";
");
//分割成一个个独立的“key=value”的形式
for (let i = 0;
i < arr.length;
i++) {
let arr2 = arr[i].split("=");
// 再次切割,arr2[0]为key值,arr2[1]为对应的value
if (arr2[0] === "userId") {
this.loginForm.userId = arr2[1];
} else if (arr2[0] === "password") {
this.loginForm.password = Base64.decode(arr2[1]);
// base64解密
this.checked = true;
}
}
}
},
},
};
推荐阅读
- 开源说|除夕用不同的语言编写绚丽的烟花
- Leetcode34在排序数组中查找元素的第一个和最后一个位置(二分法求解)
- JS学习笔记
- 三分钟学习一下JavaScript中map对象的用法
- WEB前端高级教程|web前端高级JavaScript - 关于变量提升this指向闭包作用域的一些练习题
- 前端|js vue base64 byte 转 为文件格式 (以excel为例)
- 三分钟学习一下JavaScript中set对象的用法
- vue|Vue.js响应式原理(三)——发布订阅模式和观察者模式
- vue|快速理解Vue 使用 vm.$set 解决对象新增属性不能响应的问题