本章將介紹如何用 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, 所以可以看出本章的程式碼並不困難。



