統一授權認證架構設計及實現

統一鑑權認證是一個基礎服務。它幾乎在所有企業內部都需要,企業內部只要有兩個以上系統存在,就有必要實現一套統一的授權系統,否則用戶使用非常地麻煩,需要在不同系統之間來回登錄切換。特別是在微服務大行其道的今天,這個統一授權認證服務更是一個基礎和關鍵入口。實現的方案有很多種,但都大同小異。

本文主要介紹授權認證服務架構方案設計及實現,這個實踐也是本人在企業內部成功實現的經驗總結。從舊有系統Cas認證方式到升級Oauth2.0認證,怎麼保持兩套認證體(Cas和Oauth2)互認整個過程遇到不少問題,以及針對問題解決。將從如下幾個方面進行描述。

目錄結構

1、概念介紹

   1.1、什麼是認證

   1.2、什麼是授權

   1.3、什麼是鑑權

   1.4、什麼是權限控制

   1.5、三者關係

2、Http認證方案

  2.1 認證流程圖

  2.2 認證步驟解析

  2.3 優缺點對比

  2.4 使用場景

3、Session-Cookie認證方案

  3.1 認證流程圖

  3.2 認證步驟解析

  3.3 優缺點對比

  3.4 使用場景

  3.5 代碼實現

4、Token認證方案

  4.1 Token認證原理

  4.2 刷新Token

  4.3 Token與Session-Cookie區別

5、OAuth2認證方案

  5.1 OAuth2定義

  5.2 OAuth2角色

  5.3 OAuth2認證流程

6、JWT認證方案

  6.1 JWT定義

  6.2 JWT組成

  6.3 JWT使用

    6.4 JWT認證流程

  6.5 JWT優缺點

7、集團統一授權認證架構方案

  5.1 方案設計

  5.2 關鍵問題

  5.3 企業內部實踐

  5.4 關鍵代碼

8、總結

 

1、概念介紹

 

1.1、什麼是認證

認證(Identification)是指根據聲明者所特有的識別信息,確認聲明者的身份。

白話文的意思就是:你需要用身份證證明你自己是你自己。

比如我們常見的認證技術:

  • 身份證
  • 用戶名和密碼
  • 用戶手機:手機短信、手機二維碼掃描、手勢密碼
  • 用戶的電子郵箱
  • 用戶的生物學特徵:指紋、語音、眼睛虹膜
  • 用戶的大數據識別
  • 等等

1.2、什麼是授權

授權(Authorization):在信息安全領域是指資源所有者委派執行者,賦予執行者指定範圍的資源操作權限,以便對資源的相關操作。

在現實生活領域例如:銀行卡(由銀行派發)、門禁卡(由物業管理處派發)、鑰匙(由房東派發),這些都是現實生活中授權的實現方式。

在互聯網領域例如:web 服務器的 session 機制、web 瀏覽器的 cookie 機制、頒發授權令牌(token)等都是一個授權的機制。

 

1.3、什麼是鑑權

鑑權(Authentication)在信息安全領域是指對於一個聲明者所聲明的身份權利,對其所聲明的真實性進行鑑別確認的過程

若從授權出發,則會更加容易理解鑑權。授權和鑑權是兩個上下游相匹配的關係,先授權,後鑑權

在現實生活領域:門禁卡需要通過門禁卡識別器,銀行卡需要通過銀行卡識別器;

在互聯網領域:校驗 session/cookie/token 的合法性和有效性

鑑權是一個承上啓下的一個環節,上游它接受授權的輸出,校驗其真實性後,然後獲取權限(permission),這個將會爲下一步的權限控制做好準備。

 

1.4、什麼是權限控制

權限控制(Access/Permission Control)將可執行的操作定義爲權限列表,然後判斷操作是否允許/禁止

對於權限控制,可以分爲兩部分進行理解:一個是權限,另一個是控制。權限是抽象的邏輯概念,而控制是具體的實現方式。

在現實生活領域中:以門禁卡的權限實現爲例,一個門禁卡,擁有開公司所有的門的權限;一個門禁卡,擁有管理員角色的權限,因而可以開公司所有的門。

在互聯網領域:通過 web 後端服務,來控制接口訪問,允許或拒絕訪問請求。

 

1.5 認證、授權、鑑權和權限控制的關係

看到這裏,我們應該明白了認證、授權、鑑權和權限控制這四個環節是一個前後依次發生、上下游的關係,如下圖所示:

需要說明的是,這四個環節在有些時候會同時發生。例如在下面的幾個場景:

  • 使用門禁卡開門:認證、授權、鑑權、權限控制四個環節一氣呵成,在瞬間同時發生
  • 用戶的網站登錄:用戶在使用用戶名和密碼進行登錄時,認證和授權兩個環節一同完成,而鑑權和權限控制則發生在後續的請求訪問中,比如在選購物品或支付時。

 

2、Http認證方案

在 HTTP 中,基本認證方案(Basic Access Authentication)是允許客戶端(通常指的就是網頁瀏覽器)在請求時,通過用戶提供用戶名和密碼的方式,實現對用戶身份的驗證。

因爲幾乎所有的線上網站都不會走該認證方案,所以該方案大家瞭解即可

2.1 認證流程圖

 

 

2.2 認證步驟解析

(1)客戶端(如瀏覽器):向服務器請求一個受限的列表數據或資源,例如字段如下

 GET /list/ HTTP/1.1
 Host: www.baidu.com
 Authorization: Basic aHR0cHdhdGNoOmY=

(2)服務器:客戶端你好,這個資源在安全區 baidu.com裏,是受限資源,需要基本認證;

並且向客戶端返回 401 狀態碼(Unauthorized 未被授權的)以及附帶提供了一個認證域www-Authenticate: Basic realm=”baidu.com”要求進行身份驗證;

其中Basic就是驗證的模式,而realm="baidu.com"說明客戶端需要輸入這個安全域的用戶名和密碼,而不是其他域的

 HTTP/1.1 401 Unauthorized
 www-Authenticate: Basic realm= "baidu.com"

(3)客戶端:服務器,我已經攜帶了用戶名和密碼給你了,你看一下;(注:如客戶端是瀏覽器,那麼此時會自動彈出一個彈窗,讓用戶輸入用戶名和密碼);

輸入完用戶名和密碼後,則客戶端將用戶名及密碼以 Base64 加密方式發送給服務器

傳送的格式如下 (其中 Basic 內容爲:用戶名:密碼 的 ase64 形式):

 GET /list/ HTTP/1.1
 Authorization: Basic Ksid2FuZzp3YW5n==

(4)服務器:客戶端你好,我已經校驗了Authorization字段你的用戶名和密碼,是正確的,這是你要的資源。

 成功:HTTP/1.1 200 OK
 失敗:HTTP/1.1 403 Forbidden

2.3 優缺點對比

  2.3.1 優點

  實現簡單,基本所有流行的瀏覽器都支持

  2.3.2 缺點

  (1)不安全:

  • 由於是基於 HTTP 傳輸,所以它在網絡上幾乎是裸奔的,雖然它使用了 Base64 來編碼,但這個編碼很容易就可以解碼出來。
  • 即使認證內容無法被解碼爲原始的用戶名和密碼也是不安全的,惡意用戶可以再獲取了認證內容後使用其不斷的享服務器發起請求,這就是所謂的重放攻擊

  (2)無法主動註銷:

     由於 HTTP 協議沒有提供機制清除瀏覽器中的 Basic 認證信息,除非標籤頁或瀏覽器關閉、或用戶清除歷史記錄。

2.4 使用場景

  內部網絡,或者對安全要求不是很高的網絡。

 

3、Session-Cookie認證方案

Session-Cookie認證是利用服務端的Session(會話)和瀏覽器(客戶端)的 Cookie 來實現的前後端通信認證模式。

在理解這句話之前我們先簡單瞭解下什麼是 Cookie以及什麼是 Session?

3.1 什麼是 Cookie

衆所周知,HTTP 是無狀態的協議(對於事務處理沒有記憶能力,每次客戶端和服務端會話完成時,服務端不會保存任何會話信息);

所以爲了讓服務器區分不同的客戶端,就必須主動的去維護一個狀態,這個狀態用於告知服務端前後兩個請求是否來自同一瀏覽器。而這個狀態可以通過Cookie去實現。

特點:

  • Cookie 存儲在客戶端,可隨意篡改,不安全
  • 有大小限制,最大爲 4kb
  • 有數量限制,一般一個瀏覽器對於一個網站只能存不超過 20 個 Cookie,瀏覽器一般只允許存放 300個 Cookie
  • Android 和 IOS 對 Cookie 支持性不好
  • Cookie 是不可跨域的,但是一級域名和二級域名是允許共享使用的(靠的是 domain)

3.2 什麼是 Session

  Session 的抽象概念是會話,是無狀態協議通信過程中,爲了實現中斷/繼續操作,將用戶和服務器之間的交互進行的一種抽象;

具體來說,是服務器生成的一種 Session 結構,可以通過多種方式保存,如內存、數據庫、文件等,大型網站一般有專門的 Session 服務器集羣來保存用戶會話;

原理流程:

  1. 客戶端:用戶向服務器首次發送請求;
  2. 服務器:接收到數據並自動爲該用戶創建特定的 Session / Session ID,來標識用戶並跟蹤用戶當前的會話過程;
  3. 客戶端:瀏覽器收到響應獲取會話信息,並且會在下一次請求時帶上 Session / Session ID;
  4. 服務器:服務器提取後會與本地保存的 Session ID進行對比找到該特定用戶的會話,進而獲取會話狀態;
  5. 至此客戶端與服務器的通信變成有狀態的通信;

特點:

  • Session 保存在服務器上;
  • 通過服務器自帶的加密協議進行;

與 Cookie 的差異:

  • 安全性:Cookie 由於保存在客戶端,可隨意篡改,Session 則不同存儲在服務器端,無法僞造,所以 Session 的安全性更高;
  • 存取值的類型不同:Cookie 只支持字符串數據,Session 可以存任意數據類型;
  • 有效期不同:Cookie 可設置爲長時間保持,Session 一般失效時間較短;
  • 存儲大小不同:Cookie 保存的數據不能超過 4K;

看到這裏可能就有人想到了,Session-Cookie是不是就是把Session存儲在了客戶端的Cookie中呢?是的,的確是這樣的,我們接着往下看

3.3 Session-Cookie 的認證流程圖

 

3.4 Session-Cookie 認證步驟解析

  1. 客戶端:向服務器發送登錄信息用戶名/密碼來請求登錄校驗;
  2. 服務器:驗證登錄的信息,驗證通過後自動創建 Session(將 Session 保存在內存中,也可以保存在 Redis 中),然後給這個 Session 生成一個唯一的標識字符串會話身份憑證session_id(通常稱爲sid),並在響應頭Set-Cookie中設置這個唯一標識符;

注:可以使用簽名對sid進行加密處理,服務端會根據對應的secret密鑰進行解密 (非必須步驟)

  1. 客戶端:收到服務器的響應後會解析響應頭,並自動將sid保存在本地 Cookie 中,瀏覽器在下次 HTTP 請求時請求頭會自動附帶上該域名下的 Cookie 信息;
  2. 服務器:接收客戶端請求時會去解析請求頭 Cookie 中的sid,然後根據這個sid去找服務端保存的該客戶端的sid,然後判斷該請求是否合法;

3.5 Session-Cookie 優缺點對比

 優點

  • Cookie 簡單易用
  • Session 數據存儲在服務端,相較於 JWT 方便進行管理,也就是當用戶登錄和主動註銷,只需要添加刪除對應的 Session 就可以了,方便管理
  • 只需要後端操作即可,前端可以無感等進行操作;

     缺點

  • 依賴 Cookie,一旦用戶在瀏覽器端禁用 Cookie,這就完蛋,在google瀏覽器由於考慮用戶安全模式下,經常會禁用cookie,所以這個方案侷限性還是比較大的;
  • 非常不安全,Cookie 將數據暴露在瀏覽器中,增加了數據被盜的風險(容易被 CSRF 等攻擊);
  • Session 存儲在服務端,增大了服務端的開銷,用戶量大的時候會大大降低服務器性能;
  • 對移動端的支持性不友好;

3.6 使用場景

  • 一般中大型的網站都適用(除了 APP 移動端);
  • 由於一般的 Session 需集中存儲在內存服務器上(如 Redis);

 

4、Token認證方案

 

現在我們已經得知,Session-Cookie的一些缺點,以及 Session 的維護給服務端造成很大困擾,我們必須找地方存放它,又要考慮分佈式的問題,甚至要單獨爲了它啓用一套 Redis 集羣。那有沒有更好的辦法?

那Token就應運而生了

4.1 Token認證原理

Token是一個令牌,客戶端訪問服務器時,驗證通過後服務端會爲其簽發一張令牌,之後客戶端就可以攜帶令牌訪問服務器,服務端只需要驗證令牌的有效性即可。

一句話概括;訪問資源接口(API)時所需要的資源憑證

一般 Token 的組成:

uid(用戶唯一的身份標識) +time(當前時間的時間戳) +sign(簽名,Token的前幾位以哈希算法壓縮成的一定長度的十六進制字符串)

Token 的認證流程圖:

 

Token 認證步驟解析:

  1. 客戶端:輸入用戶名和密碼請求登錄校驗;
  2. 服務器:收到請求,去驗證用戶名與密碼;驗證成功後,服務端會簽發一個 Token 並把這個 Token 發送給客戶端;
  3. 客戶端:收到 Token 以後需要把它存儲起來,web 端一般會放在 localStorage 或 Cookie 中,移動端原生 APP 一般存儲在本地緩存中;
  4. 客戶端發送請求:向服務端請求 API 資源的時候,將 Token 通過 HTTP 請求頭 Authorization 字段或者其它方式發送給服務端;
  5. 服務器:收到請求,然後去驗證客戶端請求裏面帶着的 Token ,如果驗證成功,就向客戶端返回請求的數據,否則拒絕返還(401);

Token 的優點:

  • 服務端無狀態化、可擴展性好:Token 機制在服務端不需要存儲會話(Session)信息,因爲 Token 自身包含了其所標識用戶的相關信息,這有利於在多個服務間共享用戶狀態
  • 支持 APP 移動端設備;
  • 安全性好:有效避免 CSRF 攻擊(因爲不需要 Cookie)
  • 支持跨程序調用:因爲 Cookie 是不允許跨域訪問的,而 Token 則不存在這個問題

Token 的缺點:

  • 配合:需要前後端配合處理;
  • 佔帶寬:正常情況下比sid更大,消耗更多流量,擠佔更多寬帶
  • 性能問題:雖說驗證 Token 時不用再去訪問數據庫或遠程服務進行權限校驗,但是需要對 Token 加解密等操作,所以會更耗性能;
  • 有效期短:爲了避免 Token 被盜用,一般 Token 的有效期會設置的較短,所以就有了Refresh Token;

4.2 刷新 Token

業務接口用來鑑權的 Token,我們稱之爲Access Token。

爲了安全,我們的Access Token有效期一般設置較短,以避免被盜用。但過短的有效期會造成Access Token經常過期,過期後怎麼辦呢?

一種辦法是:刷新 Access Token,讓用戶重新登錄獲取新 Token,會很麻煩;

另外一種辦法是:再來一個 Token,一個專門生成 Access Token 的 Token,我們稱爲Refresh Token;

  • Access Token用來訪問業務接口,由於有效期足夠短,盜用風險小,也可以使請求方式更寬鬆靈活;
  • Refresh Token用來獲取 Access Token,有效期可以長一些,通過獨立服務和嚴格的請求方式增加安全性;由於不常驗證,也可以如前面的 Session 一樣處理;

Refresh Token 的認證流程圖:

 

Refresh Token 認證步驟解析:

  1. 客戶端:輸入用戶名和密碼請求登錄校驗;
  2. 服務端:收到請求,驗證用戶名與密碼;驗證成功後,服務端會簽發一個Access Token和Refresh Token並返回給客戶端;
  3. 客戶端:把Access Token和Refresh Token存儲在本地;
  4. 客戶端發送請求:請求數據時,攜帶Access Token傳輸給服務端;
  5. 服務端
  • 驗證 Access Token 有效:正常返回數據
  • 驗證 Access Token 過期:拒絕請求
  • 客戶端( Access Token 已過期)則重新傳輸 Refresh Token 給服務端;
  • 服務端( Access Token 已過期)驗證 Refresh Token ,驗證成功後返回新的 Access Token 給客戶端;
  • 客戶端:重新攜帶新的 Access Token 請求接口;
  • 4.3 Token 和 Session-Cookie 的區別

    Session-Cookie和Token有很多類似的地方,但是Token更像是Session-Cookie的升級改良版。

    • 存儲地不同:Session 一般是存儲在服務端;Token 是無狀態的,一般由前端存儲;
    • 安全性不同:Session 和 Token 並不矛盾,作爲身份認證 Token 安全性比 Session 好,因爲每一個請求都有簽名還能防止監聽以及重放攻擊;
    • 支持性不同:Session-Cookie 認證需要靠瀏覽器的 Cookie 機制實現,如果遇到原生 NativeAPP 時這種機制就不起作用了,或是瀏覽器的 Cookie 存儲功能被禁用,也是無法使用該認證機制實現鑑權的;而 Token 驗證機制豐富了客戶端類型。

    如果你的用戶數據可能需要和第三方共享,或者允許第三方調用API接口,用Token 。如果永遠只是自己的網站,自己的App,用什麼就無所謂了。

    5、OAuth2.0認證方案

    5.1 OAuth2.0定義

    OAuth 2.0 是一個開放授權標準,它允許用戶讓第三方應用基於令牌Token的授權,在無需暴露用戶密碼的情況下使第三應用能獲取對用戶數據的有限訪問權限 。

    OAuth 2.0定義了四種授權許可類型:

    1. Authorization Code:授權碼
    2. Implicit:隱式許可
    3. Resource Owner Password Credentials:密碼憑證
    4. Client Credentials :客戶端憑證。

    5.2 OAuth2.0角色

     

    (A)資源擁有者(RO)
    (B)客戶端(Client)
    (C)資源服務器(RS)
    (D)授證服務器(AS)。

     

    5.3 OAuth2.0認證流程

    5.3.1、OAuth 2.0流程圖

    關鍵步驟:

    (A)用戶打開客戶端以後,客戶端要求用戶給予授權。
    (B)用戶同意給予客戶端授權。
    (C)客戶端使用上一步獲得的授權,向認證服務器申請令牌。
    (D)授權認證服務器對客戶端進行認證以後,確認無誤,同意發放令牌。
    (E)客戶端使用令牌,向資源服務器申請獲取資源。
    (F)資源服務器確認令牌無誤,同意向客戶端開放資源。

     

    5.3.2、授權碼模式

    授權碼模式(authorization code)是功能最完整、流程最嚴密的授權模式。
    它的特點就是通過客戶端的後臺服務器,與“服務提供商”的授權認證中心進行互動

    關鍵步驟:

    (A)用戶訪問客戶端,後者將前者導向認證服務器。
    (B)用戶選擇是否給予客戶端授權。
    (C)假設用戶給予授權,認證服務器將用戶導向客戶端事先指定的"重定向URI"(redirection URI),同時附上一個授權碼。
    (D)客戶端收到授權碼,附上早先的"重定向URI",向認證服務器申請令牌。這一步是在客戶端的後臺的服務器上完成的,對用戶不可見。
    (E)認證服務器覈對了授權碼和重定向URI,確認無誤後,向客戶端發送訪問令牌(access token)和更新令牌(refresh token)。

    說明備註:

    第1步驟中,客戶端申請認證的URI,包含以下參數:
    response_type:表示授權類型,必選項,此處的值固定爲"code"
    client_id:表示客戶端的ID,必選項
    redirect_uri:表示重定向URI,可選項
    scope:表示申請的權限範圍,可選項
    state:表示客戶端的當前狀態,可以指定任意值,認證服務器會原封不動地返回這個值。

     

    5.3.2、隱式許可模式

    隱式許可模式(implicit grant type)不通過第三方應用程序的服務器,直接在瀏覽器中向認證服務器申請令牌,跳過了"授權碼"這個步驟,因此得名。

    所有步驟在瀏覽器中完成,令牌對訪問者是可見的,且客戶端不需要認證。

     

    關鍵步驟:

    (A)客戶端將用戶導向認證服務器。
    (B)用戶決定是否給於客戶端授權。
    (C)假設用戶給予授權,認證服務器將用戶導向客戶端指定的"重定向URI",並在URI的Hash部分包含了訪問令牌。
    (D)瀏覽器向資源服務器發出請求,其中不包括上一步收到的Hash值。
    (E)資源服務器返回一個網頁,其中包含的代碼可以獲取Hash值中的令牌。
    (F)瀏覽器執行上一步獲得的腳本,提取出令牌。
    (G)瀏覽器將令牌發給客戶端。

    說明備註:

    A步驟中,客戶端發出的HTTP請求,包含以下參數:
    response_type:表示授權類型,此處的值固定爲"token",必選項。
    client_id:表示客戶端的ID,必選項。
    redirect_uri:表示重定向的URI,可選項。
    scope:表示權限範圍,可選項。
    state:表示客戶端的當前狀態,可以指定任意值,認證服務器會原封不動地返回這個值。

     

    5.3.3、密碼憑證模式

    密碼憑證模式(Resource Owner Password Credentials Grant)中,用戶向客戶端提供自己的用戶名和密碼。

    客戶端使用這些信息,向"服務提供商"索要授權。
    在這種模式中,用戶必須把自己的密碼給客戶端,但是客戶端不得儲存密碼。
    這通常用在用戶對客戶端高度信任的情況下,並且只有授權認證中心在其他授權模式無法執行的情況下,才能考慮使用這種模式。

    關鍵步驟:

    (A)客戶端將用戶導向認證服務器。
    (B)用戶決定是否給於客戶端授權。
    (C)假設用戶給予授權,認證服務器將用戶導向客戶端指定的"重定向URI",並在URI的Hash部分包含了訪問令牌。
    (D)瀏覽器向資源服務器發出請求,其中不包括上一步收到的Hash值。
    (E)資源服務器返回一個網頁,其中包含的代碼可以獲取Hash值中的令牌。
    (F)瀏覽器執行上一步獲得的腳本,提取出令牌。
    (G)瀏覽器將令牌發給客戶端。

    說明備註:

    B步驟中,客戶端發出的HTTP請求,包含以下參數:
    grant_type:表示授權類型,此處的值固定爲"password",必選項。
    username:表示用戶名,必選項。
    password:表示用戶的密碼,必選項。
    scope:表示權限範圍,可選項。

     

    5.3.4、密碼憑證模式

    客戶端憑證模式(Client Credentials Grant)指客戶端以自己的名義,而不是以用戶的名義,向授權認證中心進行認證。嚴格地說,客戶端憑證模式並不屬於OAuth框架所要解決的問題。在這種模式中,用戶直接向客戶端註冊,客戶端以自己的名義要求"服務提供商"提供服務,其實不存在授權問題。

    關鍵步驟:

    (A)客戶端向認證服務器進行身份認證,並要求一個訪問令牌。
    (B)認證服務器確認無誤後,向客戶端提供訪問令牌。

    備註說明:

    A步驟中,客戶端發出的HTTP請求,包含以下參數:
    granttype:表示授權類型,此處的值固定爲"clientcredentials",必選項。
    scope:表示權限範圍,可選項。

     

    6、JWT Token驗證

    我們知道了Token的使用方式以及組成,我們不難發現,服務端驗證客戶端發送過來的 Token 時,還需要查詢數據庫獲取用戶基本信息,然後驗證 Token 是否有效;

    這樣每次請求驗證都要查詢數據庫,增加了查庫帶來的延遲等性能消耗;

    那麼這時候業界常用的JWT就應運而生了!!!

    6.1 JWT定義

    JWT是Auth0提出的通過對 JSON 進行加密簽名來實現授權驗證的方案;

    就是登錄成功後將相關用戶信息組成 JSON 對象,然後對這個對象進行某種方式的加密,返回給客戶端;客戶端在下次請求時帶上這個 Token;服務端再收到請求時校驗 token 合法性,其實也就是在校驗請求的合法性。

    6.2 JWT 的組成

    JWT 由三部分組成:Header 頭部、Payload 負載和Signature 簽名

    它是一個很長的字符串,中間用點(.)分隔成三個部分。列如 :

     eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

    Header 頭部:

    在 Header 中通常包含了兩部分:

    • typ:代表 Token 的類型,這裏使用的是 JWT 類型;
    • alg:使用的 Hash 算法,例如 HMAC SHA256 或 RSA.

     {
       "alg": "HS256",
       "typ": "JWT"
     }

    Payload 負載:

    它包含一些聲明 Claim (實體的描述,通常是一個 User 信息,還包括一些其他的元數據) ,用來存放實際需要傳遞的數據,JWT 規定了7個官方字段:

    • iss (issuer):簽發人
    • exp (expiration time):過期時間
    • sub (subject):主題
    • aud (audience):受衆
    • nbf (Not Before):生效時間
    • iat (Issued At):簽發時間
    • jti (JWT ID):編號

    除了官方字段,你還可以在這個部分定義私有字段,下面就是一個例子。

     {
       "sub": "1234567890",
       "name": "John Doe",
       "admin": true
     }

    Signature 簽名

    Signature 部分是對前兩部分的簽名,防止數據篡改。

    首先,需要指定一個密鑰(secret)。這個密鑰只有服務器才知道,不能泄露給用戶。然後,使用 Header 裏面指定的簽名算法(默認是 HMAC SHA256),按照下面的公式產生簽名。

     HMACSHA256(
       base64UrlEncode(header) + "." +
       base64UrlEncode(payload),
       secret)

    JWT 加密、解密標例

     

    6.3 JWT使用

    客戶端收到服務器返回的 JWT,可以儲存在 Cookie 裏面,也可以儲存在 localStorage。

    此後,客戶端每次與服務器通信,都要帶上這個 JWT。你可以把它放在 Cookie 裏面自動發送,但是這樣不能跨域,所以更好的做法是放在 HTTP 請求的頭信息Authorization字段裏面。

     Authorization: Bearer <token>

    6.4 JWT認證流程

    其實 JWT 的認證流程與 Token 的認證流程差不多,只是不需要再單獨去查詢數據庫查找用戶用戶;簡要概括如下:

     

    6.5 JWT優缺點

     優點:

    • 不需要在服務端保存會話信息(RESTful API 的原則之一就是無狀態),所以易於應用的擴展,即信息不保存在服務端,不會存在 Session 擴展不方便的情況;
    • JWT 中的 Payload 負載可以存儲常用信息,用於信息交換,有效地使用 JWT,可以降低服務端查詢數據庫的次數

     缺點:

    • 加密問題:JWT 默認是不加密,但也是可以加密的。生成原始 Token 以後,可以用密鑰再加密一次。
    • 到期問題:由於服務器不保存 Session 狀態,因此無法在使用過程中廢止某個 Token,或者更改 Token 的權限。也就是說,一旦 JWT 簽發了,在到期之前就會始終有效,除非服務器部署額外的邏輯。

     

    7、集團統一授權認證架構方案

    首先感謝各位看官!文章很長,爲了普及認證基本原理和知識不得花重手筆詳細描述清楚,能一路看到這裏,恭喜!你應該對認證體系有了一個系統性瞭解和認識。

    通過上面各種方案分析整理對比,我們已經有了一個清晰結構認知。下面我們來設計一套適用於集團內部的統一的授權認證方案。

    我們集團內部有很多個業務系統包括:OA系統、商旅系統、財務系統、培訓系統、BPM、ERP、MES、MLP、MOM、MSCS、WCC、MTCS、TIMS、SRM、WMS、PLM、營銷系統(這裏面又包含60多個子系統,如:設計軟件、客服400系統、客情調查問卷、訂單系統,賬號系統、活動、促銷、會員、CRM、電商引流、營銷補貼、學習培訓、傳單系統等以及各種獨立的業務中臺和數據中臺),這些系統都要打通單點登錄,統一授權體系。所以整體實施方案是比較複雜的。

    當時在設計授權方案是遇到不小挑戰,基於已有業務存在兩個重要困難點:

    1、集團內部已經有非常多個子系統,算下來大大小小有130多個子系統;

    2、集團職能體系和製造體系已經有一套相對早期的基於CAS單點登錄系統,而且很難改得,涉及財務和生產製造的保持系統穩定壓倒一切,而營銷體系的各個系統相對比較新,主要採用Oauth2協議認證,實現營銷體系內部統一認證。但還是不能滿足業務的要求,兩套認證體系各自爲政,這認人不可接受的。集團內部所有系統一次登錄必須互通互聯。

     

    7.1 方案設計

     

     

    7.2 關鍵問題

    授權認證中心主要提供四個端點:認證端點、令牌頒發端點、令牌校驗端點和註銷端點。

    集團內部Cas單點與營銷系統Oauth2.0認證內部打通隧道建立互信機制。

    1、用戶登錄職能或製造系統在CAS認證通過,發起一次營銷系統的Oauth2認證服務消息通知,並完成一次授信;

    2、用戶登錄營銷系統在oauth2認證服務鑑權通過,發起一次CAS通訊通過用戶關鍵信息進行TGT交換,並做一次token和tgt綁定動作。

    3、用戶在任意一個體系註銷,兩邊系統都會互發一次消息通知。

    通過上次機制設計實現了兩套獨立認證體系互通互信。不用修改原有舊系統登錄邏輯,也是最少代價方案實現全集團系統單點登錄 。

    7.3 核心代碼實現

     

     

    對外提供訪問入口API
    
    @RestController
    @RequestMapping("/oauth")
    @Module("令牌授權")
    public class AccessTokenController implements AccessTokenRemoteService {
        @Resource
        private AccessTokenService accessTokenService;
    
    
        @RestApi(name = "授權碼模式授權",no = "Auth02",idx = 1)
        @PostMapping(value = "/token/authCode")
        public Response<AccessToken> authByAuthCode(@RequestBody AuthCodeAuthentication authentication){
            return accessTokenService.authByAuthCode(authentication);
        }
    
        @RestApi(name = "密碼模式授權",no = "Auth03",idx = 2)
        @PostMapping(value = "/token/password")
        public Response<AccessToken> authByPassword(@RequestBody PasswordAuthentication authentication) {
            return accessTokenService.authByPassword(authentication);
        }
    
        @RestApi(name = "客戶端憑證模式授權",no = "Auth04",idx = 3)
        @PostMapping(value = "/token/clientCredentials")
        public Response<AccessToken> authByRefreshToken(@RequestBody ClientCredentialsAuthentication authentication) {
            return accessTokenService.authByClientCredentials(authentication);
        }
    
    
        @RestApi(name = "刷新令牌授權",no = "Auth05",idx = 4)
        @PostMapping(value = "/token/refreshToken")
        public Response<AccessToken> authByRefreshToken(@RequestBody RefreshTokenAuthentication authentication) {
            return accessTokenService.authByRefreshToken(authentication);
        }

     

    服務層代碼
     @Service
    public class AccessTokenService {
        @Resource
        private RedisTokenStore redisTokenStore;
        @Resource
        private ClientDetailsRepository clientDetailsRepository;
        @Resource
        private LoginUserRepository loginUserRepository;
    
        @Value("${miop.auth.defaultClientId:}")
        public String defaultClientId;
        @Resource
        private RedisTemplate redisTemplate;
    
        /**
         * 獲取登錄用戶
         * @param accessToken
         * @return
         */
        public Response<ShiroUser> getUserByToken(String accessToken) {
            OAuth2Authentication auth2Authentication = redisTokenStore.readAuthentication(accessToken);
            if(auth2Authentication == null){
                return Response.failure("令牌已失效");
            }
            UserCO user =  (UserCO)auth2Authentication.getUserAuthentication().getPrincipal();
            if(user == null){
                return Response.failure("令牌已失效");
            }
            SpringContextUtil.getBean(AccessTokenService.class).renewalAccessToken(accessToken);
            ShiroUser shiroUser = BeanToolkit.instance().copy(user,ShiroUser.class);
            shiroUser.setAccessToken(accessToken);
            return Response.of(shiroUser);
        }
    
        /**
         * 一分鐘內只更新一次token的過期時間
         * @param accessToken
         * @return
         */
        @Cacheable(value = "renewalAccessToken",key = "#accessToken")
        public char renewalAccessToken(String accessToken){
            redisTokenStore.renewalAccessToken(accessToken);
            return '1';
        }
    
    
        /**
         * 註銷用戶
         * @param accessToken
         * @return
         */
        public Response logout(String accessToken) {
            OAuth2Authentication auth2Authentication = redisTokenStore.readAuthentication(accessToken);
            if(auth2Authentication != null){
                OAuth2AccessToken oAuth2AccessToken = redisTokenStore.getAccessToken(auth2Authentication);
                if(oAuth2AccessToken != null){
                    redisTokenStore.removeRefreshToken(oAuth2AccessToken.getRefreshToken().getValue());
                    redisTokenStore.removeAccessToken(accessToken);
                }
            }
            loginUserRepository.logout(accessToken);
            return Response.success();
        }
    
        public Response<String> createJwt(String accessToken) {
            Response<ShiroUser> shiroUser = getUserByToken(accessToken);
            Map claims = (Map) JSON.toJSON(shiroUser);
            String jwt = JwtUtils.createJwt(claims,20);
            return Response.of(jwt);
        }
    
    
        /**
         * 用戶密碼授權
         * @param authentication
         * @return
         */
        public Response<AccessToken> authByPassword(PasswordAuthentication authentication){
            AuthLog authLog = createAuthLog(authentication);
            try {
                AccessToken accessToken = auth(authentication,GrantType.PASSWORD);
                authLog.setStatus(Status.NORMAL.getValue());
                ShiroUser user = accessToken.getUser();
                if(user != null){
                    authLog.setUserName(user.getName());
                }
                return Response.of(accessToken);
            }catch (Exception e){
                authLog.setStatus(Status.UN_NORMAL.getValue());
                authLog.setMsg(e.getMessage());
                throw e;
            }finally {
                redisTemplate.opsForList().rightPush(LogConstants.LOGIN_LOG_REDIS_QUEUE, authLog);
            }
        }
    
    
        public Response<AccessToken> authByAuthCode(AuthCodeAuthentication authentication){
            AccessToken accessToken = auth(authentication,GrantType.AUTHORIZATION_CODE);
            return Response.of(accessToken);
        }
    
        public Response<AccessToken> authByRefreshToken(RefreshTokenAuthentication authentication){
            AccessToken accessToken = auth(authentication,GrantType.REFRESH_TOKEN);
            return Response.of(accessToken);
        }
    
        public Response<AccessToken> authByClientCredentials(ClientCredentialsAuthentication authentication){
            AccessToken accessToken = auth(authentication,GrantType.CLIENT_CREDENTIALS);
            return Response.of(accessToken);
        }
    
        private AccessToken auth(BaseAuthentication authentication,GrantType grantType){
            ClientDetails clientDetails = clientDetailsRepository.selectByIdWithCache(authentication.getClientId());
            if(!StringUtil.equals(clientDetails.getClientSecret(),authentication.getClientSecret())){
                throw new AuthException("無效的 client credentials:"+authentication.getClientSecret());
            }
            if(!clientDetails.getAuthorizedGrantTypes().contains(grantType.getValue())){
                throw new AuthException("該clientId不允許"+grantType.getValue()+"授權方式");
            }
            for (String scope : authentication.getScope().split(",")) {
                if (!clientDetails.getScope().contains(scope)) {
                    throw new AuthException("不合法的scope:"+scope);
                }
            }
            TokenRequest tokenRequest = new TokenRequest((Map)JSON.toJSON(authentication), authentication.getClientId(),
                    Arrays.asList(authentication.getScope().split(",")), grantType.getValue());
            OAuth2AccessToken oAuth2AccessToken = AuthorizationServer.endpoints.getTokenGranter().grant(grantType.getValue(),tokenRequest);
            AccessToken accessToken = getAccessToken(oAuth2AccessToken);
            return accessToken;
        }
    
    
        /**
         * 轉成自定義的令牌對象
         * @param oAuth2AccessToken
         * @return
         */
        private AccessToken getAccessToken(OAuth2AccessToken oAuth2AccessToken) {
            DefaultOAuth2AccessToken defaultOAuth2AccessToken = (DefaultOAuth2AccessToken)oAuth2AccessToken;
            DefaultExpiringOAuth2RefreshToken refreshToken = (DefaultExpiringOAuth2RefreshToken)defaultOAuth2AccessToken.getRefreshToken();
            AccessToken accessToken = new AccessToken();
            accessToken.setTokenType(defaultOAuth2AccessToken.getTokenType());
            accessToken.setAccessToken(oAuth2AccessToken.getValue());
            accessToken.setAccessTokenExpiresIn(oAuth2AccessToken.getExpiresIn());
            if(oAuth2AccessToken.getRefreshToken() != null){
                accessToken.setRefreshToken(defaultOAuth2AccessToken.getRefreshToken().getValue());
                accessToken.setRefreshTokenExpiresIn((int)((refreshToken.getExpiration().getTime() - System.currentTimeMillis())/1000));
            }
            OAuth2Authentication auth2Authentication = redisTokenStore.readAuthentication(oAuth2AccessToken.getValue());
            if(auth2Authentication != null){
                Object principal = auth2Authentication.getUserAuthentication().getPrincipal();
                ShiroUser shiroUser = BeanToolkit.instance().copy(principal,ShiroUser.class);
                shiroUser.setAccessToken(oAuth2AccessToken.getValue());
                accessToken.setUser(shiroUser);
                Map claims = (Map) JSON.toJSON(shiroUser);
                String jwt = JwtUtils.createJwt(claims,20);
                accessToken.setJwt(jwt);
            }
            return accessToken;
        }
    
        private AuthLog createAuthLog(PasswordAuthentication authentication) {
            AuthLog authLog = new AuthLog();
            authLog.setClientId(authentication.getClientId());
            authLog.setGrantType(GrantType.PASSWORD.getValue());
            authLog.setAccessTime(LocalDateTime.now());
            authLog.setIp(RequestUtil.getIpAddress());
            authLog.setTraceId(Trace.traceId.get());
            authLog.setAccount(authentication.getAccount());
            authLog.setOrgNo(authentication.getOrgNo());
            return authLog;
        }

     

    8、總結

    1、通過本文,我們可以全現系統學習認證體系的原理和設計方案;

    2、所有公司只有兩個以上系統存在就有必要實現授證登錄體系,單獨從認證服務本身來看是比較簡單的,但是跟多個系統集成的時候還是遇到的問題坑,特別是大企業裏系統錯綜複雜,動則上個百個系統集團單點登錄,要成功實施上也不是那麼簡單的事,希望通過本文我們能學到一點啓發。

    3、輕巧方案,用最小的代價,最快方式去實現。認證授權還是有一些細節的坑,比如跨域問題、安全攻防的問題本文還沒有提到,等有空再補了!有需要的同學持續關注吧,我會持續完善和修改,寫這篇文章我花了三天時間,暫時先偷一下懶。

    4、本文主要是工作中總結的資料方案,也有引用到一些網上的資料(時間太久也不知道原作者是誰,若有涉及侵權問題請聯繫本人)。

     

    發表評論
    所有評論
    還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
    相關文章