什么是單點登錄
我們通過一個例子來說明,假設有一所大學,內部有兩個系統,一個是郵箱系統,一個是課表查詢系統。現在想實現這樣的效果:在郵箱系統中登錄一遍,然后此時進入課表系統的網站,無需再次登錄,課表網站系統直接跳轉到個人課表頁面,反之亦然。比較專業的定義如下:
單點登錄(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 登出,則在所有應用登出,實現單點登出。
使用 Authing 解決單點登錄
以上就是一個完整的單點登錄系統的輪廓,我們需要維護一份全體用戶目錄,進行用戶注冊、登錄;我們需要自己搭建一個 OIDC Provider,並申請一個 OIDC Client;我們需要使用 code 換 token,token 換用戶信息;我們需要在自己的應用中不斷輪詢 OP 的登錄狀態。
讀到這里,你可能會覺得實現一套完整的單點登錄系統十分繁瑣,不僅要對 OIDC 協議非常熟悉,還要自己架設 OIDC Provider,並且需要自行處理應用、用戶、OP 之間登錄狀態。有沒有開箱即用的登錄服務呢?Authing 能夠提供雲上的 OP,雲上的用戶目錄和直觀的控制台,能夠輕松管理所有用戶、完成對 OP 的配置。
Authing 對開發者十分友好,提供豐富的 SDK,進行快速集成。
如果你不想關心登錄的細節,將 Authing 集成到你的系統必定能夠大幅提升開發效率,能夠將更多的精力集中到核心業務上。
歡迎體驗:https://authing.cn
實現單點登錄:https://docs.authing.cn/authing/quickstart/implement-sso-with-authing
相關閱讀
- 為什么所有軟件都應該使用單點登錄來管理用戶?
- 用 Authing 10 分鍾實現單點登錄
- 案例 | 在 Odoo 中集成 Authing 完成單點登錄
- Authing 插件上架 Odoo 官方市場,單點登錄即可擁有
本文由博客一文多發平台 OpenWrite 發布!