zoukankan      html  css  js  c++  java
  • 如何通过 OIDC 协议实现单点登录?

    此文转载自:https://my.oschina.net/authing/blog/3212301

    什么是单点登录

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

    单点登录(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 发布!

       

    更多内容详见微信公众号:Python测试和开发

    Python测试和开发

  • 相关阅读:
    复杂网络常用数据集网站
    01单人决策问题
    《无线网络安全技术》阅读笔记
    最优化理论基础
    测试layer控件,除了ie报错其它浏览器都生效
    Native App、Web App、Hybrid App
    有些效果在IE下运行时,IE下开调试模式才显示正常是什么原因?
    关于Content-Type中application/x-www-form-urlencoded 和 multipart/form-data的区别及用法
    js表单提交的三种方式
    前端涉及的所有知识体系
  • 原文地址:https://www.cnblogs.com/phyger/p/14190293.html
Copyright © 2011-2022 走看看