什么是 单点登录
我们通过一个例子来说明,假设有一所大学,内部有两个系统,一个是邮箱系统,一个是课表查询系统。现在想实现这样的效果:在邮箱系统中登录一遍,然后此时进入课表系统的网站,无需再次登录,课表网站系统直接跳转到个人课表页面,反之亦然。比较专业的定义如下:
单点登录 (Single Sign On),简称为 SSO ,是目前比 较流行的 企业业务整合的解决方案之一。 SSO 的定义是在多个应用系统中, 用户只需要登录一次 就可以 访问所有 相互信任的应用系统。
为什么要实现单点登录
单点登录的意义在于能够在不同的系统中统一账号、统一登录。用户不必在每个系统中都进行注册、登录,只需要使用一个统一的账号,登录一次,就可以访问所有系统。
通过 OIDC 协议实现单点登录
- 创建自己的用户目录
用户目录 这个词很贴切,你的系统的总用户表就像一本书一样,书的封皮上写着“所有用户”四个字。打开第一页,就是目录,里面列满了用户的名字,翻到对应的页码就能看到这个人的邮箱,手机号,生日信息等等。无论你开发多少个应用,要确保你有一份这些应用所有用户信息的 truth source。所有的注册、认证、注销都要到你的用户目录中进行增加、查询、删除操作。你要做的就是 创建一个中央数据表,专门用于存储用户信息 ,不论这个用户是来自 A 应用、B 应用还是 C 应用。
- 什么是 OIDC 协议
OIDC 的全称是 OpenID Connect,是一个基于 OAuth 2.0 的轻量级认证 + 授权协议,是 OAuth 2.0 的超集。它规定了其他应用,例如你开发的应用 A(XX 邮件系统),应用 B(XX 聊天系统),应用 C(XX 文档系统),如何到你的 中央数据表 中取出用户数据,约定了交互方式、安全规范等,确保了你的用户能够在访问所有应用时,只需登录一遍,而不是反反复复地输入密码,而且遵循这些规范,你的用户认证环节会很安全。
- 架设 自己的 OIDC Provider
什么是 OIDC Provider 呢?我来举一个例子:你经常见到一些网站的登录页面上有「使用 Github 登录」、「使用 Google 登录」这样的按钮。要想集成这样的功能,你 要先去 Github 那里注册一个 OAuth App,填写一些资料,然后 Github 分配给你一对 id 和 key。 此时 Github 扮演的角色就是 OIDC Provider,你要做的就是把 Github 的这种角色的行为,搬到你自己的服务器来。
在 Github 上面搜索 OIDC Provider 会有很多结果:
JS:
Golang:
Python:
…
不再一一列举,你需要选择适合你的编程语言的 OIDC Provider 包,然后让它在你的服务器上运行起来。本文使用 JS 语言的 node-oidc-provider。
示例代码 Github
可以在 Github 找到本文示例代码:
创建文件夹
我们首先创建一个文件夹,用于存放代码:
$ mkdir demo
$ cd demo
克隆仓库
然后我们将 仓库 clone 到本地
$ git clone <
安装依赖
$ cd node-oidc-provider
$ npm install
在 OIDC Provider 申请一个 Client
上一步讲到,Github 会分配给你一对 id 和 key,这一步其实就是你在 Github 申请了一个 Client。那么如何向我们自己的服务器上的 OIDC Provider 申请一对这样的 id 和 key 呢?
以 node-oidc-provider 举例,最快的获得一个 Client 的方法就是将 OIDC Client 所需的元数据直接写入 node-oidc-provider 的配置文件里面。
Wait wait wait,跨度有些大,这两者之间有什么关系?首先我们看,在 Github 上填写应用信息,然后提交,会发送一个 HTTP 请求到 Github 服务器。Github 服务器会生成一对 id 和 key,还会把它们与你的应用信息存储到 Github 自己的数据库里。所以,我们将 OIDC Client 所需的元数据直接写入到配置文件,可以理解成,我们在自己的数据库里手动插入了一条数据,为自己指定了一对 id 和 key 还有其他的一些 OIDC Client 信息。
修改配置文件
进入 node-oidc-provider 项目下的 example 文件夹:
$ cd ./example
编辑 ./support/configuration.js ,更改第 16 行的 clients 配置,我们为自己指定了一个 client_id 和一个 client_secret,其中的 grant_types 为授权模式,authorization_code 即授权码模式,redirect_uris 数组是允许的业务回调地址,需要填写 Web App 应用的地址,OIDC Provider 会将临时授权码发送到这个地址,以便后续换取 token。
module.exports = {
clients: [
{
client_id: '1',
client_secret: '1',
grant_types: ['refresh_token', 'authorization_code'],
redirect_uris: ['<#39;, '< html >'],
},
],
...
}
启动 node-oidc-provider
在 node-oidc-provider/example 文件夹下,运行以下命令来启动我们的 OP:
$ node express.js
到现在,我们的准备工作已经完成了,在讲如何在 Web App 中进行单点登录之前,我们先了解一下 OIDC 授权码模式。刚刚提到的许多术语: 授权码模式 、 业务回调地址 、 临时授权码 ,可能这些概念你会感到陌生,下文会详细介绍。
- OIDC 授权码模式
以下是 OIDC 授权码模式的交互模式,你的应用和 OP 之间要通过这样的交互方式来获取用户信息。
我们的 OIDC Provider 对外暴露一些接口
授权接口
每次调用这个接口,就像是对 OIDC Provider 喊话:我要登录,如第一步所示。
然后 OIDC Provider 会 检查当前用户在 OIDC Provider 的登录状态 ,如果是未登录状态,OIDC Provider 会弹出一个登录框,与终端用户确认身份,登录成功后会将一个 临时授权码 (一个随机字符串)发到你的应用( 业务回调地址 );如果是已登录状态,OIDC Provider 会将浏览器直接重定向到你的应用( 业务回调地址 ),并携带 临时授权码 (一个随机字符串)。如第二、三步所示。
token 接口
每次调用这个接口,就像是对 OIDC Provider 说:这是我的授权码,给我换一个 access_token。如第四、五步所示。
用户信息接口
每次调用这个接口,就像是对 OIDC Provider 说:这是我的 access_token,给我换一下用户信息。到此用户信息获取完毕。
为什么这么麻烦?直接返回用户信息不行吗?
因为安全,关于 OIDC 协议的安全性,又可以展开很大的篇幅,现在简单解释一下:code 的有效期一般只有十分钟,而且一次使用过后作废。OIDC 协议授权码模式中,只有 code 的传输经过了用户的浏览器,一旦泄露,攻击者很难抢在应用服务器拿这个 code 换 token 之前,先去 OP 使用这个 code 换掉 token。而如果 access_token 的传输经过浏览器,一般 access_token 的有效期都是一个小时左右,攻击者可以利用 access_token 获取用户的信息,而应用服务器和 OP 也很难察觉到,更不必说去手动撤退了。如果直接传输用户信息,那安全性就更低了。一句话:避免让攻击者偷走用户信息。
- 编写第一个应用
我们创建一个 app1.html 文件来编写第一个应用 demo ,在 demo/app 目录下创建:
$ touch app1.html
并写入以下内容:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>第一个应用</title>
</head>
<body>
<a href="< profile&response_type=code&state=455356436">登录</a>
</body>
</html>
- 编写第二个应用
我们创建一个 app2.html 文件来编写第二个应用 demo,注意 redirect_uri 的变化,在 demo/app 目录下创建:
$ touch app2.html
并写入以下内容:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>第二个应用</title>
</head>
<body>
<a href="< profile&response_type=code&state=455356436">登录</a>
</body>
</html>
- 向 OIDC Provider 发起登录请求
现在我们启动一个 web 服务器,推荐使用 http-server
$ npm install -g http-server # 安装 http-server
$ cd demo/app
$ http-server .
我们访问第一个应用:
然后点击「登录」,也就是访问 OIDC Provider 的 授权接口 。然后我们来到了 OIDC Provider 交互环节,OIDC Provider 发现用户没有登录,要求用户先登录。node-oidc-provider demo 会放通任意用户名 + 密码,但是你在真正实施单点登录时,你必须使用你的 用户目录 即 中央数据表中的用户数据 来鉴权用户,相关的代码可能会涉及到数据库适配器,自定义用户查询逻辑,这些在 node-oidc-provider 包的相关配置中需要自行插入。
现在点击「登录」,转到确权页面,这个页面会显示你的应用需要获取那些用户权限,本例中请求用户授权获取他的基础资料。
点击「继续」,完成在 OP 的登录,之后 OP 会将浏览器重定向到预先设置的业务回调地址,所以我们又回到了 app1.html。
在 url query 中有一个 code 参数,这个参数就是临时授权码。code 最终对应一条用户信息,接下来看我们如何获取用户信息。
- Web App 从 OIDC Provider 获取用户信息
事实上,code 可以直接发送到后端,然后在后端使用 code 换取 access_token。这里我使用 postman 演示如何通过 code 换取 access_token。
你可以使用 curl 命令来发送 HTTP 请求:
$ curl –location –request POST ‘#39;
–header ‘Content-Type: application/x-www-form-urlencoded’
–data-urlencode ‘client_id=1’
–data-urlencode ‘client_secret=1’
–data-urlencode ‘redirect_uri=#39;
–data-urlencode ‘code=QL10pBYMjVSw5B3Ir3_KdmgVPCLFOMfQHOcclKd2tj1’
–data-urlencode ‘grant_type=authorization_code’
获取到 access_token 之后,我们可以使用 access_token 访问 OP 上面的资源,主要用于获取用户信息,即 你的应用 从你的 用户目录 中读取一条用户信息。
你可以使用 curl 来发送 HTTP 请求:
$ curl –location –request POST ‘#39;
–header ‘Content-Type: application/x-www-form-urlencoded’
–data-urlencode ‘access_token=I6WB2g0Rq9G307pPVTDhN5vKuyC9eWjrGjxsO2j6jm-‘
到此,App 1 的登录已经完成,接下来,让我们看进入 App 2 是怎样的情形。
- 登录第二个 Web App
我们打开第二个应用:
然后点击「登录」。
用户已经在 App 1 登录时与 OP 建立了会话,User ←→ OP 已经是登录状态,所以 OP 检查到之后,没有再让用户输入登录凭证,而是直接将用户重定向回业务地址,并返回了授权码 code。
同样,App 2 使用 code 换 access_token
curl 命令代码:
$ curl –location –request POST ‘#39;
–header ‘Content-Type: application/x-www-form-urlencoded’
–data-urlencode ‘client_id=1’
–data-urlencode ‘client_secret=1’
–data-urlencode ‘redirect_uri=#39;
–data-urlencode ‘code=QL10pBYMjVSw5B3Ir3_KdmgVPCLFOMfQHOcclKd2tj1’
–data-urlencode ‘grant_type=authorization_code’
再使用 access_token 换用户信息,可以看到,是同一个用户。
curl 命令代码:
$ curl –location –request POST ‘#39;
–header ‘Content-Type: application/x-www-form-urlencoded’
–data-urlencode ‘access_token=I6WB2g0Rq9G307pPVTDhN5vKuyC9eWjrGjxsO2j6jm-‘
到此,我们实现了 App 1 与 App 2 之间的账号打通与单点登录。
- 登录态管理
到目前为止,看起来还不错,我们已经实现了两个应用之间账号的统一,而且在 App 1 中登录时输入一次密码,在 App 2 中登录,无需再次让用户输入密码进行登录,可以直接返回授权码到业务地址然后完成后续的用户信息获取。
现在我们来考虑一下退出问题
只退出 App 1 而不退出 App 2
这个问题实质上是 登录 态 的管理问题 。我们应该管理三个会话:User ←→ App 1、User ←→ App 2、User ←→ OP。
当 OP 给 App 1 返回 code 时,App 1 的后端在完成用户信息获取后,应该与浏览器建立会话,也就是说 App 1 与用户需要自己保持一套自己的登录状态,方式上可以通过 App 1 自签的 JWT Token 或 App 1 的 cookie-session。对于 App 2,也是同样的做法。
当用户在 App 1 退出时,App 1 只需清理掉自己的登录状态就完成了退出,而用户访问 App 2 时,仍然和 App 2 存在会话,因此用户在 App 2 是登录状态。
同时退出 App 1 和 App 2
刚才说到 单点登录 ,与之相对的就是 单点 登出 ,即用户只需退出一次,就能在所有的应用中退出,变成未登录状态。
最先想到的是这种方式,我们在 OIDC Provider 进行登出。
之后我们的状态是这样的:
好吧,其实没有任何效果,因为用户和 App 1 之间的会话依然保持,用户和 App 2 之间的会话同样依然保持,所以用户在 App 1 和 App 2 的状态仍然是登录态。
所以,有没有什么办法在用户从 OIDC Provider 登出之后,App 1 和 App 2 的会话也被切断呢?我们可以通过 OIDC Session Mangement 来解决这个问题。
简单来说,App 1 的前端需要轮询 OP,不断询问 OP:用户在你那还登录着吗?如果答案是否定的,App 1 主动将用户踢下线,并将会话释放掉,让用户重新登录,App 2 也是同样的操作。
当用户在 OP 登出后,App 1、App 2 轮询 OP 时会收到用户已经从 OP 登出的响应,接下来,应该释放掉自己的会话状态,并将用户踢出系统,重新登录。
刚刚我们提到 OIDC Session Management,这部分的核心就是两个 iframe ,一个是我们自己应用中写的(以下叫做 RP iframe),用于不断发送 PostMessage 给 OP iframe,OP iframe 负责查询用户登录状态,并返回给 RP iframe。
让我们把这部分的代码加上:
首先打开 node-oidc-provider 的 sessionManangement 功能,编辑 ./support/configuration.js 文件,在 42 行附近,进行以下修改:
...
features: {
sessionManagement: {
enabled: true,
keepHeaders: false,
},
},
...
然后和 app1.html、app2.html 平级新建一个 rp.html 文件,并加入以下内容:
<script>
var stat = 'unchanged';
var url = new URL(window.parent.location);
// 这里的 '1' 是我们的 client_id,之前在 node-oidc-provider 中填写的
var mes = '1' + ' ' + url.searchParams.get('session_state');
console. log ('mes: ')
console.log(mes)
function check_session() {
var targetOrigin = '<#39;;
var win = window.parent.document.getElementById('op').contentWindow;
win.postMessage(mes, targetOrigin);
}
function setTimer() {
check_session();
timerID = setInterval('check_session()', 3 * 1000);
}
window.addEventListener('message', receiveMessage, false);
setTimer()
function receiveMessage(e) {
console.log(e.data);
var targetOrigin = '<#39;;
if (e.origin !== targetOrigin) {
return;
}
stat = e.data;
if (stat == 'changed') {
console.log('should log out now!!');
}
}
</script>
在 app1.html 和 app2.html 的 body 标签内加入两个 iframe 标签:
<iframe src="rp.html" hidden></iframe>
<iframe src="<#34; id="op" hidden></iframe>
使用 Ctrl + C 关闭我们的 node-oidc-provider 和 http-server,然后再次启动。访问 app1.html,打开浏览器控制台,会得到以下信息,这意味着,用户当前处于未登录状态,应该进行 App 自身会话的销毁等操作
然后我们点击「登录」,在 OP 完成登录之后,回调到 app1.html,此时用户变成了登录状态,注意地址栏多了一个参数:session_state,这个参数就是我们上文用于在代码中向 OP iframe 轮询时需要携带的参数。
现在我们试一试单点登出,对于 node-oidc-provider 包提供的 OIDC Provider,只需要前端访问 localhost:3000/session/end
收到来自 OP 的登出成功信息
我们转到 app1.html 看一下,此时控制台输出,用户已经登出,现在要执行会话销毁等操作了。
不想维护 App 1 与用户的登录状态、App 2 与用户的登录状态
如果不各自维护 App 1、App 2 与用户的登录状态,那么无法实现只退出 App 1 而不退出 App 2 这样的需求。所有的登录状态将会完全依赖用户与 OP 之间的登录状态,在效果上是:用户在 OP 一次登录,之后访问所有的应用,都不必再输入密码,实现单点登录;用户在 OP 登出,则在所有应用登出,实现单点登出。
使用 Authing 解决单点登录
以上就是一个完整的单点登录系统的轮廓,我们需要维护一份全体用户目录,进行用户注册、登录;我们需要自己搭建一个 OIDC Provider,并申请一个 OIDC Client;我们需要使用 code 换 token,token 换用户信息;我们需要在自己的应用中不断轮询 OP 的登录状态。
读到这里,你可能会觉得实现一套完整的单点登录系统十分繁琐,不仅要对 OIDC 协议非常熟悉,还要自己架设 OIDC Provider,并且需要自行处理应用、用户、OP 之间登录状态。有没有开箱即用的登录服务呢?Authing 能够提供云上的 OP,云上的用户目录和直观的控制台,能够轻松管理所有用户、完成对 OP 的配置。
Authing 对开发者十分友好,提供丰富的 SDK,进行快速集成。
如果你不想关心登录的细节,将 Authing 集成到你的系统必定能够大幅提升开发效率,能够将更多的精力集中到核心业务上。