本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com
什么是单点登录
单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。SSO 的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。SSO 一般都需要一个独立的认证中心(passport),子系统的登录均得通过 passport,子系统本身将不参与登录操作,当一个系统成功登录以后,passport 将会颁发一个令牌给各个子系统,子系统可以拿着令牌会获取各自的受保护资源,为了减少频繁认证,各个子系统在被 passport 授权以后,会建立一个局部会话,在一定时间内可以无需再次向 passport 发起认证。
举个例子,比如淘宝、天猫都属于阿里旗下的产品,当用户登录淘宝后,再打开天猫,系统便自动帮用户登录了天猫,这种现象背后就是用单点登录实现的。
单点登录流程
1. 登录
用户访问系统 1 的受保护资源,系统 1 发现用户未登录,跳转至 sso 认证中心,并将自己的地址作为参数
sso 认证中心发现用户未登录,将用户引导至登录页面
用户输入用户名密码提交登录申请
sso 认证中心校验用户信息,创建用户与 sso 认证中心之间的会话,称为全局会话,同时创建授权令牌
sso 认证中心带着令牌跳转会最初的请求地址(系统 1)
系统 1 拿到令牌,去 sso 认证中心校验令牌是否有效
sso 认证中心校验令牌,返回有效,注册系统 1
系统 1 使用该令牌创建与用户的会话,称为局部会话,返回受保护资源
用户访问系统 2 的受保护资源
系统 2 发现用户未登录,跳转至 sso 认证中心,并将自己的地址作为参数
sso 认证中心发现用户已登录,跳转回系统 2 的地址,并附上令牌
系统 2 拿到令牌,去 sso 认证中心校验令牌是否有效
sso 认证中心校验令牌,返回有效,注册系统 2
系统 2 使用该令牌创建与用户的局部会话,返回受保护资源
用户登录成功之后,会与 sso 认证中心及各个子系统建立会话,用户与 sso 认证中心建立的会话称为全局会话,用户与各个子系统建立的会话称为局部会话,局部会话建立之后,用户访问子系统受保护资源将不再通过 sso 认证中心,全局会话与局部会话有如下约束关系
局部会话存在,全局会话一定存在
全局会话存在,局部会话不一定存在
全局会话销毁,局部会话必须销毁
2. 注销
sso 认证中心一直监听全局会话的状态,一旦全局会话销毁,监听器将通知所有注册系统执行注销操作。
用户向系统 1 发起注销请求
系统 1 根据用户与系统 1 建立的会话 id 拿到令牌,向 sso 认证中心发起注销请求
sso 认证中心校验令牌有效,销毁全局会话,同时取出所有用此令牌注册的系统地址
sso 认证中心向所有注册系统发起注销请求
各注册系统接收 sso 认证中心的注销请求,销毁局部会话
sso 认证中心引导用户至登录页面
什么是 CAS
CAS 是 Central Authentication Service 的缩写,中央认证服务,一种独立开放指令协议。CAS 是 Yale 大学发起的一个开源项目,旨在为 Web 应用系统提供一种可靠的单点登录方法。CAS 包含两个部分:CAS Server 和 CAS Client。CAS Server 需要独立部署,主要负责对用户的认证工作;CAS Client 负责处理对客户端受保护资源的访问请求,需要登录时,重定向到 CAS Server。
CAS 最基本的协议过程:
CAS Client 与受保护的客户端应用部署在一起,以 Filter 方式保护受保护的资源。对于访问受保护资源的每个 Web 请求,CAS Client 会分析该请求的 Http 请求中是否包含 Service Ticket,如果没有,则说明当前用户尚未登录,于是将请求重定向到指定好的 CAS Server 登录地址,并传递 Service (也就是要访问的目的资源地址),以便登录成功过后转回该地址。用户在第 3 步中输入认证信息,如果登录成功,CAS Server 随机产生一个相当长度、唯一、不可伪造的 Service Ticket,并缓存以待将来验证,之后系统自动重定向到 Service 所在地址,并为客户端浏览器设置一个 Ticket Granted Cookie(TGC),CAS Client 在拿到 Service 和新产生的 Ticket 过后,在第 5,6 步中与 CAS Server 进行身份核实,以确保 Service Ticket 的合法性。在该协议中,所有与 CAS 的交互均采用 SSL 协议,确保,ST 和 TGC 的安全性。协议工作过程中会有 2 次重定向的过程,但是 CAS Client 与 CAS Server 之间进行 Ticket 验证的过程对于用户是透明的。另外,CAS 协议中还提供了 Proxy (代理)模式,以适应更加高级、复杂的应用场景,具体介绍可以参考 CAS 官方网站上的相关文档。
什么是 OAuth2
OAuth(开放授权)是一个开放标准,允许用户让第三方应用访问该用户在某一网站上存储的私密的资源(如照片,视频,联系人列表),而无需将用户名和密码提供给第三方应用。通俗说,OAuth 就是一种授权的协议,只要授权方和被授权方遵守这个协议去写代码提供服务,那双方就是实现了 OAuth 模式。详细说就是,OAuth 在 "客户端" 与 "服务提供商" 之间,设置了一个授权层(authorization layer)。"客户端" 不能直接登录 "服务提供商",只能登录授权层,以此将用户与客户端区分开来。"客户端" 登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。"客户端" 登录授权层以后,"服务提供商" 根据令牌的权限范围和有效期,向 "客户端" 开放用户储存的资料。OAuth2 是 OAuth1.0 的下一个版本,OAuth2 关注客户端开发者的简易性,同时为 Web 应用,桌面应用和手机,和起居室设备提供专门的认证流程。原先的 OAuth,会发行一个 有效期非常长的 token(典型的是一年有效期或者无有效期限制),在 OAuth 2.0 中,server 将发行一个短有效期的 access token 和长生命期的 refresh token。这将允许客户端无需用户再次操作而获取一个新的 access token,并且也限制了 access token 的有效期。
CAS 和 OAuth2 区别
CAS 的单点登录时保障客户端的用户资源的安全, OAuth2 则是保障服务端的用户资源的安全;
CAS 客户端要获取的最终信息是,这个用户到底有没有权限访问我(CAS 客户端)的资源; oauth2 获取的最终信息是,我(oauth2 服务提供方)的用户的资源到底能不能让你(oauth2 的客户端)访问;
CAS 的单点登录,资源都在客户端这边,不在 CAS 的服务器那一方。用户在给 CAS 服务端提供了用户名密码后,作为 CAS 客户端并不知道这件事。随便给客户端个 ST,那么客户端是不能确定这个 ST 是用户伪造还是真的有效,所以要拿着这个 ST 去服务端再问一下,这个用户给我的是有效的 ST 还是无效的 ST,是有效的我才能让这个用户访问。
OAuth2 认证,资源都在 OAuth2 服务提供者那一方,客户端是想索取用户的资源。所以在最安全的模式下,用户授权之后,服务端并不能直接返回 token,通过重定向送给客户端,因为这个 token 有可能被黑客截获,如果黑客截获了这个 token,那用户的资源也就暴露在这个黑客之下了。于是聪明的服务端发送了一个认证 code 给客户端(通过重定向),客户端在后台,通过 https 的方式,用这个 code,以及另一串客户端和服务端预先商量好的密码,才能获取到 token 和刷新 token,这个过程是非常安全的。如果黑客截获了 code,他没有那串预先商量好的密码,他也是无法获取 token 的。这样 oauth2 就能保证请求资源这件事,是用户同意的,客户端也是被认可的,可以放心的把资源发给这个客户端了。
CAS 登录和 OAuth2 在流程上的最大区别就是,通过 ST 或者 code 去认证的时候,需不需要预先商量好的密码。
总结:
CAS:授权服务器,被授权客户端
授权服务器(一个)保存了全局的一份 session,客户端(多个)各自保存自己的 session;
客户端登录时判断自己的 session 是否已登录,若未登录,则(告诉浏览器)重定向到授权服务器(参数带上自己的地址,用于回调);
授权服务器判断全局的 session 是否已登录,若未登录则定向到登录页面,提示用户登录,登录成功后,授权服务器重定向到客户端(参数带上 ticket【一个凭证号】);
客户端收到 ticket 后,请求服务器获取用户信息;
服务器同意客户端授权后,服务端保存用户信息至全局 session,客户端将用户保存至本地 session
OAuth2: 主系统,授权系统(给主系统授权用的,也可以跟主系统是同一个系统),第三方系统
第三方系统需要使用主系统的资源,第三方重定向到授权系统;
根据不同的授权方式,授权系统提示用户授权;
用户授权后,授权系统返回一个授权凭证(accessToken)给第三方系统【accessToken 是有有效期的】;
第三方使用 accessToken 访问主系统资源【accessToken 失效后,第三方需重新请求授权系统,以获取新的 accessToken】。
什么是 JWT
JSON Web Token(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑且独立的方式,可以在各方之间作为 JSON 对象安全地传输信息。此信息可以通过数字签名进行验证和信任。JWT 可以使用秘密(使用 HMAC 算法)或使用 RSA 或 ECDSA 的公钥 / 私钥对进行签名。JSON WEB 令牌结构由三部分组成:
Header(头部):包括令牌的类型及正在使用的散列算法。
Payload(负载):声明是关于实体(通常是用户)和其他数据的声明。索赔有三种类型:标准注册声明,公共的声明和私有的声明。
Signature(签名):必须采用编码标头,编码的有效负载,秘密,标头中指定的算法,并对其进行签名。
- 负载-标准的声明:
iss:JWT 签发者
sub:JWT 所面向的用户
aud:接收 JWT 的一方
exp:JWT 的过期时间,这个过期时间必须要大于签发时间,这是一个秒数
nbf:定义在什么时间之前,该 JWT 都是不可用的
iat:JWT 的签发时间
负载-公共的声明:可以添加任何信息,一般添加用户的相关信息或其他业务需要的必要信息,但不建议添加敏感信息,因为该部分在客户端可解密。
负载-私有声明:提供者和消费者所共同定义的声明,一般不建议存放敏感信息,因为 base64 是对称解密的,意味着该部分信息可以归类为明文信息。
创建签名需要使用编码后的
header和payload以及一个秘钥,使用header中指定签名算法进行签名。例如如果希望使用HMAC SHA256算法,那么签名应该使用下列方式创建HMACSHA256(base64UrlEncode(header)+"."+base64UrlEncode(payload), secret)签名用于验证消息的发送者以及消息是没有经过篡改的。完整的 JWT 格式输出是以.分隔的三段 Base64 编码, 密钥 secret 是保存在服务端的,服务端会根据这个密钥进行生成token和验证,所以需要保护好,更多信息请移步官网
单点登录关于前端的部分
此代码采用 OAuth2。关于token存储问题,参考了网上许多教程,大部分都是将token存储在cookie中,然后将cookie设为顶级域来解决跨域问题,但我司业务需求是某些产品顶级域也各不相同。故实现思路是将token存储在localStorage中,然后通过 H5 的新属性postMessage来实现跨域共享,对跨域不了解的可以看我这篇文章。实现思路:当用户访问公司某系统 (如 product.html) 时,在 product 中会首先加载一个 iframe,iframe 中可以获取存储在 localStorage 中的 token,如果没有取到或 token 过期,iframe 中内部将把用户将重定向到登录页,用户在此页面登录,仍将去认证系统取得 token 并保存在 iframe 页面的 localStorage
<!--product.html--><head> <script src="auth_1.0.0.js"></script></head><body> <h2>产品页面</h2> <a onClick="login()" id="login">登录</a> <h3 id="txt"></h3></body><script>var opts = { origin: 'http://localhost:8080', login_path: '/login.html', path: '/cross_domain.html'}// 加载iframe,将src值为cross_domain.html的iframe加载到本页var auth = new ssoAuth(opts);function getTokenCallback(data) { //如果没有token则跳到登录页 if(!data.value){ auth.doWebLogin(); } //如果有token,直接在页面显示,然后做其它操作 document.getElementById('txt').innerText = 'token=' + data.value;}// 获取存储在名为cross_domain的iframe中的tokenauth.getToken(getTokenCallback);</script>讲解:在 product.html 中实例化了 ssoAuth 后,此页面便将 iframe 引入了当前页,名为 opts.path 的值,即 cross_domain.html。auth.getToken() 是获取此 iframe 页面中的 localStorage 值。
//auth_1.0.0.jsfunction ssoAuth(opts) { this._origin = opts.origin, this._iframe_path = opts.path, this._iframe = null, this._iframe_ready = false, this._queue = [], this._auth = {}, this._access_token_msg = { type: "get", key: "access_token" }, this._callback = undefined, that = this; //判断是否支持postMessage及localStorage var supported = (function () { try { return window.postMessage && window.JSON && 'localStorage' in window && window['localStorage'] !== null; } catch (e) { return false; } })(); _iframeLoaded = function () { that._iframe_ready = true if (that._queue.length) { for (var i = 0, len = that._queue.length; i < len; i++) { _sendMessage(that._queue[i]); } that._queue = []; } } _sendMessage = function (data) { // 通过contentWindow属性,脚本可以访问iframe元素所包含的HTML页面的window对象。 that._iframe.contentWindow.postMessage(JSON.stringify(data), that._origin); } //获取token,但因为此时iframe还没有加载完成,先将消息存储在队列_queue中 this._auth.getToken = function (callback) { that._callback = callback if (that._access_token_msg && that._iframe_ready) { //当iframe加载完成,给iframe所在的页面发送消息 _sendMessage(that._access_token_msg); } else { that._queue.push(that._access_token_msg); } } var _handleMessage = function (event) { if (event.origin === that._origin) { var data = JSON.parse(event.data); if (data.error) { console.error(event.data) that._callback({ value: null }); return; } if (that._callback && typeof that._callback === 'function') { that._callback(data); } else { console.error("callback is null or not a function, please "); } } } this._auth.doWebLogin = function () { window.location.href = opts.origin + opts.login_path + "?redirect_url=" + window.location.href } //初始化了一个iframe,并追加到父页面的底部 if (!this._iframe && supported) { this._iframe = document.createElement("iframe"); this._iframe.style.cssText = "position:absolute;width:1px;height:1px;left:-9999px;"; document.body.appendChild(this._iframe); if (window.addEventListener) { this._iframe.addEventListener("load", function () { _iframeLoaded(); }, false); window.addEventListener("message", function (event) { _handleMessage(event) }, false); } else if (this._iframe.attachEvent) { this._iframe.attachEvent("onload", function () { _iframeLoaded(); }, false); window.attachEvent("onmessage", function (event) { _handleMessage(event) }); } this._iframe.src = this._origin + this._iframe_path; } return this._auth;}<!--cross_domain.html--><script type="text/javascript"> (function () { //白名单 var whitelist = ["localhost", "127.0.0.1", "^.*\.domain\.com"]; function verifyOrigin(origin) { var domain = origin.replace(/^https?:\/\/|:\d{1,4}$/g, "").toLowerCase(), i = 0, len = whitelist.length; while (i < len) { if (domain.match(new RegExp(whitelist[i]))) { return true; } i++; } return false; } function handleRequest(event) { // 白名单较验 if (verifyOrigin(event.origin)) { var request = JSON.parse(event.data); if (request.type == 'get') { var idi = localStorage.getItem("idi"); if (!idi) { // source:对发送消息的窗口对象的引用,event.source只是window对象的代理,不能通过它访问window//的其它信息 event.source.postMessage(JSON.stringify({ key: request.key, value: null }), event.origin); return; } value = JSON.parse(idi)[request.key]; event.source.postMessage(JSON.stringify({ key: request.key, value: value }), event.origin); } else { event.source.postMessage(JSON.stringify({ error: "Not supported", error_description: "Not supported message type" }), event.origin); } } } // 接收iframe传来的消息 if (window.addEventListener) { window.addEventListener("message", handleRequest, false); } else if (window.attachEvent) { window.attachEvent("onmessage", handleRequest); } })();</script><!--login.html--><head> <script src="auth_1.0.0.js"></script></head><body> <form> <input type="text" placeholder="用户名" id="user"> <input type="password" placeholder="密码" id="pwd"> </form> <button onClick="login()">登 录</button></body><script> function login() { var name = document.getElementById('user') var pwd = document.getElementById('pwd') var expires_in = 7200 //假如这是登录成功后,后台开发人员返回的json数据 var res = { access_token: "xxxxx.yyyyy.zzzzz", expires_at: expires_in * 1000 + new Date().getTime(), refresh_token: "yyyyyyyyyyyyyyyyyyyyyyyyyyyy" }; localStorage.setItem("idi", JSON.stringify(res)) //登录成功后再返回原页面 window.location.href = getQueryString("redirect_url") } function getQueryString(name) { var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)"); var r = window.location.search.substr(1).match(reg); if (r != null) return unescape(r[2]); return null; }</script>PS: 注销暂时没做。另外 postMessage 有兼容性问题,如果其它小伙伴有更好的方法,望分享一下,谢谢~
本文作者:An_an
5. Webpack4 入门(上)|| Webpack4 入门(下)
6. MobX 入门(上) || MobX 入门(下)
- 120+ 篇原创系列汇总
回复 “加群” 与大佬们一起交流学习~
点击 “阅读原文” 查看 120+ 篇原创文章