[譯] Angular 安全 —— 使用 JSON 網路令牌(JWT)的身份認證:完全指南

0x7e2發表於2018-01-21

本文是在 Angular 應用中設計和實現基於 JWT(JSON Web Tokens)身份驗證的分步指南。

我們的目標是系統的討論基於 JWT 的認證設計和實現,衡量取捨不同的設計方案,並將其應用到某個 Angular 應用特定的上下文中。

我們將追蹤一個 JWT 從被認證伺服器建立開始,到它被返回到客戶端,再到它被返回到應用伺服器的全程,並討論其中涉及的所有的方案以及做出的決策。

由於身份驗證同樣需要一些服務端程式碼,所以我們將同時顯示這些資訊,以便我們可以掌握整個上下文,並且看清楚各個部分之間如何協作。

服務端程式碼是 Node/Typescript,Angular 開發者對這些應該是非常熟悉的。但是涵蓋的概念並不是特定於 Node 的。

如果你使用另一種服務平臺,主需要在 jwt.io 上為你的平臺選擇一個 JWT 庫,這些概念仍然適用。

目錄

在這篇文章中,我們將介紹以下主題:

  • 第一步 —— 登陸頁面
    • 基於 JWT 的身份驗證
    • 使用者在 Angular 應用中登入
    • 為什麼要使用單獨託管的登陸頁面?
    • 在我們的單頁應用(SPA)中直接登入
  • 第二步 —— 建立基於 JWT 的使用者會話
  • 第三步 —— 將 JWT 返回到客戶端
    • 在哪裡儲存 JWT 會話令牌?
    • Cookie 與 Local Storage
  • 第四步 —— 在客戶端儲存使用 JWT
    • 檢查使用者過期時間
  • 第五步 —— 每次請求攜帶 JWT 發回到伺服器
    • 如何構建一個身份驗證 HTTP 攔截器
  • 第六步 —— 驗證使用者請求
    • 構建用於 JWT 驗證的定製 Express 中介軟體
    • 使用 express-jwt 配置 JWT 驗證中介軟體
    • 驗證 JWT 簽名 —— RS256
    • RS256 與 HS256
    • JWKS (JSON Web 金鑰集) 終節點和金鑰輪換
    • 使用 node-jwks-rsa 實現 JWKS 金鑰輪換
  • 總結

所以無需再費周折(without further ado),我們開始學習基於 JWT 的 Angular 的認證吧!

基於 JWT 的使用者會話

首先介紹如何使用 JSON 網路令牌來建立使用者會話:簡而言之,JWT 是數字簽名以 URL 友好的字串格式編碼的 JSON 有效載荷(payload)。

JWT 通常可以包含任何有效載荷,但最常見的用例是使用有效載荷來定義使用者會話。

JWT 的關鍵在於,我們只需要檢查令牌本身驗證簽名就可以確定它們是否有效,而無需為此單獨聯絡伺服器,不需要將令牌儲存到記憶體中,也不需要在請求的時候儲存到伺服器或記憶體中。

如果使用 JWT 身份驗證,則它們將至少包含使用者 ID 和過期時間戳。

如果你想要深入瞭解有關 JWT 格式的詳細資訊(包括最常用的簽名型別如何工作),請參閱本文後面的 JWT: The Complete Guide to JSON Web Tokens 一文。

如果想知道 JWT 是什麼樣子的話,下面是一個例子:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNTM0NTQzNTQzNTQzNTM0NTMiLCJleHAiOjE1MDQ2OTkyNTZ9.zG-2FvGegujxoLWwIQfNB5IT46D-xC4e8dEDYwi6aRM
複製程式碼

你可能會想:這看起來不像 JSON!那麼 JSON 在哪裡?

為了看到它,讓我們回到 jwt.io 並將完成的 JWT 字串貼上到驗證工具中,然後我們就能看到 JSON 的有效內容:

{
  "sub": "353454354354353453",
  "exp": 1504699256
}
複製程式碼

檢視 raw01.ts ❤託管於 GitHub

sub 屬性包含使用者識別符號,exp 包含使用者過期時間戳.這種型別的令牌被稱為不記名令牌(Bearer Token),意思是它標識擁有它的使用者,並定義一個使用者會話。

不記名令牌是使用者名稱/密碼組合的簽名臨時替換!

如果想進一步瞭解 JWT,請看看這裡。對於本文的其餘部分,我們將假定 JWT 是一個包含可驗證 JSON 有效載荷的字串,它定義了一個使用者會話。

實現基於 JWT 的身份驗證第一步是釋出不記名令牌並將其提供給使用者,這是登入/註冊頁面的主要目的。

第一步 —— 登陸頁面

身份驗證以登陸頁面開始,該頁面可以託管在我們的域中或者第三方域中。在企業場景中,登陸頁面一般會託管在單獨的伺服器上。這是公司範圍內單點登入解決方案的一部分。

在公網(Public Internet)上,登入頁面也可能是:

  • 由第三方身份驗證程式(如 Auth0)託管
  • 在我們的單頁應用中可用的登入頁面路徑或模式下直接使用。

單獨託管的登入頁面是一種安全性的改進,因為這樣密碼永遠不會直接由我們的應用程式碼來處理。

單獨託管的登入頁面可以具有最少量的 JavaScript 甚至完全沒有,並且可以將其做到不論看起來還是用起來都像是整體應用的一部分的效果。

但是,使用者在我們應用中通過內建登入頁面登入也是一種可行且常用的解決方案,所以我們也會介紹一下。

直接在 SPA 應用上的登入頁面

如果直接在我們的 SPA 程式中建立登入頁面,它將看起來是這樣的:

@Component({
  selector: 'login',
  template: `
<form [formGroup]="form">
    <fieldset>
        <legend>Login</legend>
        <div class="form-field">
            <label>Email:</label>
            <input name="email" formControlName="email">
        </div>
        <div class="form-field">
            <label>Password:</label>
            <input name="password" formControlName="password" 
                   type="password">
        </div>
    </fieldset>
    <div class="form-buttons">
        <button class="button button-primary" 
                (click)="login()">Login</button>
    </div>
</form>`})
export class LoginComponent {
    form:FormGroup;

    constructor(private fb:FormBuilder, 
                 private authService: AuthService, 
                 private router: Router) {

        this.form = this.fb.group({
            email: ['',Validators.required],
            password: ['',Validators.required]
        });
    }

    login() {
        const val = this.form.value;

        if (val.email && val.password) {
            this.authService.login(val.email, val.password)
                .subscribe(
                    () => {
                        console.log("User is logged in");
                        this.router.navigateByUrl('/');
                    }
                );
        }
    }
}
複製程式碼

檢視 raw02.ts ❤託管於 GitHub

正如我們所看到的,這個頁面是一個簡單的表單,包含兩個欄位:電子郵件和密碼。當使用者點選登入按鈕的時候,使用者和密碼將通過 login() 呼叫傳送到客戶端身份驗證服務。

為什麼要建立一個單獨的認證服務

把我們所有的客戶端身份驗證邏輯放在一個集中的應用範圍內的單個 AuthService(認證服務)中將幫助我們保持我們程式碼的組織結構。

這樣,如果以後我們需要更改安全提供者或者重構我們的安全邏輯,我們只需要改變這個類。

在這個服務裡,我們將使用一些 JavaScript API 來呼叫第三方服務,或者使用 Angular HTTP Client 進行 HTTP POST 呼叫。

這兩種方案的目標是一致的:通過 POST 請求將使用者和密碼組合通過網路傳送到認證伺服器,以便驗證密碼並啟動會話。

以下是我們如何使用 Angular HTTP Client 構建自己的 HTTP POST:

@Injectable()
export class AuthService {
     
    constructor(private http: HttpClient) {
    }
      
    login(email:string, password:string ) {
        return this.http.post<User>('/api/login', {email, password})
            // 這只是一個 HTTP 呼叫, 
            // 我們還需要去處理 token 的接收
        	.shareReplay();
    }
}
複製程式碼

檢視 raw03.ts ❤託管於 GitHub

我們呼叫的 shareReplay 可以防止這個 Observable 的接收者由於多次訂閱而意外觸發多個 POST 請求。

在處理登入響應之前,我們先來看看請求的流程,看看伺服器上發生了什麼。

第二步 —— 建立 JWT 會話令牌

無論我們在應用級別使用登入頁面還是託管登入頁面,處理登入 POST 請求的伺服器邏輯是相同的。

目標是在這兩種情況下都會驗證密碼並建立一個會話。如果密碼是正確的,那麼伺服器將會發出一個不記名令牌,說:

該令牌的持有者的專業 ID 是 353454354354353453, 該會話在接下來的兩個小時有效

然後伺服器應該對令牌進行簽名併傳送回使用者瀏覽器!關鍵部分是 JWT 簽名:這是防止攻擊者偽造會話令牌的唯一方式。

這是使用 Express 和 Node 包 node-jsonwebtoken 建立新的 JWT 會話令牌的程式碼:

import {Request, Response} from "express";
import * as express from 'express';
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
import * as jwt from 'jsonwebtoken';
import * as fs from "fs";

const app: Application = express();

app.use(bodyParser.json());

app.route('/api/login')
    .post(loginRoute);

const RSA_PRIVATE_KEY = fs.readFileSync('./demos/private.key');

export function loginRoute(req: Request, res: Response) {

    const email = req.body.email,
          password = req.body.password;

    if (validateEmailAndPassword()) {
       const userId = findUserIdForEmail(email);

        const jwtBearerToken = jwt.sign({}, RSA_PRIVATE_KEY, {
                algorithm: 'RS256',
                expiresIn: 120,
                subject: userId
            }

          // 將 JWT 發回給使用者
          // TODO - 多種可選方案                              
    }
    else {
        // 傳送狀態 401 Unauthorized(未經授權)
        res.sendStatus(401); 
    }
}
複製程式碼

檢視 raw04.ts ❤託管於 GitHub

程式碼很多,我們逐行分解:

  • 我們首先建立一個 Express 應用
  • 接下來,我們配置 bodyParser.json() 中介軟體,使 Express 能夠從 HTTP 請求體中讀取 JSON 有效載荷
  • 然後,我們定義了一個名為 loginRoute 的路由處理程式,如果伺服器收到一個目標地址是 /api/login 的 POST 請求,就會觸發它

loginRoute 方法中,我們有一些程式碼展示瞭如何實現登入路由:

  • 由於 bodyParser.json() 中介軟體的存在,我們可以使用 req.body 訪問 JSON 請求主體有效載荷。
  • 我們先從請求主體中檢索電子郵件和密碼
  • 然後我們要驗證密碼,看看它是否正確
  • 如果密碼錯誤,那麼我們返回 HTTP 401 狀態碼錶示未經授權
  • 如果密碼正確,我們從檢索使用者專用標識開始
  • 然後我們使用使用者 ID 和過期時間戳建立一個普通的 JavaScript 物件,然後將其傳送回客戶端
  • 我們使用 node-jsonwebtoken 庫對有效載荷進行簽名,然後選擇 RS256 簽名型別(稍後詳細介紹)
  • .sign() 呼叫結果是 JWT 字串本身

總而言之,我們驗證了密碼並建立一個 JWT 會話令牌。現在我們已經對這個程式碼的工作原理有了一個很好的瞭解,讓我們來關注使用了 RS256 簽名的包含使用者會話詳細資訊的 JWT 簽名的關鍵部分。

為什麼簽名的型別很重要?因為沒有理解它,我們就無法理解應用程式服務端上對相關令牌的驗證程式碼。

什麼是 RS256 簽名?

RS256 是基於 RSA 的 JWT 簽名型別,是一種廣泛使用的公鑰加密技術。

使用 RS256 簽名的主要優點之一是我們可以將建立令牌的能力與驗證他們的能力分開。

如果您想知道如何手動重現它們,可以閱讀 JWT 指南中使用此類簽名的所有優點。

簡而言之,RS256 簽名的工作方式如下:

  • 私鑰(如我們的程式碼中的 RSA_PRIVATE_KEY)用於對 JWT 進行簽名
  • 一個公鑰用來驗證它們
  • 這兩個金鑰是不可互換的:它們只能標記 token,或者只能驗證,它們中的任何一個都不能同時做這兩件事

為什麼用 RS256?

為什麼使用公鑰加密簽署 JWT ?以下是一些安全和運營優勢的例子:

  • 我們只需要在認證伺服器部署簽名私鑰,不是在多個應用伺服器使用相同認證伺服器。
  • 我們不必為了同時更改每個地方的共享金鑰而以協同的方式關閉認證伺服器和應用伺服器。
  • 公鑰可以在 URL 中公佈並且被應用伺服器在啟動時以及定時自動讀取。

最後一部分是一個很好的特性:能夠釋出驗證金鑰給我們內建的金鑰輪換或者撤銷,我們將在這篇文章中實現!

這是因為(使用 RS256)為了啟用一個新的金鑰對,我們只需要釋出一個新的公鑰,並且我們會看到這個公鑰。

RS256 vs HS256

另一個常用的簽名是 HS256,沒有這些優勢。

HS256 仍然是常用的,但是例如 Auth0 等供應商現在都預設使用 RS256。如果你想了解有關 HS256,RS256 和 JWT 簽名的更多資訊,請檢視這篇文章

拋開我們使用的簽名型別不談,我們需要將新簽名的令牌傳送回使用者瀏覽器。

第三步 —— 將 JWT 傳送回客戶端

我們有幾種不同的方式將令牌發回給使用者,例如:

  • 在 Cookie 中
  • 在請求正文中
  • 在一個普通的 HTTP 頭

JWT 和 Cookie

讓我們從 cookie 開始,為什麼不使用 Cookie 呢?JWT 有時候被稱為 Cookie 的替代品,但這是兩個完全不同的概念。 Cookie 是一種瀏覽器資料儲存機制,可以安全地儲存少量資料。

該資料可以是諸如使用者首選語言之類的任何資料。但它也可以包含諸如 JWT 的使用者識別令牌。

因此,我們可以將 JWT 儲存在 Cookie 中!然後,我們來談談使用 Cookie 儲存 JWT 與其他方法相比較的優點和缺點。

瀏覽器如何處理 Cookie

Cookie 的一個獨特之處在於,瀏覽器會自動為每個請求附加到特定域和子域的 Cookie 到 HTTP 請求的頭部。

這就意味著,如果我們將 JWT 儲存到了 Cookie 中,假設登入頁面和應用共享一個根域,那麼在客戶端上,我們不需要任何其他的的邏輯,就可以讓 Cookie 隨每一個請求傳送到應用伺服器。

然後,讓我們把 JWT 儲存到 Cookie 中,看看會發生什麼。下面是我們對登入路由的實現,傳送 JWT 到瀏覽器,存入 :

... continuing the implementation of the Express login route

// this is the session token we created above
const jwtBearerToken = jwt.sign(...);

// set it in an HTTP Only + Secure Cookie
res.cookie("SESSIONID", jwtBearerToken, {httpOnly:true, secure:true});
複製程式碼

檢視 raw05.ts ❤託管於 GitHub

除了使用 JWT 值設定 Cookie 外,我們還設定了一些我們將要討論的安全屬性。

Cookie 獨特的安全屬性 —— HttpOnly 和安全標誌

Cookie 另一個獨特之處在於它有著一些與安全相關的屬性,有助於確保資料的安全傳輸。

一個 Cookie 可以標記為“安全”,這意味著如果瀏覽器通過 HTTPS 連線發起了請求,那麼它只會附加到請求中。

一個 Cookie 同樣可以被標記為 Http Only,這就意味著它 根本不能 被 JavaScript 程式碼訪問!請注意,瀏覽器依舊會將 Cookie 附加到對伺服器的每個請求中,就像使用其他 Cookie 一樣。

這意味著,當我們刪除 HTTP Only 的 Cookie 的時候,我們需要向伺服器傳送請求,例如登出使用者。

HTTP Only Cookie 的優點

HTTP Only 的 Cookie 的一個優點是,如果應用遭受指令碼注入攻擊(或稱 XSS),在這種荒謬的情況下, Http Only 標誌仍然會阻止攻擊者訪問 Cookie ,阻止使用它冒充使用者。

Secure 和 Http Only 標誌經常可以一起使用,以獲得最大的安全性,這可能使我們認為 Cookie 是儲存 JWT 的理想場所。

但是 Cookie 也有一些缺點,那麼我們來談談這些:這將有助於我們知曉在 JWT 中儲存 Cookie 是否是一種適合我們應用的好方案。(譯者注:原文是 “this will help us decide if storing cookies in a JWT is a good approach for our application”,但是上面的部分講的是將 JWT 存入 Cookie 中,所以譯者認為原文有誤,但是還是選擇尊重原文)

Cookie 的缺點 —— XSRF(跨站請求偽造)

將不記名令牌儲存在 Cookie 中的應用,因此(因為這個 Cookie)遭受的攻擊被稱為跨站請求偽造(Cross-Site Request Forgery),也成為 XSRF 或者 CSRF。下面是其原理:

  • 有人發給你一個連結,並且你點選了它
  • 這個連結向受到攻擊的網站最終傳送了一個 HTTP 請求,其中包含了所有連結到該網站的 Cookie
  • 如果你登陸了網站,這意味著包含我們 JWT 不記名令牌的 Cookie 也會被轉發,這是由瀏覽器自動完成的
  • 伺服器接收到有效的 JWT,因此伺服器無法區分這是攻擊請求還是有效請求

這就意味著攻擊者可以欺騙使用者代表他去執行某些操作,只需要傳送一封電子郵件或者公共論壇上釋出連結即可。

這個攻擊不像看起來那麼嚇人,但問題是執行起來很簡單:只需要一封電子郵件或者社交媒體上的帖子。

我們會在後文詳細介紹這種攻擊,現在需要知道的是,如果我們選擇將我們的 JWT 儲存到 Cookie 中,那麼我們還需要對 XSRF 進行一些防禦。

好訊息是,所有的主流框架都帶有防禦措施,可以很容易地對抗 XSRF,因為它是一個眾所周知的漏洞。

就像是發生過很多次一樣,Cookie 設計上魚和熊掌不能兼得:使用 Cookie 意味著利用 HTTP Only 可以很好的防禦指令碼注入,但是另一方面,它引入了一個新的問題 —— XSRF。

Cookie 和第三方認證提供商

在 Cookie 中接收會話 JWT 的潛在問題是,我們無法從處理驗證邏輯的第三方域接收到它。

這是因為在 app.example.com 執行的應用不能從 security-provider.com 等其他域訪問 Cookie。 因此在這種情況下,我們將無法訪問包含 JWT 的 Cookie,並將其傳送到我們的伺服器進行驗證,這個問題導致了 Cookie 不可用。

我們可以得到兩個方案中的最優解嗎?

第三方認證提供商可能會允許我們在我們自己網站的可配置子域名中執行外部託管的登入頁面,例如 login.example.com

因此,將所有這些解決方案中最好的部分組合起來是有可能的。下面是解決方案的樣子:

  • 將外部託管的登入頁面託管到我們自己的子域 login.example.com 上,example.com 上執行應用
  • 該頁面設定了僅包含 JWT 的 HTTP Only 和 Secure 的 Cookie,為我們提供了很好的保護,以低於依賴竊取使用者身份的多種型別的 XSS 攻擊
  • 此外,我們需要新增一些 XSRF 防禦功能,這裡有一個很好理解的解決方案

這將為我們提供最大限度的保護,防止密碼和身份令牌被盜:

  • 應用永遠不會獲取密碼
  • 應用程式碼從不訪問會話 JWT,只訪問瀏覽器
  • 該應用的請求不容易被偽造(XSRF)

這種情況有時用於企業門戶,可以提供很好的安全功能。但是這需要我們的登入頁面支援託管到自定義域,且使用了安全提供程式或企業安全代理。

但是,此功能(登入頁面託管到自定義子域)並不總是可用,這使得 HTTP Only Cookie 方法可能失效。

如果你的應用屬於這種情況,或者你正尋找不依賴 Cookie 的替代方案,那麼讓我們回到最初的起點,看看我們可以做什麼。

在 HTTP 響應正文中傳送回 JWT

具有 HTTP Only 特性的 Cookie 是儲存 JWT 的可靠選擇,但是還會有其他很好的選擇。例如我們不使用 Cookie,而是在 HTTP 響應體中將 JWT 傳送回客戶端。

我們不僅要傳送 JWT 本身,而且還要將過期時間戳作為單獨的屬性傳送。

的確,過期時間戳在 JWT 中也可以獲取到,但是我們希望讓客戶端能夠簡單地獲得會話持續時間,而不必要為此再安裝一個 JWT 庫。

以下使我們如何在 HTTP 響應體中將 JWT 傳送回客戶端:

... 繼續 Express 登入路由的實現

// 這是我們上面建立的會話令牌
const jwtBearerToken = jwt.sign(...);

// 將其放入 HTTP 響應體中
res.status(200).json({
  idToken: jwtBearerToken, 
  expiresIn: ...
});

複製程式碼

檢視 raw06.ts ❤託管於 GitHub

這樣,客戶端將收到 JWT 及其過期時間戳。

為了不使用 Cookie 儲存 JWT 所進行的設計妥協

不使用 Cookie 的優點是我們的應用不再容易受到 XSRF 攻擊,這是這種方法的優點之一。

但是這同樣意味著我們將不得不新增一些客戶端程式碼來處理令牌,因為瀏覽器將不再為每個嚮應用伺服器傳送的請求轉發它。

這也意味著,在成功的指令碼注入攻擊的情況下,攻擊者此時可以讀取到 JWT 令牌,而儲存到 HTTP Only Cookie 則不可能讀取到。

這是與選擇安全解決方案有關的設計折衷的一個好例子:通常是安全與便利的權衡。

讓我們繼續跟隨我們的 JWT 不記名令牌的旅程。由於我們將 JWT 通過請求體發回給客戶端,我們需要閱讀並處理它。(譯者注:原文是“Since we are sending the JWT back to the client in the request body”,譯者認為應該是響應體(response body),但是尊重原文)

第四步 —— 在客戶端儲存使用 JWT

一旦我們在客戶端收到了 JWT,我們需要把它儲存在某個地方。否則,如果我們重新整理瀏覽器,它將會丟失。那麼我們就必須要重新登入了。

有很多地方可以儲存 JWT(Cookie 除外)。本地儲存(Local Storage)是儲存 JWT 的實用場所,它是以字串的鍵值對的形式儲存的,非常適合儲存少量資料。

請注意,本地儲存具有同步 API。讓我們來看看實用本地儲存的登入與登出邏輯的實現:

import * as moment from "moment";

@Injectable()
export class AuthService {

    constructor(private http: HttpClient) {

    }

    login(email:string, password:string ) {
        return this.http.post<User>('/api/login', {email, password})
            .do(res => this.setSession) 
            .shareReplay();
    }
          
    private setSession(authResult) {
        const expiresAt = moment().add(authResult.expiresIn,'second');

        localStorage.setItem('id_token', authResult.idToken);
        localStorage.setItem("expires_at", JSON.stringify(expiresAt.valueOf()) );
    }          

    logout() {
        localStorage.removeItem("id_token");
        localStorage.removeItem("expires_at");
    }

    public isLoggedIn() {
        return moment().isBefore(this.getExpiration());
    }

    isLoggedOut() {
        return !this.isLoggedIn();
    }

    getExpiration() {
        const expiration = localStorage.getItem("expires_at");
        const expiresAt = JSON.parse(expiration);
        return moment(expiresAt);
    }    
}
複製程式碼

檢視 raw07.ts ❤託管於 GitHub

讓我們分析一下這個實現過程中發生了什麼,從 login 方法開始:

  • 我們接收到包含 JWT 和 expiresIn 屬性的 login 呼叫的結果,並直接將它傳遞給 setSession 方法
  • setSession 中,我們直接將 JWT 儲存到本地儲存中的 id_token 鍵值中
  • 我們使用當前時間和 expiresIn 屬性計算過期時間戳
  • 然後我們還將過期時間戳儲存為本地儲存中 expires_at 條目中的一個數字值

在客戶端使用會話資訊

現在我們在客戶端擁有全部的會話資訊,我們可以在客戶端應用的其餘部分使用這些資訊。

例如,客戶端應用需要知道使用者是否登陸或者登出,以判斷某些比如登入/登出選單按鈕這類的 UI 元素的顯示與否。

這些資訊現在可以通過 isLoggedIn(), isLoggedOut()getExpiration() 獲取。

對伺服器的每次請求都攜帶 JWT

現在我們已經將 JWT 儲存在使用者瀏覽器中,讓我們繼續追隨其在網路中的旅程。

讓我們來看看如何使用它來讓應用伺服器知道一個給定的 HTTP 請求屬於特定使用者。這是認證方案的全部要點。

以下是我們需要做的事情:我們需要用某種方式為 HTTP 附加 JWT,併傳送到應用伺服器。

然後應用伺服器將驗證請求並將其連結到使用者,只需要檢查 JWT,檢查其簽名並從有效內容中讀取使用者標識。

為了確保每個請求都包含一個 JWT,我們將使用一個 Angular HTTP 攔截器。

如何構建一個身份驗證 HTTP 攔截器

以下是 Angular 攔截器的程式碼,用於為每個請求附加 JWT 併傳送給應用伺服器:

@Injectable()
export class AuthInterceptor implements HttpInterceptor {

    intercept(req: HttpRequest<any>,
              next: HttpHandler): Observable<HttpEvent<any>> {

        const idToken = localStorage.getItem("id_token");

        if (idToken) {
            const cloned = req.clone({
                headers: req.headers.set("Authorization",
                    "Bearer " + idToken)
            });

            return next.handle(cloned);
        }
        else {
            return next.handle(req);
        }
    }
}

複製程式碼

檢視 raw08.ts ❤託管於 GitHub

那麼讓我們來分解以下這個程式碼是如何工作:

  • 我們首先直接從本地儲存檢索 JWT 字串
  • 請注意,我們沒有在這裡注入 AuthService,因為這裡會導致迴圈依賴錯誤
  • 然後我們將檢查 JWT 是否存在
  • 如果 JWT 不存在,那麼請求將通過伺服器進行修改
  • 如果 JWT 存在,那麼我們就克隆 HTTP 頭,並新增額外的認證(Authorization)頭,其中將包含 JWT

並且在此處,最初在認證伺服器上建立的 JWT 現在會隨著每個請求傳送到應用伺服器。

我們來看看應用伺服器如何使用 JWT 來識別使用者。

驗證服務端的 JWT

為了驗證請求,我們需要從 Authorization 頭中提取 JWT,並檢查時間戳和使用者識別符號。

我們不希望將這個邏輯應用到所有的後端路由,因為某些路由是所有使用者公開訪問的。例如,如果我們建立了自己的登陸和註冊路由,那麼這些路由應該可以被所有使用者訪問。

另外,我們不希望在每個路由基礎上都重複驗證邏輯,因此最好的解決方案是建立一個 Express 認證中介軟體,並將其應用於特定的路由。

假設我們已經定義了一個名為 checkIfAuthenticated 的 express 中介軟體,這是一個可重用的函式,它只在一個地方包含認證邏輯。

以下是我們如何將其應用於特定的路由:

import * as express from 'express';

const app: Application = express();

// ... 定義 checkIfAuthenticated 中介軟體
// 檢查使用者是否僅在某些路由進行身份驗證
app.route('/api/lessons')
    .get(checkIfAuthenticated, readAllLessons);
複製程式碼

檢視 raw10.ts ❤託管於 GitHub

在這個例子中,readAllLessons 是一個 Express 路由,如果一個 GET 請求到達 /api/lessons Url,它就會提供一個 JSON 列表。

我們已經通過在 REST 端點之前應用 checkIfAuthenticated 中介軟體,使得這個路由只能被認證的使用者訪問,這意味著中介軟體功能的順序很重要。

如果沒有有效的 JWT,checkIfAuthenticated 中介軟體將會報錯,或允許請求通過中介軟體鏈繼續。

在 JWT 存在的情況下,如果簽名正確但是過期,中介軟體也需要丟擲錯誤。請注意,在使用基於 JWT 的身份驗證的任何應用中,所有這些邏輯都是相同的。

我們可以使用 node-jsonwebtoken 自己編寫的中介軟體,但是這個邏輯很容易出錯,所以我們使用第三方庫。

使用 express-jwt 配置 JWT 驗證中介軟體

為了建立 checkIfAuthenticated 中介軟體,我們將使用 express-jwt 庫。

這個庫可以讓我們快速建立常用的基於 JWT 的身份驗證設定的中介軟體,所以我們來看看如何使用它來驗證 JWT,比如我們在登入服務中建立 JWT(使用 RS256 簽名)。

首先假定我們首先在伺服器的檔案系統中安裝了簽名驗證公鑰。以下是我們如何使用它來驗證 JWT:

const expressJwt = require('express-jwt');

const RSA_PUBLIC_KEY = fs.readFileSync('./demos/public.key');

const checkIfAuthenticated = expressJwt({
    secret: RSA_PUBLIC_KEY
}); 

app.route('/api/lessons')
    .get(checkIfAuthenticated, readAllLessons);
複製程式碼

檢視 raw11.ts ❤託管於 GitHub

現在讓我們逐行分解程式碼:

  • 我們通過從檔案系統讀取公鑰來開始,這將用於驗證 JWT
  • 此金鑰只能用於驗證現有的 JWT,而不能建立和簽署新的 JWT
  • 我們將公鑰傳遞給了 express-jwt,並且我們得到一個準備使用的中介軟體函式!

如果認證頭沒有正確簽名的 JWT,那麼這個中介軟體將會丟擲錯誤。如果 JWT 簽名正確,但是已經過期,中介軟體也會丟擲錯誤。

如果我們想要改變預設的異常處理方法,比如不將異常丟擲。而是返回一個狀態碼 401 和一個 JSON 負載的訊息,這也是可以的

使用 RS256 簽名的主要優點之一是我們不需要像我們在這個例子中所做的那樣,在應用伺服器上安裝公鑰。

想象一下,伺服器上有幾個正在執行的例項:在任何地方同時替換公鑰都會出現問題。

利用 RS256 簽名

由認證伺服器在公開訪問的 URL 中釋出用於驗證 JWT 的公鑰。而不是在應用伺服器上安裝公鑰。

這給我們帶來了很多好處,比如說可以簡化金鑰輪換和撤銷。如果我們需要一個新的金鑰對,我們只需要釋出一個新的公鑰。

通常金鑰週期輪換期間內,我們會將兩個金鑰釋出和啟用一段時間,這段時間一般大於會話時序時間,目的是不中斷使用者體驗,然而撤銷可能會更有效。

攻擊者可以使用公鑰,但是這沒有危險。攻擊者可以使用公鑰進行攻擊的唯一方法是驗證現有 JWT 簽名,可是這對攻擊者無用。

攻擊者無法使用公鑰偽造新建立的 JWT,或者以某種方式使用公鑰猜測私鑰簽名值。(譯者注:這一部分主要涉及的是對稱加密和非對稱加密,感覺說的很囉嗦)

現在的問題是,如何釋出公鑰?

JWKS (JSON Web 金鑰集) 端點和金鑰輪換

JWKS 或者 JSON Web 金鑰集 是用於在 REST 端點中基於 JSON 標準釋出的公鑰。

這種型別的端點輸出有點嚇人,但好訊息是我們不必直接使用這種格式,因為有一個庫直接使用了它:

{
  "keys": [
    {
      "alg": "RS256",
      "kty": "RSA",
      "use": "sig",
      "x5c": [
        "MIIDJTCCAg2gAwIBAgIJUP6A\/iwWqvedMA0GCSqGSIb3DQEBCwUAMDAxLjAsBgNVBAMTJWFuZ3VsYXJ1bml2LXNlY3VyaXR5LWNvdXJzZS5hdXRoMC5jb20wHhcNMTcwODI1MTMxNjUzWhcNMzEwNTA0MTMxNjUzWjAwMS4wLAYDVQQDEyVhbmd1bGFydW5pdi1zZWN1cml0eS1jb3Vyc2UuYXV0aDAuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwUvZ+4dkT2nTfCDIwyH9K0tH4qYMGcW\/KDYeh+TjBdASUS9cd741C0XMvmVSYGRP0BOLeXeaQaSdKBi8uRWFbfdjwGuB3awvGmybJZ028OF6XsnKH9eh\/TQ\/8M\/aJ\/Ft3gBHJmSZCuJ0I3JYSBEUrpCkWjkS5LtyxeCPA+usFAfixPnU5L5lyacj3t+dwdFHdkbXKUPxdVwwkEwfhlW4GJ79hsGaGIxMq6PjJ\/\/TKkGadZxBo8FObdKuy7XrrOvug4FAKe+3H4Y5ZDoZZm5X7D0ec4USjewH1PMDR0N+KUJQMRjVul9EKg3ygyYDPOWVGNh6VC01lZL2Qq244HdxRwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH\/MB0GA1UdDgQWBBRwgr0c0DYG5+GlZmPRFkg3+xMWizAOBgNVHQ8BAf8EBAMCAoQwDQYJKoZIhvcNAQELBQADggEBACBV4AyYA3bTiYWZvLtYpJuikwArPFD0J5wtAh1zxIVl+XQlR+S3dfcBn+90J8A677lSu0t7Q7qsZdcsrj28BKh5QF1dAUQgZiGfV3Dfe4\/P5wUaaUo5Y1wKgFiusqg\/mQ+kM3D8XL\/Wlpt3p804dbFnmnGRKAJnijsvM56YFSTVO0JhrKv7XeueyX9LpifAVUJh9zFsiYMSYCgBe3NIhIfi4RkpzEwvFIBwtDe2k9gwIrPFJpovZte5uvi1BQAAoVxMuv7yfMmH6D5DVrAkMBsTKXU1z3WdIKbrieiwSDIWg88RD5flreeTDaCzrlgfXyNybi4UTUshbeo6SdkRiGs="
      ],
      "n": "wUvZ-4dkT2nTfCDIwyH9K0tH4qYMGcW_KDYeh-TjBdASUS9cd741C0XMvmVSYGRP0BOLeXeaQaSdKBi8uRWFbfdjwGuB3awvGmybJZ028OF6XsnKH9eh_TQ_8M_aJ_Ft3gBHJmSZCuJ0I3JYSBEUrpCkWjkS5LtyxeCPA-usFAfixPnU5L5lyacj3t-dwdFHdkbXKUPxdVwwkEwfhlW4GJ79hsGaGIxMq6PjJ__TKkGadZxBo8FObdKuy7XrrOvug4FAKe-3H4Y5ZDoZZm5X7D0ec4USjewH1PMDR0N-KUJQMRjVul9EKg3ygyYDPOWVGNh6VC01lZL2Qq244HdxRw",
      "e": "AQAB",
      "kid": "QzY0NjREMjkyQTI4RTU2RkE4MUJBRDExNzY1MUY1N0I4QjFCODlBOQ",
      "x5t": "QzY0NjREMjkyQTI4RTU2RkE4MUJBRDExNzY1MUY1N0I4QjFCODlBOQ"
    }
  ]
}
複製程式碼

檢視 raw12.ts ❤託管於 GitHub

關於這種格式的一些細節:kid 代表金鑰識別符號,而 x5c 屬性是公鑰本身(它是 x509 證照鏈)。

再次強調,我們不必要編寫程式碼來使用這種格式,但是我們需要對這個 REST 端點中發生的事情有一點了解:他只是簡單地釋出一個公鑰。

使用 node-jwks-rsa 庫實現 JWT 金鑰輪換

由於公鑰的格式是標準化的,我們需要的是一種讀取金鑰的方法,並將其傳遞給 express-jwt ,如此以便它可以代替從檔案系統中讀取出來的公鑰。

而這正是 node-jwks-rsa 庫讓我們做的!我們來看看這個庫的運作:

const jwksRsa = require('jwks-rsa');
const expressJwt = require('express-jwt');

const checkIfAuthenticated = expressJwt({
    secret: jwksRsa.expressJwtSecret({
        cache: true,
        rateLimit: true,
        jwksUri: "https://angularuniv-security-course.auth0.com/.well-known/jwks.json"
    }),
    algorithms: ['RS256']
});

app.route('/api/lessons')
    .get(checkIfAuthenticated, readAllLessons);

複製程式碼

檢視 raw14.ts ❤託管於 GitHub

這個庫通過 jwksUri 屬性指定 URL 讀取公鑰,並使用其驗證 JWT 簽名。我們需要做的只是匹配網址,如果需要的話還需要設定一些額外引數。

使用 JWT 端點的配置選項

建議將 cache 屬性設定為 true,以防每次都檢索公鑰。預設情況下,一個金鑰會保留 10 小時,然後再檢查它是否有效,同時最多快取 5 個金鑰。

rateLimit 屬性也應該被啟用,以確保庫每分鐘不會向包含公鑰伺服器發起超過 10 個請求。

這是為了避免出現拒絕服務的情況,由於某種情況(包括攻擊,但也許是一個 bug),公共伺服器會不斷進行公鑰輪換。

這將使應用伺服器很快停止,因為它有很好的內建防禦措施!如果你想要更改這些預設引數,請檢視庫文件來獲取更多詳細資訊。

這樣,我們已經完成了 JWT 的網路之旅!

  • 我們已經在應用伺服器中建立並簽名了一個 JWT
  • 我們已經展示瞭如何在客戶端使用 JWT 並將其隨每個 HTTP 請求傳送回伺服器
  • 我們已經展示了應用伺服器如何驗證 JWT,並將每個請求連結到給定使用者

我們已經討論了這個往返過程中涉及到的多個設計方案。讓我們總結一下我們所學到的。

總結和結論

將認證和授權等安全功能委派給第三方基於 JWT 的提供商或者產品比以往更加合適,但這並不意味著安全性可以透明地新增到應用中。

即使我們選擇了第三方認證提供商或企業級單點登入解決方案,如果沒有其他可以用來理解我們所選的產品或者庫的文件,我們至少也要知道其中關於 JWT 的一些處理細節。

我們仍然需要自己做很多安全設計方案,選擇庫和產品,選擇關鍵配置選項,如 JWT 簽名型別,設定託管登入頁面(如果可用),並放置一些非常關鍵的、很容易出錯安全相關程式碼。

希望這篇文章對你有幫助並且你能喜歡它!如果您有任何問題或者意見,請在下面的評論區告訴我,我將盡快回復您。

如果有更多的貼子釋出,我們將通知你訂閱我們的新聞列表。

相關連結

Auth0 的 JWT 手冊

瀏覽 RS256 和 JWKS

爆破 HS256 是可能的: 使用強金鑰在簽署 JWT 的重要性

JSON Web 金鑰集(JWKS)

YouTube 上提供的視訊課程

看看 Angular 大學的 Youtube 頻道,我們釋出了大約 25% 到三分之一的視訊教程,新視訊一直在出版。

訂閱 獲取新的視訊教程:

Angular 上的其他帖子

同樣可以看看其他很受歡迎的帖子,你可能會覺得有趣:


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章