上次寫了 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 差別的話,請點擊這裡
建立一個 main.go,直接來看我寫的程式碼:
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 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154
| package main
import ( "fmt" "github.com/dgrijalva/jwt-go" "github.com/gin-gonic/gin" "net/http" "strconv" "strings" "time" )
type Claims struct { Account string `json:"account"` Role string `json:"role"` jwt.StandardClaims }
var jwtSecret = []byte("secret")
func main() { router := gin.Default()
router.POST("/login", func(c *gin.Context) { var body struct{ Account string Password string } err := c.ShouldBindJSON(&body) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": err.Error(), }) return }
if body.Account == "Kenny" && body.Password == "123456" { now := time.Now() jwtId := body.Account + strconv.FormatInt(now.Unix(), 10) role := "Member"
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 }
c.JSON(http.StatusUnauthorized, gin.H{ "message": "Unauthorized", }) })
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() }
func AuthRequired(c *gin.Context) { auth := c.GetHeader("Authorization") token := strings.Split(auth, "Bearer ")[1]
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
1 2 3 4 5 6
| 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
1 2
| 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 路由
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 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| router.POST("/login", func(c *gin.Context) { var body struct{ Account string Password string } err := c.ShouldBindJSON(&body) if err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": err.Error(), }) return }
if body.Account == "Kenny" && body.Password == "123456" { now := time.Now() jwtId := body.Account + strconv.FormatInt(now.Unix(), 10) role := "Member"
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 }
c.JSON(http.StatusUnauthorized, gin.H{ "message": "Unauthorized", }) })
|
這個 login API 主要就是做三件事情:
- 檢查 Post Body 是否有 account、password
- 檢查 account、password 是否正確
- 若正確則給予 JWT,不正確則回傳錯誤訊息
因此在前面是用了 gin 框架檢查 Post Body 的方式:
1 2 3 4 5 6 7 8 9 10 11 12
| 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:
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 28 29 30 31 32 33 34
| if body.Account == "Kenny" && body.Password == "123456" { now := time.Now() jwtId := body.Account + strconv.FormatInt(now.Unix(), 10) role := "Member"
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:
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 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| func AuthRequired(c *gin.Context) { auth := c.GetHeader("Authorization") token := strings.Split(auth, "Bearer ")[1]
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 的功能。
首先先看:
1 2 3 4 5 6 7 8 9 10
|
tokenClaims, err := jwt.ParseWithClaims(token, &Claims{}, func(token *jwt.Token) (i interface{}, err error) { return jwtSecret, nil })
|
這邊是 go-jwt 程式庫幫我們定義好的 Verify 的函式 jwt.ParseWithClaims,它會幫我們做兩件事情:
但注意:需要提供一個 func 參數,這個 func 參數是要定義一個函式,而這個函式是要回傳 secret key 值跟 error。在這邊可以看到我直接回傳了 secret key 跟 nil,也就是沒做任何的條件判斷。事實上,你可以加入一些條件判斷,例如判斷讀取 secret key 環境變數是否成功,成功則回傳正確的 key,不成功則回傳你自定義的 error。那麼經由 jwt.ParseWithClaims 的時候,回傳的 error 就是你自定義的 error,如果你回傳錯的 key 進去,而沒有傳自定義的 error,則會回傳 validationErrorUnverifiable,這個是由 go-jwt 幫我們定義好的 error 型態。
因此我們就可以來透過 error 型態來判斷 JWT 遇到什麼錯誤:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| 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 是合法的話,就會進入下面這裡:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| 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 路由
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| 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,而後面則是需要自己額外定義驗證加入的自定義欄位,這時候可能就可以想說如何去改造輪子,用起來更加舒適。
最後最後!請聽我一言!
如果你還沒有註冊 Like Coin,你可以在文章最下方看到 Like 的按鈕,點下去後即可申請帳號,透過申請帳號後可以幫我的文章按下 Like,而 Like 最多可以點五次,而你不用付出任何一塊錢,就能給我寫這篇文章的最大的回饋!