Golang — JSON Web Tokens(JWT)示範. 上次寫了JWT原理介紹，這次我們實際用Golang來試試JWT。基本上每個程式語…
By: 陳冠億 Kenny
Tags: Golang, Backend Development, Go, Jwt, Back End Development

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

裡面有更多文章內容，感謝！

上次寫了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主要就是做三件事情：

檢查Post Body是否有account、password 檢查account、password是否正確 若正確則給予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

}

首先檢查account、password是否符合Kenny及123456，當然這是為了示範，實務上應該要去資料庫存取，而且password也不應該是明碼。 接著我們就可以用前面宣告的Claims型態來定義我們的JWT，這邊基本上就是把Account、Role、標準的JWT Payload要有的資訊都填進去。當然看應用場景而定，簡單的話其實只要定義ExpiresAt即可。這邊設定是當發放JWT後的20秒該token就過期了，而NotBefore意思是在什麼時間點內該token是無效的，這邊定義發放後的10秒為無效。 定義好我們的JWT，接著就進行簽名，採用jwt.SigningMethodHS256演算法，並把前面定義的secret ket丟進去。如果這邊你key的型態是錯誤的，在這邊就會捕捉到錯誤。 若沒錯誤，就回傳正確的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，而後面則是需要自己額外定義驗證加入的自定義欄位，這時候可能就可以想說如何去改造輪子，用起來更加舒適。