本章將介紹如何用 Go 實作 JWT-Token-Based 驗證的方法。我們將從實作登入的 REST API 開始,驗證 Username/Password,產生 JWT Token,以及驗證 JWT Token。
Table of Contents
建立 Go 專案
首先,我們先建立一個 Go 專案。專案的 package 是 github.com/xhhuango/waynestalk/gojwtexample
。
% mkdir GoJwtExample % cd GoJwtExample GoJwtExample % go mod init github.com/xhhuango/waynestalk/gojwtexample
此專案最終會有兩個 REST APIs,分別為登入(POST /login) 和取得使用者的資料(GET /info)。
登入流程為,取得前端傳來的 username 和 password。用這 username 去資料庫讀取使用者資料,然後比對 password 是否相符。如果相符,則產生 JWT token,輸出給前端,完成登入。
取得使用者資料的流程為,從前端 request 的 header 中取得 JWT token,並驗證此 token。如果驗證成功的話,從這 token 中取得使用者 ID。然後,用這 ID 去資料庫讀取使用者資料,輸出給前端。
建立資料庫
在進入 JWT 之前,讓我們先將資料庫先建立好。為了方便起見,我們在專案中使用 GORM 和 SQLite。我們在以下的文章有介紹 GORM 和 SQLite,供有興趣的讀者參考。
用以下指令來安裝 GORM。
GoJwtExample % go get -u gorm.io/gorm GoJwtExample % go get -u gorm.io/driver/sqlite
新增 user.go,並宣告 User struct
。
package main import "gorm.io/gorm" type User struct { gorm.Model Username string `json:"username"` Password string `json:"password"` Age uint `json:"age"` }
新增 repository.go,其程式碼如下。我們會用 insertUser() 來預先插入兩筆使用者來當作測試資料。在登入流程中,根據 username 用 findUserByUsername() 讀取使用者資料。最後,在取得使用者資料的流程中,根據使用者 ID 用 findUserByID() 讀取使用者資料。
package main func insertUser(username string, password string, age uint) (*User, error) { user := User{ Username: username, Password: password, Age: age, } if res := DB.Create(&user); res.Error != nil { return nil, res.Error } return &user, nil } func findUserByUsername(username string) (*User, error) { var user User if res := DB.Where("username = ?", username).Find(&user); res.Error != nil { return nil, res.Error } return &user, nil } func findUserByID(id uint) (*User, error) { var user User if res := DB.Find(&user, id); res.Error != nil { return nil, res.Error } return &user, nil }
新增 main.go,程式碼如下。在 main() 中,我們連接資料庫,並且預先插入兩筆資料。
package main import ( "gorm.io/driver/sqlite" "gorm.io/gorm" ) var DB *gorm.DB func main() { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { panic(err) } if err := db.AutoMigrate(&User{}); err != nil { panic(err) } DB = db _, _ = insertUser("david", "123", 30) _, _ = insertUser("sophia", "321", 25) }
JWT-Token-Based Authentication
在開始實作產生和驗證 JWT token 之前,必須要先了解 JWT-Token-Based Authentication 流程。如果不太了解的話,可以先參考以下的文章。
Go 有不少第三方的 JWT package。在本文章中,我們使用 golang-jwt/jwt。
GoJwtExample % go get -u github.com/golang-jwt/jwt
再來,我們利用以下的指定產生一個 key 來加密 JWT token。
% openssl rand -base64 172 FDr1VjVQiSiybYJrQZNt8Vfd7bFEsKP6vNX1brOSiWl0mAIVCxJiR4/T3zpAlBKc2/9Lw2ac4IwMElGZkssfj3dqwa7CQC7IIB+nVxiM1c9yfowAZw4WQJ86RCUTXaXvRX8JoNYlgXcRrK3BK0E/fKCOY1+izInW3abf0jEeN40HJLkXG6MZnYdhzLnPgLL/TnIFTTAbbItxqWBtkz6FkZTG+dkDSXN7xNUxlg==
產生 JWT
新增 auth.go,其程式碼如下。在 generateToken() 中,我們設定 token 的 expiresAt 為 24 小時,而 Subject 設為使用者的 username。這兩個都是標準的 JWT 欄位,宣告在 jwt.StandardClaims。JWT 也允許我們自定義欄位,所以我們定義 UserID,並設定為使用者的 ID。另外,我們還要選擇加密的演算法,我們選擇 HS512 (HMAC using SHA-512 hash algorithm)。最後,呼叫 SignedString() 產生 JWT token 字串。
package main import ( "errors" "fmt" "github.com/golang-jwt/jwt" "time" ) var jwtKey = []byte("FDr1VjVQiSiybYJrQZNt8Vfd7bFEsKP6vNX1brOSiWl0mAIVCxJiR4/T3zpAlBKc2/9Lw2ac4IwMElGZkssfj3dqwa7CQC7IIB+nVxiM1c9yfowAZw4WQJ86RCUTXaXvRX8JoNYlgXcRrK3BK0E/fKCOY1+izInW3abf0jEeN40HJLkXG6MZnYdhzLnPgLL/TnIFTTAbbItxqWBtkz6FkZTG+dkDSXN7xNUxlg==") type authClaims struct { jwt.StandardClaims UserID uint `json:"userId"` } func generateToken(user User) (string, error) { expiresAt := time.Now().Add(24 * time.Hour).Unix() token := jwt.NewWithClaims(jwt.SigningMethodHS512, authClaims{ StandardClaims: jwt.StandardClaims{ Subject: user.Username, ExpiresAt: expiresAt, }, UserID: user.ID, }) tokenString, err := token.SignedString(jwtKey) if err != nil { return "", err } return tokenString, nil }
驗證 JWT
在 auth.go 中,我們加入 validateToken() 來驗證 JWT token。呼叫 jwt.ParseWithClaims() 來解密 JWT token。這邊我們注意到,不是直接傳遞 key 給 jwt.ParseWithClaims(),而是傳遞一個函式,而函式會回傳 key。另外,在函式中還要記得檢查加密的演算法是不是和我們加密時用的相同。
解密好後,會得到一個 jwt.Token 物件。檢查 jwt.Token.Valid 是否有效。如果驗證都通過的話,我們就可以從 claims 中取得使用者的 ID 和 username。
func validateToken(tokenString string) (uint, string, error) { var claims authClaims token, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) } return jwtKey, nil }) if err != nil { return 0, "", err } if !token.Valid { return 0, "", errors.New("invalid token") } id := claims.UserID username := claims.Subject return id, username, nil }
整合 REST APIs
接下來,我們會實作兩隻 APIs 並整合先前的資料庫和 JWT 程式碼。我們選擇 Gin 作為專案的 Web framework。不太熟悉 Gin 的讀者,可以先參考以下的文章。
登入 API
新增 handler.go,如程式碼如下。login() 從 request body 中取得 username 和 password,並用 username 從資料庫中讀取使用者資料。然後,比對 password 是否相符。如果相符,產生 JWT token 並輸出給前端。
package main import ( "fmt" "github.com/gin-gonic/gin" "net/http" ) func login(c *gin.Context) { var req struct { Username string `json:"username"` Password string `json:"password"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "error": "incorrect parameters", }) return } user, err := findUserByUsername(req.Username) if err != nil { c.JSON(http.StatusNotFound, gin.H{ "error": fmt.Sprintf("user %s not found", req.Username), }) return } if user.Password != req.Password { c.JSON(http.StatusUnauthorized, gin.H{ "error": "incorrect password", }) return } token, err := generateToken(*user) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{ "error": err.Error(), }) return } c.JSON(http.StatusOK, gin.H{ "token": token, }) }
在 main() 中,啟用 Gin web framework,並將 login() 加入到 router 中。
package main import ( "github.com/gin-gonic/gin" "gorm.io/driver/sqlite" "gorm.io/gorm" "net/http" "strings" ) var DB *gorm.DB func main() { ... r := gin.Default() r.POST("/login", login) if err := r.Run("localhost:7788"); err != nil { panic(err) } }
取得使用者資料 API
在 main() 中,我們在 router 中加入一個 middleware 來驗證 JWT token。在加入 middleware 之後,我們加入的任何 API,都會先執行這個 middleware。所以,要將 /login 加在 middleware 前面,而將 /info 加在 middleware 後面。
verifyToken() middleware 會從 request header 中取得 JWT token,並且做驗證。如果驗證成功,將從 claims 中取得的使用者 ID 和 username 設定到 Gin context。之後,handler 可以從 context 中取得 ID 和 username。
package main import ( "github.com/gin-gonic/gin" "gorm.io/driver/sqlite" "gorm.io/gorm" "net/http" "strings" ) var DB *gorm.DB func main() { ... r := gin.Default() r.POST("/login", login) r.Use(verifyToken) r.GET("/info", getUserInfo) if err := r.Run("localhost:7788"); err != nil { panic(err) } } func verifyToken(c *gin.Context) { token, ok := getToken(c) if !ok { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{}) return } id, username, err := validateToken(token) if err != nil { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{}) return } c.Set("id", id) c.Set("username", username) c.Writer.Header().Set("Authorization", "Bearer "+token) c.Next() } func getToken(c *gin.Context) (string, bool) { authValue := c.GetHeader("Authorization") arr := strings.Split(authValue, " ") if len(arr) != 2 { return "", false } authType := strings.Trim(arr[0], "\n\r\t") if strings.ToLower(authType) != strings.ToLower("Bearer") { return "", false } return strings.Trim(arr[1], "\n\t\r"), true } func getSession(c *gin.Context) (uint, string, bool) { id, ok := c.Get("id") if !ok { return 0, "", false } username, ok := c.Get("username") if !ok { return 0, "", false } return id.(uint), username.(string), true }
在 handler.go 加入 getUserInfo()。它從 Gin context 中取得使用者 ID,在根據這個 ID 從資料庫中讀取使用者資料。最後,將使用者資料輸出給前端。
package main import ( "fmt" "github.com/gin-gonic/gin" "net/http" ) ... func getUserInfo(c *gin.Context) { id, _, ok := getSession(c) if !ok { c.JSON(http.StatusUnauthorized, gin.H{}) return } user, err := findUserByID(id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{}) return } c.JSON(http.StatusOK, user) }
結語
JWT-Token-Based authentication 是現在很流行的驗證流程。golang-jwt/jwt 提供了很多方便的函式讓我們產生與驗證 token, 所以可以看出本章的程式碼並不困難。