JWT
JWT 已經成為了現代服務端通信認證的主流方式之一,具有輕量,無狀態的特點。
簡介
JWT 全名 JSON Web Tokens,關於 JWT 的詳細介紹可以在[jwt.io](JSON Web Token Introduction - jwt.io)查看,它是一種開放的,安全的,緊湊的,以 JSON 對象為載體在服務雙方之間傳輸信息的方式,它的特點就是安全性高,內容防篡改,消耗低。
結構
在 RFC 標准中,JWT 由以下三個部分組成:
- Header 頭部
- Payload 載荷
- Signature 簽名
然後每一個部分用一個點.來分隔,最後組成一個字符串,格式就是header.payload.signature嗎,這就是一個 JWT 令牌的標准結構,接下來就一個個講解每個結構的作用。
TIP
需要注意的是base64與base64URL不是同一種編碼方式,後者兼容了網頁 URL,對其進行了轉義。
頭部
頭部只是聲明一些基本信息,通常由兩部分組成,令牌的類型,和簽名所使用的加密算法,例如下方:
{
"alg": "HS256",
"typ": "JWT"
}以上的信息大致就是,令牌的類型為 JWT,簽名部分所使用的加密算法為 HS256,最後再將 JSON 對象通過Base64Url編碼成字符串,該字符串就是 JWT 的頭部。
載荷
JWT 的第二部分是載荷部分,主要包含聲明(claims)部分,聲明部分通常是關於一個實體的數據,比如一個用戶。關於聲明的類型總共有三種:
reigstered:Registered claims代表著 一些預定義的聲明,一些並不強制使用但是仍然推薦使用,例如:iss(issuer 簽發者),exp(expiration time 過期時間) ,aud(audience 受眾)。public:Public claims是可以由使用 JWT 的人隨意定義的,最好要避免和其他聲明部分沖突。private claims:這部分的聲明同樣也是自定義的,通常用於在服務雙方共享一些信息。
一個載荷示例如下:
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}該 JSON 對象將會通過Base64Url被編碼成字符串,從而組成 JWT 的第二部分。
DANGER
雖然載荷部分也受到保護,也有防篡改,但是這一部分是公共可讀的,所以不要把敏感信息存放在 JWT 內。
簽名
在獲得了編碼的頭部和編碼的載荷部分後,就可以通過頭部所指明的簽名算法根據前兩個部分的內容再加上密鑰進行加密簽名,所以一旦 JWT 的內容有任何變化,解密時得到的簽名都會不一樣,同時如果是使用私鑰,也可以對 JWT 的簽發者進行驗證。
sign = HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)例如下方的例子:
Header
{
"alg": "HS256",
"typ": "JWT"
}
Payload
{
"alg": "HS256",
"typ": "JWT"
}
Verify Signature
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your secret
)最後得到的輸出就是一個由三個base64Url字符串組成且由.分隔的字符串,大概長下面這樣
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
cThIIoDvwdueQB468K5xDc5633seEFoqwxjF_xSJyQQ工作原理
在身份驗證中,當用戶使用憑據成功登錄時,將返回一個 JSON Web 令牌。由於令牌是憑證,因此必須非常小心地防止出現安全問題。一般來說,令牌的保存時間不應超過所需的時間。然後無論何時用戶想要訪問受保護的路由和資源,在發起請求時就必須攜帶上 token,通常都是在請求頭中的Authorization header 中的Bearer schema,例如下方:
Authorization: Bearer <token>服務器在收到 JWT 後,會對其進行有效性驗證,例如內容有篡改,token 已過期等等,如果驗證通過就可以順利的訪問資源。雖然 JWT 中可以攜帶一些基本信息,但是依舊建議信息不要太大。
JWT 庫
官方倉庫:golang-jwt/jwt: Community maintained clone of https://github.com/dgrijalva/jwt-go
官方文檔:jwt package - github.com/golang-jwt/jwt/v4 - Go Packages
這個庫支持解析和驗證以及 JWT 的生成和簽名。目前支持的簽名算法有 HMAC SHA, RSA, RSA-PSS, 和 ECDSA,不過也可以添加自己的鉤子。
安裝
go get -u github.com/golang-jwt/jwt/v4導入
import "github.com/golang-jwt/jwt/v4"選擇簽名算法
可用的簽名算法有好幾種,在使用之前應該先了解下它們之間的區別以便更好的去選擇簽名算法,它們之間最大的不同就是對稱加密和非對稱加密。
最簡單的對稱加密算法HSA,讓任何[]byte都可以用作有效的密鑰,所以計算速度稍微快一點。在生產者和消費者雙方都是可以被信任的時候,對稱加密算法的效率是最高的。不過由於簽名和驗證都使用相同的密鑰,因此無法輕松的分發用於驗證的密鑰,畢竟簽名的密鑰也是同一個,簽名洩露了則 JWT 的安全性就毫無意義。
非對稱加密簽名方法,例如RSA,使用不同的密鑰來進行簽名和驗證 token,這使得生成帶有私鑰的令牌成為可能,同時也允許任何使用公鑰驗證的人正常訪問。
不同的簽名算法所需要的密鑰的類型也不同,下面給出一些常見簽名算法的類型:
HMAC:對稱加密,需要類型[]byte的值用於簽名和驗證。 (HS256,HS384,HS512)RSA:非對稱加密,需要*rsa.PrivateKey類型的值用於簽名,和*rsa.PublicKey類型的值用於驗證。(RS256,RS384,RS512)ECDSA:非對稱加密,需要*ecdsa.PrivateKey類型的值用於簽名,和*ecdsa.PublicKey類型的值用於驗證。(ES256,ES384,ES512)EdDSA:非對稱加密,需要ed25519.PrivateKey類型的值用於簽名和ed25519.PublicKey類型的值用於驗證。(Ed25519)
示例
下面會演示一些示例,有關於 jwt 的創建與簽名,已經解析與驗證。
type Token struct {
Raw string // 原始Token字符串,當開始解析時填充此字段
Method SigningMethod // 簽名使用的方法
Header map[string]interface{} // JWT的header部分
Claims Claims // JWT的payload部分
Signature string // JWT的簽名部分,當開始解析時填充此字段
Valid bool // JWT是否合法有效
}Token 結構體代表了一個 JWT Token,字段的使用主要取決於 JWT 是如何被創建/簽名或解析/驗證的。
type RegisteredClaims struct {
// the `iss` (Issuer) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.1
Issuer string `json:"iss,omitempty"`
// the `sub` (Subject) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.2
Subject string `json:"sub,omitempty"`
// the `aud` (Audience) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3
Audience ClaimStrings `json:"aud,omitempty"`
// the `exp` (Expiration Time) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4
ExpiresAt *NumericDate `json:"exp,omitempty"`
// the `nbf` (Not Before) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.5
NotBefore *NumericDate `json:"nbf,omitempty"`
// the `iat` (Issued At) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.6
IssuedAt *NumericDate `json:"iat,omitempty"`
// the `jti` (JWT ID) claim. See https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.7
ID string `json:"jti,omitempty"`
}這是庫中提供的預定義 Claims,可以適當使用以達到目的需求。
例 1.HMAC 的創建與簽名
func TestHmac(t *testing.T) {
// hmac的密鑰類型是字節數組
secret := []byte("my secret")
// 使用HS256算法,jwt.MapClaims是payload
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"id": 123456,
"name": "jack",
})
fmt.Printf("%+v\n", *token)
// 簽名
signedString, err := token.SignedString(secret)
fmt.Println(signedString, err)
}輸出:
{Raw: Method:0xc000008150 Header:map[alg:HS256 typ:JWT] Claims:map[id:123456 name:jack] Signature: Valid:false}
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpZCI6MTIzNDU2LCJuYW1lIjoiamFjayJ9.
QxLw9NkFgZW3BluyXIofe4efp1IAy61s8b2fe3Eo86M
<nil>例 3.使用預定義 Claims
mySigningKey := []byte("AllYourBase")
// 創建Claims
claims := &jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Unix(1516239022, 0)),
Issuer: "test",
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
ss, err := token.SignedString(mySigningKey)
fmt.Printf("%v %v", ss, err)輸出:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJpc3MiOiJ0ZXN0IiwiZXhwIjoxNTE2MjM5MDIyfQ.
0XN_1Tpp9FszFOonIBpwha0c_SfnNI22DhTnjMshPg8
<nil>例 3.自定義 Claims
type MyClaims struct {
User string `json:"user"`
jwt.RegisteredClaims
}
func TestCustomClaims(t *testing.T) {
// 創建密鑰
secret := []byte("my secret")
// 創建Claims
claims := MyClaims{
User: "114514",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "Server",
},
}
// 創建Token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// 簽名
signedString, err := token.SignedString(secret)
fmt.Println(signedString, err)
}輸出:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ1c2VyIjoiMTE0NTE0IiwiaXNzIjoiU2VydmVyIiwiZXhwIjoxNjczMDg1Nzk2LCJuYmYiOjE2NzMwODIxOTYsImlhdCI6MTY3MzA4MjE5Nn0.
PdPXdQBbDuYtE4ENXzoAcrW-dBSxqsufeYXCT5zTwVI
<nil>TIP
當在自定義 Claims 中嵌入了標准 Claims 時,需要確保:
1.嵌入的標准 Claims 是非指針類型
2.如果是指針類型,最好確保在傳遞之前為其分配合適的內存,否則將會 panic。
例 4.HMAC 解析驗證 Token
func TestParse(t *testing.T) {
secret := []byte("my secret")
// 假設通過HS256算法創建並簽名生成了一個token
tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzNDU2LCJuYW1lIjoiamFjayJ9.QxLw9NkFgZW3BluyXIofe4efp1IAy61s8b2fe3Eo86M"
// 傳入token字符串和驗證鉤子函數,返回值就是一個Token結構體
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 驗證簽名算法是否匹配
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("不匹配的簽名算法: %s", token.Header["alg"])
}
// 返回驗證密鑰
return secret, nil
})
if err != nil {
fmt.Println(token, err)
}
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
fmt.Println(claims)
} else {
fmt.Println(err)
}
}輸出:
map[id:123456 name:jack]例 5.錯誤處理
func TestProcess(t *testing.T) {
secret := []byte("my secret")
// 假設通過HS256算法創建並簽名生成了一個token
tokenString := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTIzNDU2LCJuYW1lIjoiamFjayJ9.QxLw9NkFgZW3BluyXIofe4efp1IAy61s8b2fe3Eo86M"
// 傳入token字符串和驗證鉤子函數,返回值就是一個Token結構體
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 驗證簽名算法是否匹配
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("不匹配的簽名算法: %s", token.Header["alg"])
}
// 返回驗證密鑰
return secret, nil
})
if token.Valid {
fmt.Println("token合法")
} else if errors.Is(err, jwt.ErrTokenMalformed) {
fmt.Println("傳入的字符串甚至連一個token都不是...")
} else if errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet) {
fmt.Println("token已經過期或者還沒有生效")
} else {
fmt.Println("token處理異常...")
}
}輸出:
token合法例 6.自定義 Claims 解析
如果在創建 Token 時使用的是自定義 Claims,那麼在解析時如果希望 Claims 可以直接轉換自定義的 Claims 而不是 map,就需要傳入自定義 Claims。
func TestCustomClaimsParse(t *testing.T) {
secret := []byte("my secret")
tokenstring := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiMTE0NTE0IiwiaXNzIjoiU2VydmVyIiwiZXhwIjoxNjczMDg4MDU2LCJuYmYiOjE2NzMwODQ0NTYsImlhdCI6MTY3MzA4NDQ1Nn0.T245aoDeL2x19X8_JZde0EmZ2TDyIgr1u3ddKFjQmgw"
token, err := jwt.ParseWithClaims(tokenstring, &MyClaims{}, func(token *jwt.Token) (interface{}, error) {
return secret, nil
}, jwt.WithValidMethods([]string{"HS256"})) // 使用option進行驗證
// 類型斷言
if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
fmt.Println(claims)
} else {
fmt.Println(err)
}
}輸出:
&{114514 {Server [] 2023-01-07 18:40:56 +0800 CST 2023-01-07 17:40:56 +0800 CST 2023-01-07 17:40:56 +0800 CST }}例 7.RSA 的簽名與解析
RSA 在分布式架構中用的會比較多,大致過程如下:
- 認證中心創建密鑰對,使用私鑰將 jwt 簽名,jwt 返回給客戶端,公鑰則有業務服務持有
- 客戶端攜帶 jwt 向業務服務發起請求,業務模塊使用公鑰對 jwt 進行解析,無需訪問認證中心
- 認證通過則返回業務信息
- 認證失敗則返回失敗信息
func TestRsa(t *testing.T) {
// 創建密鑰對
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
publicKey := &privateKey.PublicKey
if err != nil {
fmt.Println(err)
return
}
// claims
claims := MyClaims{
User: "114514",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "Server",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
// 私鑰加密
signedString, err := token.SignedString(privateKey)
fmt.Println(signedString, err)
// 公鑰解密
token, err = jwt.ParseWithClaims(signedString, &MyClaims{}, func(token *jwt.Token) (interface{}, error) {
return publicKey, nil
})
if err != nil {
fmt.Println(err)
} else if claims, ok := token.Claims.(*MyClaims); ok && token.Valid {
fmt.Println(claims)
}
}