developers,

五分钟理解什么是 OIDC

叶祖伟 叶祖伟 Follow Mar 27, 2020 · 8 mins read
五分钟理解什么是 OIDC
分享

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

什么是单点登录

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

单点登录(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: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://usercontents.authing.cn/how-to-implement-sso/Untitled.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://usercontents.authing.cn/how-to-implement-sso/Untitled%201.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://usercontents.authing.cn/how-to-implement-sso/Untitled%202.png

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

https://usercontents.authing.cn/how-to-implement-sso/Untitled%203.png

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

https://usercontents.authing.cn/how-to-implement-sso/Untitled%204.png

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

https://usercontents.authing.cn/how-to-implement-sso/Untitled%205.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://usercontents.authing.cn/how-to-implement-sso/Untitled%206.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://usercontents.authing.cn/how-to-implement-sso/Untitled%207.png

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

登录第二个 Web App

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

然后点击「登录」。

https://usercontents.authing.cn/how-to-implement-sso/Untitled%208.png

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

https://usercontents.authing.cn/how-to-implement-sso/Untitled%209.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://usercontents.authing.cn/how-to-implement-sso/Untitled%2010.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://usercontents.authing.cn/how-to-implement-sso/Untitled%2011.png

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

登录态管理

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

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

只退出 App 1 而不退出 App 2

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

https://usercontents.authing.cn/how-to-implement-sso/Untitled%2012.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://usercontents.authing.cn/how-to-implement-sso/Untitled%2013.png

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

https://usercontents.authing.cn/how-to-implement-sso/Untitled%2014.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://usercontents.authing.cn/how-to-implement-sso/Untitled%2015.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://usercontents.authing.cn/how-to-implement-sso/Untitled%2016.png

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

https://usercontents.authing.cn/how-to-implement-sso/Untitled%2017.png

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

https://usercontents.authing.cn/how-to-implement-sso/Untitled%2018.png

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

https://usercontents.authing.cn/how-to-implement-sso/Untitled%2019.png

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

https://usercontents.authing.cn/how-to-implement-sso/Untitled%2020.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

叶祖伟
Written by 叶祖伟
Fullstack developer who plays music.