什么是单点登录
单点登录(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 认证中心引导用户至登录页面
单点登录关于前端的部分
此代码采用OAuth2
。关于token
存储问题,参考了网上许多教程,大部分都是将token
存储在cookie
中,然后将cookie
设为顶级域来解决跨域问题,但我司业务需求是某些产品顶级域也各不相同。故实现思路是将token
存储在localStorage
中,然后通过 H5 的新属性postMessage
来实现跨域共享
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 的有效期。
实现思路:当用户访问公司某系统(如 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中的token
auth.getToken(getTokenCallback);
</script>
讲解:在 product.html 中实例化了 ssoAuth 后,此页面便将 iframe 引入了当前页,名为 opts.path 的值,即 cross_domain.html。auth.getToken()是获取此 iframe 页面中的 localStorage 值。
//auth_1.0.0.js
function 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 = sessionStorage.getItem("idi");
if (!idi) {
// source:对发送消息的窗口对象的引用
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>