通過 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: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 的配置檔案裡面。
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 .
我們訪問第一個應用:http://localhost:8080/app1.html
然後點選「登入」,也就是訪問 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
我們開啟第二個應用,http://localhost:8080/app2.html
然後點選「登入」。
使用者已經在 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