從有狀態應用(Session)到無狀態應用(JWT),以及 SSO 和 OAuth2

無論用哪一種方式認證用戶,均可能被中間人攻擊竊取 SessionID 或 Token,從而發生 CSRF 攻擊。解決方式就是全站 HTTPS。如今 Let’s Encrypt 已經支持免費的通配符 HTTPS 證書了。php

0. 引子

HTTP 協議是無狀態的,要保存用戶狀態須要額外的機制。html

0.1 開始

剛開始時,多數公司使用的技術棧是:單臺雲服務器上安裝所需的全部軟件,包括 Nginx 提供 Web 服務,MySQL 數據庫,PHP-FPM 應用程序服務。這時候使用的用戶認證協議使用最簡單的 Session。客戶端的每一個請求都會攜帶 Cookie,其中保存了 SessionID 字段,服務器能夠經過這個 SessionID 字段訪問到對應的 Session(例如 PHP 中的 $_SESSION ),從而識別出用戶登陸狀態。Session 中還能夠添加一些經常使用的字段進來(好比用戶名、手機號等),避免對數據庫的頻繁訪問。前端

0.2 發展

後來,隨着用戶量增大、併發增大,單臺服務器搞不定了,因而搞了個水平擴展的服務器集羣,經過 Nginx 或 LVS 實現負載均衡。這時發現個問題,用戶登陸後 Session 是保存到集羣中的某一臺服務器上的。要使 Session 機制能夠在分佈式環境下繼續工做,須要一些額外操做。並且對於如今的大前端(瀏覽器、APP、小程序)趨勢來講,Cookie 機制略顯累贅。java

而這時,JWT 認證協議徹底知足需求。協議簡單清晰,花一個下午就能夠搞清楚。nginx

0.3 壯大

多產品線

公司發展過程當中,產品線會慢慢增多,好比百度的貼吧、網盤、瀏覽器等。這時,須要一套單點登陸機制 SSO(Single sign-on),用戶只要一次登陸,就可使用這一系列產品。SSO 描述了認證的問題。laravel

SSO 須要一個獨立的認證中心 CAS(Central Authentication Service,中央認證服務),只有認證中心能提供登陸入口,接受用戶的用戶名密碼等憑證,其餘系統無登陸入口,只接受認證中心的間接受權。這裏有個開源的 CAS:apereo CAS,其服務端用 Java 實現,客戶端支持多種語言。其架構文檔能夠參考 這裏git

微服務

單體項目拆分紅微服務後,能夠更加靈活。一般全部的服務都在網關以後,全部請求都發送到網關,由網關統一轉發。微服務的網關一般實現了 OAuth,成爲認證受權中心,用於判斷是否有足夠權限。微服務之間能夠經過 JWT 進行訪問鑑權,避免身份認證。github

成爲開放平臺

隨着公司用戶增多(假設跟微信同樣,有幾億用戶),合做企業也愈來愈多。若是每次都要在後臺經過人工給合做夥伴配置帳號密碼,分配權限管理,那太麻煩了。同時,一些企業有本身的平臺,想要利用個人用戶帳號體系實如今這些平臺上的登陸(受權登陸)。對於用戶的圖片,一些圖片打印公司也想在通過用戶贊成後,直接訪問到我服務器上的用戶圖片,優化體驗。web

總之,就是隻要用戶贊成,他能夠分享本身的全部資源(帳號、圖片等)。這時,就須要 OAuth2 了。這是一個受權框架,描述了各類受權的問題。算法

0.4 關於 authorization(受權) 和 authentication(認證)

  • authorization(受權):表示容許作某些事情
  • authentication(認證):判斷真實性

例如,用戶登陸論壇時,須要先用用戶名和密碼認證用戶有沒有權限登陸,若是密碼正確則認證經過,登陸成功。用戶登陸後,判斷其角色並授予相應的權限,例如超級管理員能夠刪除全部人,版主能夠刪除其版塊的帖子。

1. Session

1.1 Session 原理

最傳統的用戶認證方式。用戶首次訪問應用服務器後創建會話,服務器可使用 Set-Cookie 這個 HTTP Header,將會話的 SessionID 寫入在用戶端保存的 Cookie 中(具體的名字能夠自行設置,系統中統一便可)。下次用戶再次向這個域名發請求時會攜帶全部 Cookie 信息,包括這個 SessionID。

Session 信息保存在服務器端,而用於惟一標識這個 Session 的 SessionID 則保存在對應客戶端的 Cookie 中。SessionID 這個會話標識符本質上是一個隨機字符串,每一個用戶的 SessionID 都不同。

Session 中能夠保存不少信息。例如設置一個 IsLogin 字段,用戶經過帳號密碼登陸後,將這個字段設置爲 TRUE。這樣,在 Session 的有效期內(好比 2 小時),即便用戶關閉網頁,再次打開後仍會保持登陸狀態(除非用戶清理了 Cookie,致使其訪問服務器時沒有攜帶 SessionID 字段)。對於其餘的經常使用字段(如 userID、userName等)也能夠添加到 Session 中,以減小數據庫的訪問壓力,但注意不要太大,由於全部用戶的會話信息都是保存在服務器的內存中的。

1.2 經過 Fiddler 抓包分析 Session

下面的示例,基於 PHP 語言,CodeIgniter 框架。同時,省略了無關的 HTTP Header,重點分析 Session 相關字段。

1. 首次訪問某個網站

在第一次訪問一個網站時,瀏覽器中沒有對應 Cookie 信息,全部請求的 HTTP Header 中沒有 Cookie 這個字段。若是應用服務器支持會話,能夠在爲這個用戶建立 Session 後,經過在響應的 HTTP Header 中使用 Set-Cookie 字段將這個會話的 SessionID 保存到瀏覽器的 Cookie 中。能夠看到我這裏對應的 SessionID 的名字是 ci_session:

-----------------------------------------請求的 HTTP Header-----------------------------------------
GET http://tuan.local.cn/ HTTP/1.1
Host: tuan.local.cn
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
If-Modified-Since: Thu, 10 May 2018 06:20:36 GMT
...

-----------------------------------------響應的 HTTP Header-----------------------------------------
HTTP/1.1 200 OK
Date: Thu, 10 May 2018 06:21:13 GMT
Content-Type: text/html; charset=UTF-8
Set-Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf; expires=Thu, 10-May-2018 08:21:13 GMT; Max-Age=7200; path=/; HttpOnly
...

這裏 Set-Cookie 中的各個字段解釋以下,完整的中文版解釋參考 這裏

  • ci_session:SessionID,這個會話對應的服務器上的 Session 的惟一標識符。
  • expires:Cookie 的有效期。
  • Max-Age:Cookie 過時前的秒數。
  • path:能夠在 Header 中使用這個 Cookie 的 URL 路徑,這裏表示這個域名下的全部請求都會攜帶這個 Cookie。
  • HttpOnly:表示這個 Cookie 沒法經過 JavaScript 的 Document.cookie 屬性或 XMLHttpRequest 和 Request 這兩個 API 訪問,避免 XSS(cross-site scripting,跨站腳本攻擊)。

2. 再次訪問這個網站

每次經過域名或 IP 地址訪問時,瀏覽器都會檢查是否有可用的 Cookie,若是有,則放到請求的 HTTP Header 中一同發送到服務器:

-----------------------------------------請求的 HTTP Header-------------------------------------------
GET http://tuan.local.cn/ HTTP/1.1
Host: tuan.local.cn
Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf
...

-----------------------------------------響應的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Thu, 10 May 2018 06:22:02 GMT
Content-Type: text/html; charset=UTF-8
...

3. 登陸

登陸成功以後,登陸請求對應的響應會再次設置 Cookie 字段,從新設置 Cookie 字段的有效期。個人應用程序中設置 Session 爲兩個小時的有效期:

這裏演示的是經過 AJAX 登陸,因此有 Origin 和 X-Requested-With 這兩個由瀏覽器自動設置的字段:

-----------------------------------------請求的 HTTP Header-------------------------------------------
POST http://tuan.local.cn/index/login_password HTTP/1.1
Host: tuan.local.cn
Origin: http://tuan.local.cn
X-Requested-With: XMLHttpRequest
Content-Type: application/json
Referer: http://tuan.local.cn/
Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf
...

{"Mobile":"18866668888","Password":"888666"}

-----------------------------------------響應的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Set-Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf; expires=Thu, 10-May-2018 08:22:33 GMT; Max-Age=7200; path=/; HttpOnly
...

4. 登陸後的訪問

跟正常訪問沒有區別,只是攜帶的 Cookie 中有 SessionID,且服務器端對應的 Session 中須要(好比 IsLogin=true,本身設置)標識已登陸狀態:

-----------------------------------------請求的 HTTP Header-------------------------------------------
GET http://tuan.local.cn/ HTTP/1.1
Host: tuan.local.cn
Cookie: ci_session=hfvn0lq3ct62mppl86n2du535bc4stcf
...

-----------------------------------------響應的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Thu, 10 May 2018 06:22:34 GMT
...

1.3 Session 的不足

Session 的主要問題有:

  • 服務器壓力大:每一個用戶在認證後,Session 信息都會保存在服務器的內存中,開銷大。
  • 難以擴展:對於基於 Session 的分佈式系統,要實現負載均衡,有兩個辦法:確保同一用戶始終訪問同一個服務器,或在多臺服務器之間同步 Session。對於前者,Nginx 也能夠用 ip_hash 把同一來源的 IP(同一 C 段)指向後端的同一臺機器。對於後者則須要經過 Session Sticky 機制在多臺服務器之間同步 Session(例如 Nginx 的擴展模塊 nginx-sticky-module。假設 Session 存儲在 A 服務器上,而用戶訪問了 B 服務器,則能夠將 Session 從 A 同步到 B,可是若是存儲 Session 的 A 服務器掛掉,仍是會致使用戶掉線)。

還有,就是目前大前端的發展,除了瀏覽器外,各類 APP、小程序層出不窮,而非瀏覽器下環境下避免使用 Cookie 可能會更簡單。

2. JWT

JWT 官網的詳細介紹
Larval + Vue 案例

Session 之因此這麼麻煩,是由於須要在服務器端保存信息,那我把信息保存在客戶端,不就能夠避免這個麻煩了嘛。JWT 就是這麼個思路,服務器端保存加密機制及密鑰,對用戶指定字段進行加密後的字符串保存在客戶端,用戶下次請求時攜帶加密前的字段和加密後的字符串,若是跟服務器加密結果匹配,則認爲登陸成功。

2.1 JWT 原理

JWT(JSON web token)是一種認證協議,能夠發佈接入令牌(Access Token,保持在客戶端)並對發佈的簽名接入令牌進行驗證。令牌(Token)自己包含一系列聲明,應用程序能夠根據這些聲明限制用戶對資源的訪問。

JWT 由三段信息構成的:

  • header
  • payload
  • signature

JWT 示例:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjU5NDM4MTksIm5iZiI6MTUyNTk0Mzg3OSwiZXhwIjoxNTI1OTQ3NDE5LCJ1aWQiOjF9.jL-Hrl8obZlLGutjr-nVPCSoF2ObFh-rWfSwSZxoxzs

1. header 部分

Header 部分用於聲明協議類型和加密方式。

上面的 JWT 示例的 header 部分通過 base64_decode 後獲得原始 JSON 字符串,內容以下:

{
    "typ":"JWT",
    "alg":"HS256",
    "jti":"4f1g23a12aa" }

其中,typ 內容固定爲 JWT,alg 表示加密算法,這裏使用的是 HMAC SHA256。

2. payload 部分

payload 部分用於存放負載,將明文信息通過 base64 編碼後存儲,未經加密,不可存儲敏感信息。包括如下三種:

  • JWT 標準中註冊的聲明
  • 公共聲明
  • 私有聲明

JWT 標準中註冊的聲明(不強制使用)有如下幾種,完整版能夠 參考這裏

  • iat:Issued At,簽發時間
  • iss:Issuer,JWT 簽發者
  • sub:subject,JWT 所面向的訂閱者,每一個 Issuer 範圍內是惟一的
  • aud:Audience,JWT 的接收方
  • exp:Expiration Time,過時時間,這個過時時間必需要大於簽發時間
  • nbf:定義在什麼時間以前,該 JWT 都是不可用的.
  • jti:JWT 的惟一身份標識,主要用來做爲一次性 Token,避免重放攻擊

上面 JWT 示例中的 payload 部分對應的 JSON 字符串爲:

{
    "iss":"http:\/\/example.com",
    "aud":"http:\/\/example.org",
    "jti":"4f1g23a12aa",
    "iat":1525943995,
    "nbf":1525944055,
    "exp":1525947595,
    "userID":6666,
    "userName":"kika",
    "userSex":"m" }

這個 payload 中添加了幾個自定義字段。

3. signature 部分

將 header 和 payload 通過 base64 編碼後,用 . 句點拼接成一個字符串,經過 HMACSHA256(Java 的方法)或 hash_hmac(PHP 的方法),使用指定密鑰加密這個字符串獲得 signature。

JAVA:

sig = HMACSHA256(base64UrlEncode(header) + "." +  base64UrlEncode(payload),  secret);

PHP:

$sig = hash_hmac('sha256', base64_encode($header) + "." +  base64_decode($payload), $secret);

JWT 支持兩種簽名方式:

  • 密鑰:基於字符串,簡單,安全性低
  • RSA 和 ECDSA 簽名:基於公鑰和私鑰,須要先生成私鑰文件,簽名時指定這個文件的位置

2.2 JWT 特色

  • 信息基於 base64 編碼轉換爲 ASCII 碼,傳輸可靠。
  • 信息是不加密存儲的,不可存敏感信息。
  • JWT 本質上是經過時間換空間,服務器不存儲用戶狀態信息,可是每一個用戶請求都會消耗 CPU 時間來驗證 Token。
  • 基於 Token 的鑑權機制保持了 HTTP 協議的無狀態型,從而實現更簡單的水平擴展。
  • 須要在服務器端額外編程(Session 則不用)。
  • 生成簽名字段時,支持使用密鑰字符串簽名(安全性較低),也支持使用 RSA、ECDSA 私鑰簽名。

用戶登錄後,能夠把一些經常使用字段(用戶標識,是不是管理員,權限有哪些等等能夠公開的信息)用 JWT 編碼存儲在 Cookie 中,每次服務器讀取到 Cookie 後就能夠解析到當前用戶對應的信息,減少數據庫壓力。也能夠用 Authorization: Bearer <jwttoken> 的方式經過 HTTP Header 僅發送 JWT 的 Token。

2.3 JWT 工做流程

  1. 用戶經過帳號密碼發起登陸請求
  2. 服務器驗證經過後,設置 header 和 payload,並獲得加密後的簽名,而後將這三部分做爲 Token 發送給用戶
  3. 客戶端保存 Token,並在每一個請求中附加這個 Token
  4. 若是請求攜帶了 Token,服務器會驗證這個 Token 並根據驗證結果進行不一樣處理

發送請求時,Token 放在請求的 HTTP Header 中。另外,若是發生跨域,例如 www.xx.com 下發出到 api.xx.com 的請求,須要在服務端開啓 CORS(跨域資源共享):

Access-Control-Allow-Origin: *

2.4 經過 Fiddler 抓包分析 JWT

下面的示例,基於 PHP 語言,CodeIgniter 框架。同時,省略了無關的 HTTP Header,重點分析 JWT 相關字段。

-----------------------------------------請求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/create_token HTTP/1.1
Host: jwt.com
...

-----------------------------------------響應的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Fri, 11 May 2018 02:27:19 GMT
Set-Cookie: jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDU2MzksIm5iZiI6MTUyNjAwNTY5OSwiZXhwIjoxNTI2MDA5MjM5LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.MvYG6L71mM_AJj5FT4--RzCluIQ__nqgYSe9RTj8VCk; expires=Fri, 11-May-2018 04:27:19 GMT; Max-Age=7200; path=/
Content-Length: 1052
...

服務器端從 Cookie 中提取 jwt 這個字段後驗證簽名,若是經過驗證則認爲內容可靠,解析其中的內容並以此決定用戶登陸狀態、權限等:

-----------------------------------------請求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/ HTTP/1.1
Host: jwt.com
Cookie: jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDU2MzksIm5iZiI6MTUyNjAwNTY5OSwiZXhwIjoxNTI2MDA5MjM5LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.MvYG6L71mM_AJj5FT4--RzCluIQ__nqgYSe9RTj8VCk
...

經過 HTTP Header 字段 Authorization 實現

1. 登陸成功,服務器建立並設置客戶端的 Authorization 這個 HTTP Header

-----------------------------------------請求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/create_token HTTP/1.1
Host: jwt.com
...

-----------------------------------------響應的 HTTP Header-------------------------------------------
HTTP/1.1 200 OK
Date: Fri, 11 May 2018 02:35:19 GMT
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDc3NTQsIm5iZiI6MTUyNjAwNzgxNCwiZXhwIjoxNTI2MDExMzU0LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.CBsw7_rDC-GeJBob2JwCOITp7L80g_VT9KUtSVmKYKY
Host: jwt.com

2. 用戶再次訪問時,攜帶 Authorization

-----------------------------------------請求的 HTTP Header-------------------------------------------
GET http://jwt.com/welcome/ HTTP/1.1
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImp0aSI6IjRmMWcyM2ExMmFhIn0.eyJpc3MiOiJodHRwOlwvXC9leGFtcGxlLmNvbSIsImF1ZCI6Imh0dHA6XC9cL2V4YW1wbGUub3JnIiwianRpIjoiNGYxZzIzYTEyYWEiLCJpYXQiOjE1MjYwMDc3NTQsIm5iZiI6MTUyNjAwNzgxNCwiZXhwIjoxNTI2MDExMzU0LCJ1c2VySUQiOjY2NjYsInVzZXJOYW1lIjoia2lrYSIsInVzZXJTZXgiOiJtIn0.CBsw7_rDC-GeJBob2JwCOITp7L80g_VT9KUtSVmKYKY
Host: jwt.com

後端服務器對這個 Authorization 進行判斷便可。

2.5 示例(基於 PHP)

對於 PHP,可使用的 JWT 庫有 jwtjwt-auth。這裏以第一個 jwt 爲例,具體操做請結合所使用語言及框架和安裝的 JWT 庫。

2.5.1 使用 composer 安裝 JWT 庫

composer require lcobucci/jwt

注意,PHP 版本須要 5.5+,同時須要開啓 OpenSSL 擴展。

2.5.2 經過 JWT 庫生成 Token

使用祕鑰簽名

use Lcobucci\JWT\Parser;
use Lcobucci\JWT\Builder;
use Lcobucci\JWT\Signer\Hmac\Sha256;

public function create_token() {
    $builder = new Builder();
    $signer = new Sha256();

    // 設置簽發者
    $builder->setIssuer('http://xx.com');
    // 設置接收者
    $builder->setAudience('http://xx.com');
    // 設置 ID,能夠用來區分
    $builder->setId('4f1g23a12aa', true);
    // 設置簽發時間
    $builder->setIssuedAt(time());
    // 在 60 秒內該 token 沒法使用
    $builder->setNotBefore(time() + 60);
    // 設置過時時間位 2 小時
    $builder->setExpiration(time() + 7200);
    // 設置自定義的 payload 信息
    $builder->set('userID', 6666);
    $builder->set('userName', 'kika');
    $builder->set('userSex', 'm');
    // sha256 簽名,密鑰字符串能夠自定義
    $builder->sign($signer, 'signatureString');
    // 獲取生成的token
    $token = $builder->getToken();

    // 能夠經過 Cookie 傳輸
    set_cookie('jwt', $token, 7200);
    // 也能夠經過 HTTP Header 傳輸,在前端保存 token 後添加到 HTTP Header 便可:Authorization: Bearer xx.xx.xx

    // 查看字段內容
    $token = explode('.', $token);
    echo base64_decode($token[0]).'<br/>';
    echo base64_decode($token[1]).'<br/>';
}

使用 RSA 和 ECDSA 簽名

把上面使用字符串加密的這一行:

$builder->sign($signer, 'signatureString');

替換爲使用密鑰文件加密便可,須要提供私鑰地址:

$builder->sign($signer, $keychain->getPrivateKey('私鑰地址'));

在每個請求頭裏加入 Authorization,並加上 Bearer:

fetch('api/user', {
  headers: {
    'Authorization': 'Bearer ' + token
  }
})

2.5.4 驗證簽名

經過 Cookie 傳輸 JWT 信息:

if ($token = get_cookie('jwt')) {
    $rs = $this->verify_token($token);
    if ($rs) {
        echo 'you have right jwt<br />';
    } else {
        echo 'error<br />';
    }
}

經過 HTTP Header 傳輸 JWT 信息:

$headers = apache_request_headers();
if (!empty($headers['Authorization']) && $token = $headers['Authorization']) {
    $token = substr($token, strpos($token, 'Bearer ') + 7);
    $rs = $this->verify_token($token);
    if ($rs) {
        echo 'you have right jwt from Authorization<br />';
    } else {
        echo 'error Authorization<br />';
    }
}

2.5.5 提取數據

直接從 $token 中獲取全部數據:

public function get_claims ($token) {
    $parser = new Parser();
    $parse = $parser->parse($token);
    return $parse->getClaims();
}

也能夠獲取單條數據:

$parse->getClaim('aud');

3. OAuth 2.0

內容比較多,另寫一篇,參考 這裏

相關文章
相關標籤/搜索