1. 程式人生 > 實用技巧 >通過 OIDC 協議實現單點登入

通過 OIDC 協議實現單點登入

通過 OIDC 協議實現單點登入

來源https://zhuanlan.zhihu.com/p/118037137

什麼是單點登入

我們通過一個例子來說明,假設有一所大學,內部有兩個系統,一個是郵箱系統,一個是課表查詢系統。現在想實現這樣的效果:在郵箱系統中登入一遍,然後此時進入課表系統的網站,無需再次登入,課表網站系統直接跳轉到個人課表頁面,反之亦然。比較專業的定義如下:

「單點登入」(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:

Golang:

Python:

...

不再一一列舉,你需要選擇適合你的程式語言的 OIDC Provider 包,然後讓它在你的伺服器上執行起來。本文使用 JS 語言的 node-oidc-provider。

示例程式碼 Github

可以在 Github 找到本文示例程式碼:

建立資料夾

我們首先建立一個資料夾,用於存放程式碼:

$ mkdir demo
$ cd demo

克隆倉庫

然後我們將倉庫 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 .

我們訪問第一個應用:

然後點選「登入」,也就是訪問 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

我們開啟第二個應用,

然後點選「登入」。

使用者已經在 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 登出,則在所有應用登出,實現單點登出。

============ End