用 Go 實現 JWT Based 驗證

Photo by Ricky Kharawala on Unsplash
Photo by Ricky Kharawala on Unsplash
本章將介紹如何用 Go 實作 JWT-Token-Based 驗證的方法。我們將從實作登入的 REST APIs 開始,驗證 Username/Password,產生 JWT Token,以及驗證 JWT Token。

本章將介紹如何用 Go 實作 JWT-Token-Based 驗證的方法。我們將從實作登入的 REST API 開始,驗證 Username/Password,產生 JWT Token,以及驗證 JWT Token。

本章完整的程式碼可以在 下載。

建立 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, 所以可以看出本章的程式碼並不困難。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *