您现在的位置是:首页 > 文章详情

如何通过 OIDC 协议实现单点登录?

日期:2020-03-27点击:528

什么是单点登录

我们通过一个例子来说明,假设有一所大学,内部有两个系统,一个是邮箱系统,一个是课表查询系统。现在想实现这样的效果:在邮箱系统中登录一遍,然后此时进入课表系统的网站,无需再次登录,课表网站系统直接跳转到个人课表页面,反之亦然。比较专业的定义如下:

单点登录(Single Sign On),简称为 SSO,是目前比较流行的企业业务整合的解决方案之一。 SSO 的定义是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。

为什么要实现单点登录

单点登录的意义在于能够在不同的系统中统一账号、统一登录。用户不必在每个系统中都进行注册、登录,只需要使用一个统一的账号,登录一次,就可以访问所有系统。

通过 OIDC 协议实现单点登录

创建自己的用户目录

用户目录这个词很贴切,你的系统的总用户表就像一本书一样,书的封皮上写着“所有用户”四个字。打开第一页,就是目录,里面列满了用户的名字,翻到对应的页码就能看到这个人的邮箱,手机号,生日信息等等。无论你开发多少个应用,要确保你有一份这些应用所有用户信息的 truth source。所有的注册、认证、注销都要到你的用户目录中进行增加、查询、删除操作。你要做的就是创建一个中央数据表,专门用于存储用户信息,不论这个用户是来自 A 应用、B 应用还是 C 应用。

什么是 OIDC 协议

The OIDC family of specs and supporting specs

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:https://github.com/panva/node-oidc-provider

Golang:https://github.com/dexidp/dex

Python:https://github.com/juanifioren/django-oidc-provider

...

不再一一列举,你需要选择适合你的编程语言的 OIDC Provider 包,然后让它在你的服务器上运行起来。本文使用 JS 语言的 node-oidc-provider。

示例代码 Github

可以在 Github 找到本文示例代码:

https://github.com/Authing/implement-oidc-sso-demo.git

创建文件夹

我们首先创建一个文件夹,用于存放代码:

$ mkdir demo $ cd demo 

克隆仓库

然后我们将 https://github.com/panva/node-oidc-provider.git 仓库 clone 到本地

$ git clone https://github.com/panva/node-oidc-provider.git 

安装依赖

$ 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 的配置文件里面。

https://oscimg.oschina.net/oscnet/up-4ce67ad68960749c07ce6762d95b41a093a.png

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: ['http://localhost:8080/app1.html', 'http://localhost:8080/app2.html'], }, ], ... } 

启动 node-oidc-provider

在 node-oidc-provider/example 文件夹下,运行以下命令来启动我们的 OP:

$ node express.js 

到现在,我们的准备工作已经完成了,在讲如何在 Web App 中进行单点登录之前,我们先了解一下 OIDC 授权码模式。刚刚提到的许多术语:授权码模式业务回调地址临时授权码,可能这些概念你会感到陌生,下文会详细介绍。

OIDC 授权码模式

以下是 OIDC 授权码模式的交互模式,你的应用和 OP 之间要通过这样的交互方式来获取用户信息。

https://oscimg.oschina.net/oscnet/up-41f4544a58675d5582c01afdc69e2f4e5e4.png

我们的 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="http://localhost:3000/auth?client_id=1&redirect_uri=http://localhost:8080/app1.html&scope=openid 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="http://localhost:3000/auth?client_id=1&redirect_uri=http://localhost:8080/app2.html&scope=openid 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 . 

我们访问第一个应用:http://localhost:8080/app1.html

https://oscimg.oschina.net/oscnet/up-a71b76140e04a98f19b453ec3c4cfb1cd5e.png

然后点击「登录」,也就是访问 OIDC Provider 的授权接口。然后我们来到了 OIDC Provider 交互环节,OIDC Provider 发现用户没有登录,要求用户先登录。node-oidc-provider demo 会放通任意用户名 + 密码,但是你在真正实施单点登录时,你必须使用你的用户目录中央数据表中的用户数据来鉴权用户,相关的代码可能会涉及到数据库适配器,自定义用户查询逻辑,这些在 node-oidc-provider 包的相关配置中需要自行插入。

https://oscimg.oschina.net/oscnet/up-8beae9003dd96b4e06c2243106e88332e8d.png

现在点击「登录」,转到确权页面,这个页面会显示你的应用需要获取那些用户权限,本例中请求用户授权获取他的基础资料。

https://oscimg.oschina.net/oscnet/up-c7e2aecb2ae15af8e9602b3f33c7644324a.png

点击「继续」,完成在 OP 的登录,之后 OP 会将浏览器重定向到预先设置的业务回调地址,所以我们又回到了 app1.html。

https://oscimg.oschina.net/oscnet/up-573b52f9a25580ea3d4fd612a761468b34f.png

在 url query 中有一个 code 参数,这个参数就是临时授权码。code 最终对应一条用户信息,接下来看我们如何获取用户信息。

Web App 从 OIDC Provider 获取用户信息

事实上,code 可以直接发送到后端,然后在后端使用 code 换取 access_token。这里我使用 postman 演示如何通过 code 换取 access_token。

你可以使用 curl 命令来发送 HTTP 请求:

$ curl --location --request POST 'http://localhost:3000/token' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'client_id=1' \ --data-urlencode 'client_secret=1' \ --data-urlencode 'redirect_uri=http://localhost:8080/app2.html' \ --data-urlencode 'code=QL10pBYMjVSw5B3Ir3_KdmgVPCLFOMfQHOcclKd2tj1' \ --data-urlencode 'grant_type=authorization_code' 

https://oscimg.oschina.net/oscnet/up-243d96393aa6383795dd8e638dbc979e5c8.png

获取到 access_token 之后,我们可以使用 access_token 访问 OP 上面的资源,主要用于获取用户信息,即你的应用从你的用户目录中读取一条用户信息。

你可以使用 curl 来发送 HTTP 请求:

$ curl --location --request POST 'http://localhost:3000/me' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'access_token=I6WB2g0Rq9G307pPVTDhN5vKuyC9eWjrGjxsO2j6jm-' 

https://oscimg.oschina.net/oscnet/up-46adb9273d570e0718df2b0388eb449c809.png

到此,App 1 的登录已经完成,接下来,让我们看进入 App 2 是怎样的情形。

登录第二个 Web App

我们打开第二个应用,http://localhost:8080/app2.html

然后点击「登录」。

https://oscimg.oschina.net/oscnet/up-52e555b88a59c7339771e1368ad6e2bb644.png

用户已经在 App 1 登录时与 OP 建立了会话,User ←→ OP 已经是登录状态,所以 OP 检查到之后,没有再让用户输入登录凭证,而是直接将用户重定向回业务地址,并返回了授权码 code。

https://oscimg.oschina.net/oscnet/up-17aada029557442a13855d599626f536317.png

同样,App 2 使用 code 换 access_token

curl 命令代码:

$ curl --location --request POST 'http://localhost:3000/token' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'client_id=1' \ --data-urlencode 'client_secret=1' \ --data-urlencode 'redirect_uri=http://localhost:8080/app2.html' \ --data-urlencode 'code=QL10pBYMjVSw5B3Ir3_KdmgVPCLFOMfQHOcclKd2tj1' \ --data-urlencode 'grant_type=authorization_code' 

https://oscimg.oschina.net/oscnet/up-925771a09805f9dc734a952195edcd64c2e.png

再使用 access_token 换用户信息,可以看到,是同一个用户。

curl 命令代码:

$ curl --location --request POST 'http://localhost:3000/me' \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'access_token=I6WB2g0Rq9G307pPVTDhN5vKuyC9eWjrGjxsO2j6jm-' 

https://oscimg.oschina.net/oscnet/up-500a92e8b2419c242ac89de6396afcdc0d9.png

到此,我们实现了 App 1 与 App 2 之间的账号打通与单点登录。

登录态管理

到目前为止,看起来还不错,我们已经实现了两个应用之间账号的统一,而且在 App 1 中登录时输入一次密码,在 App 2 中登录,无需再次让用户输入密码进行登录,可以直接返回授权码到业务地址然后完成后续的用户信息获取。

现在我们来考虑一下退出问题

只退出 App 1 而不退出 App 2

这个问题实质上是登录态的管理问题。我们应该管理三个会话:User ←→ App 1、User ←→ App 2、User ←→ OP。

https://oscimg.oschina.net/oscnet/up-42047b63d08aebebfbd7874474ce432b3ee.png

当 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 进行登出。

https://oscimg.oschina.net/oscnet/up-28597bdbee93ae008e2555578091153c34c.png

之后我们的状态是这样的:

https://oscimg.oschina.net/oscnet/up-b22f0481d3de786b49186514463e0b96851.png

好吧,其实没有任何效果,因为用户和 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 也是同样的操作。

https://oscimg.oschina.net/oscnet/up-40fbbbea0b1c8e2071649b9a6907a5c645e.png

当用户在 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 = 'http://localhost:3000'; 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 = 'http://localhost:3000'; if (e.origin !== targetOrigin) { return; } stat = e.data; if (stat == 'changed') { console.log('should log out now!!'); } } </script> 

在 app1.html 和 app2.html 中加入两个 iframe 标签:

<iframe src="rp.html" hidden></iframe> <iframe src="http://localhost:3000/session/check" id="op" hidden></iframe> 

使用 Ctrl + C 关闭我们的 node-oidc-provider 和 http-server,然后再次启动。访问 app1.html,打开浏览器控制台,会得到以下信息,这意味着,用户当前处于未登录状态,应该进行 App 自身会话的销毁等操作

https://oscimg.oschina.net/oscnet/up-2939692fe11efbd42000f84e470e3297889.png

然后我们点击「登录」,在 OP 完成登录之后,回调到 app1.html,此时用户变成了登录状态,注意地址栏多了一个参数:session_state,这个参数就是我们上文用于在代码中向 OP iframe 轮询时需要携带的参数。

https://oscimg.oschina.net/oscnet/up-824fd14579e90300006fb725267f11c738d.png

现在我们试一试单点登出,对于 node-oidc-provider 包提供的 OIDC Provider,只需要前端访问 localhost:3000/session/end

https://oscimg.oschina.net/oscnet/up-0417b1170f8c1547d03cfc5fdc5eb2dcd89.png

收到来自 OP 的登出成功信息

https://oscimg.oschina.net/oscnet/up-a3286096fb5c5c663c612181e46ae675ff9.png

我们转到 app1.html 看一下,此时控制台输出,用户已经登出,现在要执行会话销毁等操作了。

https://oscimg.oschina.net/oscnet/up-2ac460ef363033500ffb2a9643fbedac78b.png

不想维护 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 的配置。

dashboard

op

Authing 对开发者十分友好,提供丰富的 SDK,进行快速集成。

sdk

如果你不想关心登录的细节,将 Authing 集成到你的系统必定能够大幅提升开发效率,能够将更多的精力集中到核心业务上。

欢迎体验:https://authing.cn

实现单点登录:https://docs.authing.cn/authing/quickstart/implement-sso-with-authing

相关阅读

  1. 为什么所有软件都应该使用单点登录来管理用户?
  2. 用 Authing 10 分钟实现单点登录
  3. 案例 | 在 Odoo 中集成 Authing 完成单点登录
  4. Authing 插件上架 Odoo 官方市场,单点登录即可拥有

本文由博客一文多发平台 OpenWrite 发布!

原文链接:https://my.oschina.net/authing/blog/3212301
关注公众号

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。

持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。

转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。

文章评论

共有0条评论来说两句吧...

文章二维码

扫描即可查看该文章

点击排行

推荐阅读

最新文章