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

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

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

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

因爲身份驗證一樣須要一些服務端代碼,因此咱們將同時顯示這些信息,以便咱們能夠掌握整個上下文,而且看清楚各個部分之間如何協做。ios

服務端代碼是 Node/Typescript,Angular 開發者對這些應該是很是熟悉的。可是涵蓋的概念並非特定於 Node 的。git

若是你使用另外一種服務平臺,主須要在 jwt.io 上爲你的平臺選擇一個 JWT 庫,這些概念仍然適用。github

目錄

在這篇文章中,咱們將介紹如下主題:web

  • 第一步 —— 登錄頁面
    • 基於 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 的認證吧!express

基於 JWT 的用戶會話

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

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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索