如何通過 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 發布!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM