Golang — JSON Web Tokens(JWT)示範

陳冠億 Kenny
企鵝也懂程式設計
25 min readFeb 10, 2020

首先對於我的文章感興趣的,可以參考我個人部落格:

裡面有更多文章內容,感謝!

上次寫了JWT原理介紹,這次我們實際用Golang來試試JWT。基本上每個程式語言裡面都會有許多開源的JWT程式庫,雖然JWT的原理並不難理解,實作起來是需要考慮許多細節的,所以通常如果有好的輪子,建議就是用輪子,然後再好好閱讀輪子的原始碼,讓使用上可以更順手。

如何選擇好的JWT程式庫?

事實上,JWT官網首頁就有提供許多程式語言開源的JWT程式庫,還很貼心地列出該程式庫有提供哪些功能,例如實作了哪些JWT加密演算法及在驗證JWT上有提供哪些檢查。此外,還提供了GitHub網址及Star數量,畢竟開源庫的Star數量可以保證一定的品質。

今天的JWT示範用Golang程式語言,因此我們看到:

這是一個比較知名的golang JWT庫,在上面可以看到它提供了哪些演算法跟check的支援。此次示範就使用該程式庫。

jwt-go 程式庫示範

這次示範用RESTful API的方式來呈現。

專案名稱:ginJWT

此次專案用到的Web框架是gin,這是Golang中我最喜歡的Web框架,有機會在寫文章介紹。並且使用Go Modules的方式來管理第三方套件,如果不知道GOPATH、Go Modules差別的話,{% post_link Golang-GOROOT、GOPATH、Go-Modules-三者的關係介紹 請點擊這裡 %}

建立一個main.go,直接來看我寫的程式碼:

package main

import (
"fmt"
"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
"net/http"
"strconv"
"strings"
"time"
)

// custom claims
type Claims struct {
Account string `json:"account"`
Role string `json:"role"`
jwt.StandardClaims
}

// jwt secret key
var jwtSecret = []byte("secret")

func main() {
router := gin.Default()

router.POST("/login", func(c *gin.Context) {
// validate request body
var body struct{
Account string
Password string
}
err := c.ShouldBindJSON(&body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}

// check account and password is correct
if body.Account == "Kenny" && body.Password == "123456" {
now := time.Now()
jwtId := body.Account + strconv.FormatInt(now.Unix(), 10)
role := "Member"

// set claims and sign
claims := Claims{
Account: body.Account,
Role: role,
StandardClaims: jwt.StandardClaims{
Audience: body.Account,
ExpiresAt: now.Add(20 * time.Second).Unix(),
Id: jwtId,
IssuedAt: now.Unix(),
Issuer: "ginJWT",
NotBefore: now.Add(10 * time.Second).Unix(),
Subject: body.Account,
},
}
tokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
token, err := tokenClaims.SignedString(jwtSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}

c.JSON(http.StatusOK, gin.H{
"token": token,
})
return
}

// incorrect account or password
c.JSON(http.StatusUnauthorized, gin.H{
"message": "Unauthorized",
})
})

// protected member router
authorized := router.Group("/")
authorized.Use(AuthRequired)
{
authorized.GET("/member/profile", func(c *gin.Context) {
if c.MustGet("account") == "Kenny" && c.MustGet("role") == "Member" {
c.JSON(http.StatusOK, gin.H{
"name": "Kenny",
"age": 23,
"hobby": "music",
})
return
}

c.JSON(http.StatusNotFound, gin.H{
"error": "can not find the record",
})
})
}

router.Run()
}

// validate JWT
func AuthRequired(c *gin.Context) {
auth := c.GetHeader("Authorization")
token := strings.Split(auth, "Bearer ")[1]

// parse and validate token for six things:
// validationErrorMalformed => token is malformed
// validationErrorUnverifiable => token could not be verified because of signing problems
// validationErrorSignatureInvalid => signature validation failed
// validationErrorExpired => exp validation failed
// validationErrorNotValidYet => nbf validation failed
// validationErrorIssuedAt => iat validation failed
tokenClaims, err := jwt.ParseWithClaims(token, &Claims{}, func(token *jwt.Token) (i interface{}, err error) {
return jwtSecret, nil
})

if err != nil {
var message string
if ve, ok := err.(*jwt.ValidationError); ok {
if ve.Errors & jwt.ValidationErrorMalformed != 0 {
message = "token is malformed"
} else if ve.Errors & jwt.ValidationErrorUnverifiable != 0{
message = "token could not be verified because of signing problems"
} else if ve.Errors & jwt.ValidationErrorSignatureInvalid != 0 {
message = "signature validation failed"
} else if ve.Errors & jwt.ValidationErrorExpired != 0 {
message = "token is expired"
} else if ve.Errors & jwt.ValidationErrorNotValidYet != 0 {
message = "token is not yet valid before sometime"
} else {
message = "can not handle this token"
}
}
c.JSON(http.StatusUnauthorized, gin.H{
"error": message,
})
c.Abort()
return
}

if claims, ok := tokenClaims.Claims.(*Claims); ok && tokenClaims.Valid {
fmt.Println("account:", claims.Account)
fmt.Println("role:", claims.Role)
c.Set("account", claims.Account)
c.Set("role", claims.Role)
c.Next()
} else {
c.Abort()
return
}
}

這邊為了方便示範JWT在RESTful API的作用,這邊只建立一個go檔案進行所有事情,並沒有做好的分層架構。

來一一解釋每段code的作用~

建立custom claims

// custom claims
type Claims struct {
Account string `json:"account"`
Role string `json:"role"`
jwt.StandardClaims
}

在JWT原理介紹說過,JWT包含三大部分,分別是Header、Payload、Signature,這邊定義的Claims是指Payload的部分,例如想要在Payload裡面放使用者的Account(帳號)、Role(角色)資訊就可以在這邊定義,此外要加上一個jwt.StandardClaims作為Embedded,這個代表會加入標準的JWT Payload應有的屬性,例如:exp、iat、nbf等等。

建立 JWT SecretKey

// jwt secret key
var jwtSecret = []byte("secret")

這次示範採用JWT的簽名演算法是用HS256,這個是對稱式演算法,共用相同一把金鑰,因此要特別注意該密鑰不能外流,而在程式碼中定義也是相當危險的行為,事實上這種資料建議都採用環境變數的方式來存取,例如還有資料庫連線資訊等等,都應用環境變數的方式。

如果想知道JWT HS256跟RS256兩者演算法的差別及使用情境,可參考:https://stackoverflow.com/questions/39239051/rs256-vs-hs256-whats-the-difference

在簡單的情況下可採用HS256即可。

然後要記住型態要宣告成[]byte,否則會出現key is of invalid type的錯誤訊息。

定義login API路由

router.POST("/login", func(c *gin.Context) {
// validate request body
var body struct{
Account string
Password string
}
err := c.ShouldBindJSON(&body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}

// check account and password is correct
if body.Account == "Kenny" && body.Password == "123456" {
now := time.Now()
jwtId := body.Account + strconv.FormatInt(now.Unix(), 10)
role := "Member"

// set claims and sign
claims := Claims{
Account: body.Account,
Role: role,
StandardClaims: jwt.StandardClaims{
Audience: body.Account,
ExpiresAt: now.Add(20 * time.Second).Unix(),
Id: jwtId,
IssuedAt: now.Unix(),
Issuer: "ginJWT",
NotBefore: now.Add(10 * time.Second).Unix(),
Subject: body.Account,
},
}
tokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
token, err := tokenClaims.SignedString(jwtSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}

c.JSON(http.StatusOK, gin.H{
"token": token,
})
return
}

// incorrect account or password
c.JSON(http.StatusUnauthorized, gin.H{
"message": "Unauthorized",
})
})

這個login API主要就是做三件事情:

  1. 檢查Post Body是否有account、password
  2. 檢查account、password是否正確
  3. 若正確則給予JWT,不正確則回傳錯誤訊息

因此在前面是用了gin框架檢查Post Body的方式:

// validate request body
var body struct{
Account string
Password string
}
err := c.ShouldBindJSON(&body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": err.Error(),
})
return
}

主要要講解的是如何發放JWT:

// check account and password is correct
if body.Account == "Kenny" && body.Password == "123456" {
now := time.Now()
jwtId := body.Account + strconv.FormatInt(now.Unix(), 10)
role := "Member"

// set claims and sign
claims := Claims{
Account: body.Account,
Role: role,
StandardClaims: jwt.StandardClaims{
Audience: body.Account,
ExpiresAt: now.Add(20 * time.Second).Unix(),
Id: jwtId,
IssuedAt: now.Unix(),
Issuer: "ginJWT",
NotBefore: now.Add(10 * time.Second).Unix(),
Subject: body.Account,
},
}
tokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
token, err := tokenClaims.SignedString(jwtSecret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}

c.JSON(http.StatusOK, gin.H{
"token": token,
})
return
}
  1. 首先檢查account、password是否符合Kenny及123456,當然這是為了示範,實務上應該要去資料庫存取,而且password也不應該是明碼。
  2. 接著我們就可以用前面宣告的Claims型態來定義我們的JWT,這邊基本上就是把Account、Role、標準的JWT Payload要有的資訊都填進去。當然看應用場景而定,簡單的話其實只要定義ExpiresAt即可。這邊設定是當發放JWT後的20秒該token就過期了,而NotBefore意思是在什麼時間點內該token是無效的,這邊定義發放後的10秒為無效。
  3. 定義好我們的JWT,接著就進行簽名,採用jwt.SigningMethodHS256演算法,並把前面定義的secret ket丟進去。如果這邊你key的型態是錯誤的,在這邊就會捕捉到錯誤。
  4. 若沒錯誤,就回傳正確的JWT給用戶端。

定義AuthRequired函式來檢查JWT是否正確

當發放JWT出去,當使用者要存取受保護的API時,都應該帶著JWT,而後端都要檢查該JWT是否正確,如果正確才給予存取API,否則不允許。

直接看code:

// validate JWT
func AuthRequired(c *gin.Context) {
auth := c.GetHeader("Authorization")
token := strings.Split(auth, "Bearer ")[1]

// parse and validate token for six things:
// validationErrorMalformed => token is malformed
// validationErrorUnverifiable => token could not be verified because of signing problems
// validationErrorSignatureInvalid => signature validation failed
// validationErrorExpired => exp validation failed
// validationErrorNotValidYet => nbf validation failed
// validationErrorIssuedAt => iat validation failed
tokenClaims, err := jwt.ParseWithClaims(token, &Claims{}, func(token *jwt.Token) (i interface{}, err error) {
return jwtSecret, nil
})

if err != nil {
var message string
if ve, ok := err.(*jwt.ValidationError); ok {
if ve.Errors & jwt.ValidationErrorMalformed != 0 {
message = "token is malformed"
} else if ve.Errors & jwt.ValidationErrorUnverifiable != 0{
message = "token could not be verified because of signing problems"
} else if ve.Errors & jwt.ValidationErrorSignatureInvalid != 0 {
message = "signature validation failed"
} else if ve.Errors & jwt.ValidationErrorExpired != 0 {
message = "token is expired"
} else if ve.Errors & jwt.ValidationErrorNotValidYet != 0 {
message = "token is not yet valid before sometime"
} else {
message = "can not handle this token"
}
}
c.JSON(http.StatusUnauthorized, gin.H{
"error": message,
})
c.Abort()
return
}

if claims, ok := tokenClaims.Claims.(*Claims); ok && tokenClaims.Valid {
fmt.Println("account:", claims.Account)
fmt.Println("role:", claims.Role)
c.Set("account", claims.Account)
c.Set("role", claims.Role)
c.Next()
} else {
c.Abort()
return
}
}

這個是類似一個middleware的功能,可以加在受保護API的前面,也就是當存取受保護API時,都要先經過該函式,因此這邊就可以定義JWT Verify的功能。

首先先看:

// parse and validate token for six things:
// validationErrorMalformed => token is malformed
// validationErrorUnverifiable => token could not be verified because of signing problems
// validationErrorSignatureInvalid => signature validation failed
// validationErrorExpired => exp validation failed
// validationErrorNotValidYet => nbf validation failed
// validationErrorIssuedAt => iat validation failed
tokenClaims, err := jwt.ParseWithClaims(token, &Claims{}, func(token *jwt.Token) (i interface{}, err error) {
return jwtSecret, nil
})

這邊是go-jwt程式庫幫我們定義好的Verify的函式jwt.ParseWithClaims,它會幫我們做兩件事情:

  • Parse JWT
  • Validate JWT

但注意:需要提供一個func參數,這個func參數是要定義一個函式,而這個函式是要回傳secret key值跟error。在這邊可以看到我直接回傳了secret key跟nil,也就是沒做任何的條件判斷。事實上,你可以加入一些條件判斷,例如判斷讀取secret key環境變數是否成功,成功則回傳正確的key,不成功則回傳你自定義的error。那麼經由jwt.ParseWithClaims的時候,回傳的error就是你自定義的error,如果你回傳錯的key進去,而沒有傳自定義的error,則會回傳validationErrorUnverifiable,這個是由go-jwt幫我們定義好的error型態。

因此我們就可以來透過error型態來判斷JWT遇到什麼錯誤:

if err != nil {
message := err.Error()
if ve, ok := err.(*jwt.ValidationError); ok {
if ve.Errors & jwt.ValidationErrorMalformed != 0 {
message = "token is malformed"
} else if ve.Errors & jwt.ValidationErrorUnverifiable != 0{
message = "token could not be verified because of signing problems"
} else if ve.Errors & jwt.ValidationErrorSignatureInvalid != 0 {
message = "signature validation failed"
} else if ve.Errors & jwt.ValidationErrorExpired != 0 {
message = "token is expired"
} else if ve.Errors & jwt.ValidationErrorNotValidYet != 0 {
message = "token is not yet valid before sometime"
} else {
message = "can not handle this token"
}
}
c.JSON(http.StatusUnauthorized, gin.H{
"error": message,
})
c.Abort()
return
}

jwt.ValidationError是go-jwt幫我們定義好了,方便我們檢驗錯誤的型態。因此在這邊可以透過型態判斷來得知屬於哪種error,這邊是採用位元運算來判斷。假設兩者的error是一樣的,它們的數字應該要一樣,因此進行 & 運算,應 != 0。

所以如果剛剛在jwt.ParseWithClaims的func參數你有回傳自定義的error的話,這邊就不會進入jwt.ValidationError這邊,因為是不屬於該型態的,而是回傳你自定義的錯誤訊息。

最後,如果JWT是合法的話,就會進入下面這裡:

if claims, ok := tokenClaims.Claims.(*Claims); ok {
if claims.Account == "" || claims.Role == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "JWT token payload is improper",
})
c.Abort()
return
} else {
fmt.Println("account:", claims.Account)
fmt.Println("role:", claims.Role)
c.Set("account", claims.Account)
c.Set("role", claims.Role)
c.Next()
}
} else {
c.JSON(http.StatusUnauthorized, gin.H{
"error": "JWT token payload is improper",
})
c.Abort()
return
}

首先將jwt.ParseWithClaims回傳的tokenCliams進行型態判斷,理論上會是自定義的Cliams型態,如果不正確的話代表就是不合法的JWT,如果是是自定義的Cliams型態,可以在判斷是否有自定義的欄位,如果沒有的話也是當作不合法的JWT。如果有的話,就可以正式判斷該JWT是合法的,可以存取JWT Payload裡面的內容比較傳遞給受保護的API,讓其可以使用該資訊。

受保護API路由

// protected member router
authorized := router.Group("/")
authorized.Use(AuthRequired)
{
authorized.GET("/member/profile", func(c *gin.Context) {
if c.MustGet("account") == "Kenny" && c.MustGet("role") == "Member" {
c.JSON(http.StatusOK, gin.H{
"name": "Kenny",
"age": 23,
"hobby": "music",
})
return
}

c.JSON(http.StatusNotFound, gin.H{
"error": "can not find the record",
})
})
}

這邊就不多加講解了,也就是當JWT是合法後,就能進入該API,而API也就能拿到JWT Payload裡面的資訊。

總結

jwt-go是個老牌的程式庫,但是其文件並沒有寫得很清楚,很多使用方式都是我去看source code來理解出來的。不過還好source code並沒有到很複雜,我覺得嘛雖然很多輪子都有前人造好了,但是還是要學會看原始碼的功力,這樣的話你才能有辦法去改造輪子,畢竟有時候輪子可能並不是很符合你的需求,有時候需求一改,可能就需要改造輪子來符合你的需求。像我覺得最後一段還要額外判斷是否有自定義的欄位在payload就很多餘,應該要在前面parse的那個函式就做掉會更理想。當然也可以想成,前面的jwt.ParseWithClaims是在驗證標準JWT Payload,而後面則是需要自己額外定義驗證加入的自定義欄位,這時候可能就可以想說如何去改造輪子,用起來更加舒適。

--

--