免费注册,打造高效身份管理
博客/开发者/大型网站的单点登录系统是如何设计的?
大型网站的单点登录系统是如何设计的?
Authing 官方2022.09.14阅读 2287

快速实现单点登录的云服务很多。这一类云服务,比如 AWS Cognito 和 Auth0,在硅谷已经有很多朋友在用了(国内由于政策原因用不了)。当然国内也有 Authing,可以快速实现免密登录、单点登录系统。

 

01

什么是单点登录

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

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

 

02

为什么要实现单点登录

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

03

通过 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:github.com/panva/node-o

Golang:github.com/dexidp/dex

Python:github.com/juanifioren/

...

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

示例代码 Github

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

github.com/Authing/impl

创建文件夹

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

$ mkdir demo
$ cd demo

克隆仓库

然后我们将 github.com/panva/node-o 仓库 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 的配置文件里面。

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 之间要通过这样的交互方式来获取用户信息。

我们的 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 .

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

然后点击「登录」,也就是访问 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 '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'

获取到 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-'

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

登录第二个 Web App

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

然后点击「登录」。

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

同样,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'

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

curl 命令代码:

$ curl --location --request POST 'http://localhost:3000/me' \
--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 = '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 自身会话的销毁等操作

然后我们点击「登录」,在 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 登出,则在所有应用登出,实现单点登出。

04

使用 Authing 解决单点登录

以上就是一个完整的单点登录系统的轮廓,我们需要维护一份全体用户目录,进行用户注册、登录;我们需要自己搭建一个 OIDC Provider,并申请一个 OIDC Client;我们需要使用 code 换 token,token 换用户信息;我们需要在自己的应用中不断轮询 OP 的登录状态。

读到这里,你可能会觉得实现一套完整的单点登录系统十分繁琐,不仅要对 OIDC 协议非常熟悉,还要自己架设 OIDC Provider,并且需要自行处理应用、用户、OP 之间登录状态。有没有开箱即用的登录服务呢?Authing 能够提供云上的 OP,云上的用户目录和直观的控制台,能够轻松管理所有用户、完成对 OP 的配置。

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

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

 

点击此处了解更多行业身份管理

「解决方案」以及「最佳实践案例」

文章作者

avatar

Authing 官方

0

文章总数

authing blog rqcode
关注 Authing 公众号
随时随地发现更多内容
authing blog rqcode
添加 Authing 小助手
加入 Authing 开发者大家庭
身份顾问在线解答
当前在线
如何打造完整的身份体系?
立即沟通
authing
添加企业微信,领取行业资料
authing
authing
下载 Authing 令牌,体验快速登录认证!
免费使用
在线咨询
电话咨询