Day29-關於 JWT 驗證

關於 JWT 驗證

前言

接下來這一篇將會來介紹 JWT 的驗證,畢竟前面一直都沒提到,可是實戰上來講卻是非常重要的一環哩。

身份認證

為什麼要身份認證呢?前面的許多章節中,其實我們都沒有做身份認證的機制,舉凡 API、Discord、Chrome Extension 等等,都沒有做身份認證,那麼為什麼要做身份認證呢?

我們所開發的東西有些功能是僅限於特定的人才能使用,例如:管理員、VIP 等等,這時候我們就需要一個機制來做身份認證,那麼這時候就會提到 JWT(JSON Web Token)了。

而現今主流開發比較常見於前後端分離的架構,不再是傳統的 SSR(Server Side Rendering)的架構,前端大多都是透過後端所提供的 API 來取得資料,而這個取資料的過程就必須要被認證。

Note
早期開發時,使用者登入後,後端會將使用者的 UID 儲存在伺服器的記憶體中(Session),但因為現今主流開發事前後端分離的架構,因此這種方式已經不適用了,因此就有了 JWT。

JSON Web Token 是什麼?

剛才有提到 JWT 的全名是 JSON Web Token,是一個廣泛被使用來驗證與授權的標準,尤其是在網頁開發上很常被使用,而且也是一個開放的標準(RFC 7519)。

基本上 JWT 的結構分為三個部分:

  • HEADER(標頭):ALGORITHM & TOKEN TYPE
    • 用來說明這一組 Token 是用什麼演算法加密的,以及 Token 的類型
  • PAYLOAD(負載):DATA
    • 主要是用來存放一些資料,例如使用者的 ID、名字等等
  • VERIFY SIGNATURE(驗證簽名)
    • 這一部分是用來驗證 Token 是否為正確的 Token
1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

很難看出其差異吧?如果我依據上面的結構來拆解的話,就會變成這樣:

1
HEADER.PAYLOAD.VERIFY SIGNATURE

搭配上方的 JWT 範例,就會變成這樣:

1
2
3
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.  # HEADER(標頭)
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ. # PAYLOAD(負載)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c # VERIFY SIGNATURE(驗證簽名)

JWT 原理

那麼 JWT 是如何實作出來的呢?剛才有提到 JWT 的結構分為三個部分,其實比較核心的部分是 PAYLOAD(負載)與 VERIFY SIGNATURE(驗證簽名),但我們還是針對 JWT 三個部分來做介紹,而這邊示範的範例會是前面所講的 JWT Token 範例:

1
2
3
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.  # HEADER(標頭)
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ. # PAYLOAD(負載)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c # VERIFY SIGNATURE(驗證簽名)

HEADER(標頭)

HEADER 主要組合會是兩個部分:

  • alg(Algorithm):加密演算法
  • typ(Type):Token 類型

而這兩個部分會被編碼成 Base64 字串,例如:

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.  # HEADER(標頭)

PAYLOAD(負載)

PAYLOAD(負載)的部分,其實就是一個 JSON 物件,例如:

1
2
3
4
5
{
"username": "Ray", // 使用者名稱
"email": "example.com", // 使用者信箱
"iat": 1516239022 // 簽發(Issued At)時間
}

而這個 JSON 物件會被編碼成 Base64 字串,例如:

1
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ. # PAYLOAD(負載)

感覺 PAYLOAD 滿單純的吧?但實際上它是由三個部分組成的,分別是:

  • Registered Claim(註冊聲明)
  • Public Claim(公開聲明)
  • Private Claim(私有聲明)

而這三個部分都是選填的,但實戰上來講,通常都會使用 Public Claim(公開聲明)來存放一些資料,例如:使用者的 ID、名字等等。

比較常見的設定會是 Registered Claim 中的 iat(Issued At)與 exp(Expiration Time),而這兩個屬性可以用來驗證 Token 是否過期等。

當然,Registered Claim 中還有很多屬性,例如:

  • iss(Issuer):發行者
  • sub(Subject):主體
  • aud(Audience):對象
  • exp(Expiration Time):過期時間
  • nbf(Not Before):生效時間
  • iat(Issued At):簽發時間
  • jti(JWT ID):JWT ID

因此以上面前面的 JSON 物件來說,就會變成這樣:

1
2
3
4
5
{
"username": "Ray", // 使用者名稱 => Public Claim(公開聲明)
"email": "example.com", // 使用者信箱 => Public Claim(公開聲明)
"iat": 1516239022 // 簽發(Issued At)時間 => Registered Claim(註冊聲明)
}

Note
要注意一下 Claim(聲明)的單字只有三個字母,因為 JWT 旨在簡潔與輕量化,因此 Claim(聲明)的單字只有三個字母。

VERIFY SIGNATURE(驗證簽名)

VERIFY SIGNATURE(驗證簽名)的部分,其實就是將 PAYLOAD(負載)與 HEADER(標頭)進行編碼,然後再進行加密,例如:

1
2
3
4
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)

那麼問題來了,JWT 是如何知道要用什麼演算法來加密呢?其實就是透過 HEADER(標頭)來取得,例如:

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

如果今天是用 PS256 演算法來加密的話,就會變成這樣:

1
2
3
4
{
"alg": "PS256",
"typ": "JWT"
}

接著就會將 PAYLOAD(負載)與 HEADER(標頭)進行編碼,然後再進行加密,例如:

1
2
3
4
PS256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)

而這個加密後的字串也會被編碼成 Base64 字串。

那 secret 是什麼呢?其實就是一個私鑰,我們在針對 JWT TOken 生成時,會需要使用一組私鑰,而這組私鑰會被用來加密,而當我們要驗證 JWT Token 時,就會需要使用這組私鑰來解密。

那麼另一個問題來了,我們生成的 JWT Token:

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

你可以把它丟到 jwt.io 這個網站上,然後你就會發現它會自動幫你解析,並且告訴你這個 Token 是用什麼演算法加密的,以及 Token 的類型,如下圖:

JWT Token

好的,問題來了

「為什麼我們沒有提供私鑰(secret),但是它卻可以解析出來呢?」

因為 JWT Token 只是透過 Base64 編碼,而並沒有進行加密,因此你是可以自己寫一個 JWT Token 解析器,這邊我也簡單示範寫一個 JWT Token 解析器範例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Base64 解碼
const Base64decoded = (str) => {
str = str.replace(/-/g, '+').replace(/_/g, '/');

// 如果字串長度不是 4 的倍數,補上 "="
while (str.length % 4 !== 0) {
str += '=';
}

return window.atob(str);
}

// 解析 JWT Token
const jwtTokenEncoded = (str) => {
if(!str) return console.log('請輸入 JWT Token');
const [header, payload, signature] = str.split('.');

const headerDecoded = JSON.parse(Base64decoded(header));
const payloadDecoded = JSON.parse(Base64decoded(payload));

return {
header: headerDecoded,
payload: payloadDecoded,
}
}

jwtTokenEncoded('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c')

接著你貼到瀏覽器上就可以看到解析結果了,如下圖:

解析結果

因此實際上來講 JWT Token 是用 Base64 編碼的,而且有一定的安全性問題,所以千萬不要把敏感資訊放在 JWT Token 中,例如:使用者的密碼、使用者的信用卡資訊等等。

JWT Token 核心驗證的方式主要在於剛剛提到的 secret(私鑰),因此就算我們解析出來了,但是沒有私鑰的話,也是無法驗證成功。

那麼透過這一篇,我相信你應該對於 JWT Token 有一定的了解了,接下來我們就來看看如何實作 JWT Token 吧!

實作 JWT Token 發放

JWT Token 在 Node.js 上使用非常的簡單,只需要安裝 jsonwebtoken 套件

1
npm install jsonwebtoken

用法也很簡單,只需要安裝 jsonwebtoken 套件並這樣寫就可以了:

1
2
3
4
5
6
7
8
const jwt = require('jsonwebtoken');

const token = jwt.sign({
username,
email: 'example.com',
}, 'secret', { expiresIn: 60 });

console.log(token); // 這邊就會印出 JWT Token

jwt.sign 會接受三個參數:

  • payload:PAYLOAD(負載)
  • secret:私鑰
  • options:選項,也就是 Registered Claim(註冊聲明)

jwt.sign 會回傳一個 JWT Token,這個 JWT Token 就是我們要發放給使用者的 Token。

往後前端需要請求某些需要權限或驗證的 API 時,就可以將這個 JWT Token 放在 Header 中,例如:

1
2
3
4
5
axios.get('https://example.com/api', {
headers: {
Authorization: `Bearer ${token}`,
},
});

接著後端只需要使用 jwt.verify 就可以驗證 JWT Token 是否正確,例如:

1
2
3
4
5
6
7
8
9
10
const jwt = require('jsonwebtoken');

const token = jwt.sign({
username,
email: 'example.com',
}, 'secret', { expiresIn: 60 });

const decoded = jwt.verify(token, 'secret');

console.log(decoded); // 這邊就會印出解析後的 JWT Token

是不是超簡單的呢?這邊我就不額外提供範例了,你可以再試著自己嘗試看看。

Note
Bearer 是一種 HTTP 認證方式,你可以參考 Authentication 這篇文章。

那麼這一篇也差不多了,我們下一篇見哩。