实践|前端登陆功能


标题

  • Cookie + Session登录
  • Token登录
    • 生成token
    • refresh token
    • session 和 token
  • SSO单点登录
    • SSO 单点登录退出
  • OAuth第三方登录
    • 手机APP扫码登陆pc端
      • 二维码
      • app认证机制
      • pc端获取二维码状态
      • 流程
  • 实现登录记住账号密码功能

Cookie + Session登录 HTTP 是一种无状态的协议,客户端每次发送请求时,首先要和服务器端建立一个连接,在请求完成后又会断开这个连接。这种方式可以节省传输时占用的连接资源,但同时也存在一个问题:每次请求都是独立的,服务器端无法判断本次请求和上一次请求是否来自同一个用户,进而也就无法判断用户的登录状态。
用户首次登录时:
实践|前端登陆功能
文章图片

  1. 用户访问 a.com/pageA,并输入密码登录。
  2. 服务器验证密码无误后,会创建 SessionId,并将它保存起来。服务器端的SessionId可能存放在很多地方,例如:内存、文件、数据库等。
  3. 服务器端响应这个 HTTP 请求,并通过 Set-Cookie 头信息,将 SessionId 写入 Cookie 中。
第一次登录完成之后,后续的访问就可以直接使用 Cookie 进行身份验证了:
实践|前端登陆功能
文章图片

  1. 用户访问 a.com/pageB 页面时,会自动带上第一次登录时写入的 Cookie。
  2. 服务器端比对 Cookie 中的 SessionId 和保存在服务器端的 SessionId 是否一致。如果一致,则身份验证成功。
Session 的分布式问题:通常服务端是集群,而用户请求过来会走一次负载均衡,不一定打到哪台机器上。那一旦用户后续接口请求到的机器和他登录请求的机器不一致,或者登录请求的机器宕机了,session 不就失效了吗?这个问题现在有几种解决方式。
  1. 从「存储」角度,把session集中存储。如果我们用独立的Redis或普通数据库,就可以把session都存到一个库里。【推荐】
  2. 从「分布」角度,让相同IP的请求在负载均衡时都打到同一台机器上。以nginx为例,可以配置ip_hash来实现。这种方式相当于阉割了负载均衡,且仍没有解决「用户请求的机器宕机」的问题。
Cookie + Session存在的问题:
  1. 由于服务器端需要对接大量的客户端,也就需要存放大量的SessionId,这样会导致服务器压力过大。
  2. 如果服务器端是一个集群,为了同步登录态,需要将SessionId同步到每一台机器上,无形中增加了服务器端维护成本。
  3. 由于SessionId存放在Cookie 中,所以无法避免 CSRF 攻击。
服务器端Session 的存储方式
  1. Redis(推荐):内存型数据库,redis中文官方网站。以key-value的形式存,正合sessionId-sessionData的场景;且访问快。
  2. 内存:直接放到变量里。一旦服务重启就没了
  3. 数据库:普通数据库。性能不高。
Token登录 为了解决Session + Cookie机制暴露出的诸多问题,我们可以使用Token的登录方式。
Token是服务端生成的一串字符串,以作为客户端请求的一个令牌。当第一次登录后,服务器会生成一个Token并返回给客户端,客户端后续访问时,只需带上这个Token即可完成身份认证。
用户首次登录时:
实践|前端登陆功能
文章图片

  1. 用户输入账号密码,并点击登录。
  2. 服务器端验证账号密码无误,创建Token。
  3. 服务器端将Token返回给客户端,由客户端自由保存。
后续页面访问时:
实践|前端登陆功能
文章图片

  1. 用户访问a.com/pageB 时,带上第一次登录时获取的Token。
  2. 服务器端验证Token ,有效则身份验证成功。
Token的优缺点:
  1. 服务器端不需要存放Token,所以不会对服务器端造成压力,即使是服务器集群,也不需要增加维护成本。
  2. Token可以存放在前端任何地方,可以不用保存在Cookie中,提升了页面的安全性。
  3. Token下发之后,只要在生效时间之内,就一直有效,如果服务器端想收回此Token的权限,并不容易。
生成token 最常见的生成方式是使用JWT(Json Web Token),它让通信双方之间以JSON对象的形式安全的传递信息。
其实Token是通过多种算法拼接组合而成的字符串
JWT算法主要分为3个部分:header(头信息),playload(消息体),signature(签名)。
  1. header部分指定了该JWT使用的签名算法:header = '{"alg":"HS256","typ":"JWT"}' // HS256表示使用HMAC-SHA256来生成签名。
  2. playload部分表明了JWT的意图:payload = '{"loggedInAs":"admin","iat":1422779638}' //iat 表示令牌生成的时间
  3. 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经常过期,过期后怎么办呢?
  1. 一种办法是,让用户重新登录获取新token,显然不够友好,要知道有的access token过期时间可能只有几分钟。
  2. 另外一种办法是,再来一个专门生成access token的token,我们称为refresh token。
access token用来访问业务接口,由于有效期足够短,盗用风险小,也可以使请求方式更宽松灵活
refresh token用来获取access token,有效期可以长一些,通过独立服务和严格的请求方式增加安全性;由于不常验证,也可以如前面的session一样处理
有了refresh token后,几种情况的请求流程变成这样:
实践|前端登陆功能
文章图片

如果 refresh token 也过期了,就只能重新登录了。
session 和 token 狭义上,我们通常认为session是「种在cookie上、数据存在服务端」的认证方案,token 是「客户端存哪都行、数据存在token里」的认证方案。对 session 和 token 的对比本质上是「客户端存 cookie/存别地儿」、「服务端存数据/不存数据」的对比。
  1. 客户端存cookie/存别地儿
    (1)存cookie固然方便不操心,但问题也很明显:在浏览器端,可以用cookie(实际上token就常用cookie),但出了浏览器端,没有cookie怎么办?另外,cookie是浏览器在域下自动携带的,这就容易引发CSRF攻击
    (2)存别的地方,可以解决没有cookie的场景;通过参数等方式手动带,可以避免CSRF攻击。
  2. 服务端存数据/不存数据
    (1)存数据:请求只需携带id,可以大幅缩短认证字符串长度,减小请求体积
    (2)不存数据:不需要服务端整套的解决方案和分布式处理,降低硬件成本;避免查库带来的验证延迟
SSO单点登录 单点登录指的是在公司内部搭建一个公共的认证中心,公司下的所有产品的登录都可以在认证中心里完成,一个产品在认证中心登录后,再去访问另一个产品,可以不用再次登录,即可获取登录状态。
用户首次访问时,需要在认证中心登录:
实践|前端登陆功能
文章图片

  1. 用户访问网站a.com下的pageA页面。
  2. 由于没有登录,则会重定向到认证中心,并带上回调地址www.sso.com?return_uri=a.com/pageA,以便登录后直接进入对应页面。
  3. 用户在认证中心输入账号密码,提交登录。
  4. 认证中心验证账号密码有效,然后重定向到a.com?ticket=123带上授权码ticket,并将认证中心sso.com的登录态写入Cookie。在a.com服务器中,拿着ticket向认证中心确认,授权码ticket真实有效。验证成功后,服务器将登录信息写入Cookie(此时客户端有2个Cookie分别存有a.com和sso.com的登录态)。
认证中心登录完成之后,继续访问a.com下的其他页面:
实践|前端登陆功能
文章图片

这个时候,由于a.com存在已登录的Cookie信息,所以服务器端直接认证成功。
如果认证中心登录完成之后,访问b.com下的页面:
实践|前端登陆功能
文章图片

这个时候,由于认证中心存在之前登录过的Cookie,所以也不用再次输入账号密码,直接返回第4步,下发ticket给b.com即可。
SSO 单点登录退出 目前我们已经完成了单点登录,在同一套认证中心的管理下,多个产品可以共享登录态。现在我们需要考虑退出了,即:在一个产品中退出了登录,怎么让其他的产品也都退出登录?回过头来看第 5 步,每一个产品在向认证中心验证ticket时,其实可以顺带将自己的退出登录 api 发送到认证中心。
当某个产品c.com退出登录时:
  1. 清空 c.com 中的登录态 Cookie。
  2. 请求认证中心sso.com中的退出api。
  3. 认证中心遍历下发过ticket的所有产品,并调用对应的退出api,完成退出。
OAuth第三方登录 实践|前端登陆功能
文章图片

以微信开放平台的接入流程为例:
实践|前端登陆功能
文章图片

  1. 首先,a.com 的运营者需要在微信开放平台注册账号,并向微信申请使用微信登录功能。
  2. 申请成功后,得到申请的appid、appsecret。
  3. 用户在a.com上选择使用微信登录。
  4. 这时会跳转微信的OAuth授权登录,并带上a.com的回调地址。
  5. 用户输入微信账号和密码,登录成功后,需要选择具体的授权范围,如:授权用户的头像、昵称等。
  6. 授权之后,微信会根据拉起a.com?code=123,这时带上了一个临时票据code。
  7. 获取code之后,a.com会拿着code、appid、appsecret,向微信服务器申请token,验证成功后,微信会下发一个token。有了token之后,a.com就可以凭借token拿到对应的微信用户头像,用户昵称等信息了。
  8. a.com 提示用户登录成功,并将登录状态写入Cooke,以作为后续访问的凭证。
手机APP扫码登陆pc端 登录认证,要做的也就两件事情:告诉系统我是谁、向系统证明我是谁
二维码
二维码的内容不止可以存数字,还可以存任何的字符串。我们可以认为,它就是字符的另外一种表现形式。
手机扫码这个过程,其实是对二维码的解码,获取二维码中包含的数据。
二维码包含什么:服务端必须给数据生成惟一的标识作为二维码ID,同时还应该设置二维码过期的时间。服务端也应该保存二维码的一些状态:未扫描、已成功、已失效。PC端根据二维码ID等数据生成二维码。
实践|前端登陆功能
文章图片

app认证机制
我们发现,只有装载APP,第一次登录的时候,才需要进行基于账号密码的登录,之后即使清理掉这个应用进程,甚至手机重启,都是不需要再次输入账号密码的,它可以自动登录。手机端不存储登录密码,他有一套基于token的认证机制。
实践|前端登陆功能
文章图片

  1. APP登录认证的时候除了账号密码,还有设备信息
  2. 账号密码校验通过,服务端会把账号与设备进行一个绑定,进行持久化的保存,包含了账号ID,设备ID,设备类型等等。因为移动端的设备具备唯一性,可以为每个客户端生成专属token,这个token也不用过期,所以这就是我们可以一次登录,长久使用的原理。
这个token其实就是一串有着特殊意义的字符串,它的意义就在于,通过它可以找到对应的账号与设备信息
  1. APP每次请求除了携带token key,还需要携带设备信息。
  2. 服务端通过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,不然将此连接挂起。
流程
实践|前端登陆功能
文章图片

  1. 访问PC端二维码生成页面,PC端请求服务端获取二维码ID(uuid)
  2. 服务端生成相应的二维码ID,设置二维码的过期时间,状态等,保存在redis中。
  3. PC获取二维码ID,生成相应的二维码。
  4. 手机端扫描二维码,获取二维码ID。
  5. 手机端将手机端token、设备信息和二维码ID发送给服务端,确认登录。
  6. 服务端校验手机端token,根据手机端token和二维码ID生成PC端token
  7. PC端通过轮询方式请求服务端,通过二维码ID获取二维码状态,如果已成功,返回PC token,登录成功。
实现登录记住账号密码功能 一、思路:用户登录时若勾选“记住我”功能选项,则将登录名和密码(加密后)保存至本地缓存中,下次登录页面加载时自动获取保存好的账号和密码(需解密),回显到登录输入框中。
二、存储账号密码的方法:
  1. localStorage
    1). 除非主动清除localStorage里的信息,否则将永远存在,关闭浏览器窗口后下次启动任然存在
    2). 存放数据大小一般为5MB
    3). 不与服务器进行交互通信
  2. cookies
    1). 可以手动设置过期时间,超过有效期则失效。未设置过期时间,关闭浏览器窗口后就被清除了
    2). 存放数据大小一般为4K
    3). 每次请求都会被传送到服务器
  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; } } } }, }, };

    推荐阅读