使用 Go HTTP 框架 Hertz 進行 JWT 認證

2022-11-17 12:00:53

前言

上一篇文章簡單介紹了一個高效能的 Go HTTP 框架——Hertz,本篇文章將圍繞 Hertz 開源倉庫的一個 demo,講述如何使用 Hertz 完成 JWT 的認證與授權流程。

這裡要說明的是,hertz-jwt 是 Hertz 眾多外部擴充套件元件之一,Hertz 豐富的擴充套件生態為開發者帶來了很大的便利,值得你在本文之外自行探索。

Demo 介紹

  • 使用命令列工具 hz 生成程式碼
  • 使用 JWT 擴充套件完成登陸認證和授權存取
  • 使用 Gorm 存取 MySQL 資料庫

Demo 下載

git clone https://github.com/cloudwego/hertz-examples.git
cd bizdemo/hertz_jwt

Demo 結構

hertz_jwt
├── Makefile # 使用 hz 命令列工具生成 hertz 腳手架程式碼
├── biz
│   ├── dal
│   │   ├── init.go 
│   │   └── mysql
│   │       ├── init.go # 初始化資料庫連線
│   │       └── user.go # 資料庫操作
│   ├── handler
│   │   ├── ping.go
│   │   └── register.go # 使用者註冊 handler
│   ├── model
│   │   ├── sql
│   │   │   └── user.sql
│   │   └── user.go # 定義資料庫模型
│   ├── mw
│   │   └── jwt.go # 初始化 hertz-jwt 中介軟體
│   ├── router
│   │   └── register.go
│   └── utils
│       └── md5.go # md5 加密
├── docker-compose.yml # mysql 容器環境支援
├── go.mod
├── go.sum
├── main.go # hertz 服務入口
├── readme.md
├── router.go # 路由註冊
└── router_gen.go

Demo 分析

下方是這個 demo 的介面列表。

// customizeRegister registers customize routers.
func customizedRegister(r *server.Hertz) {
    r.POST("/register", handler.Register)
    r.POST("/login", mw.JwtMiddleware.LoginHandler)
    auth := r.Group("/auth", mw.JwtMiddleware.MiddlewareFunc())
    auth.GET("/ping", handler.Ping)
}

使用者註冊

對應 /register 介面,當前 demo 的使用者資料通過 gorm 操作 mysql 完成持久化,因此在登陸之前,需要對使用者進行註冊,註冊流程為:

  1. 獲取使用者名稱密碼和郵箱
  2. 判斷使用者是否存在
  3. 建立使用者

使用者登陸(認證)

伺服器需要在使用者第一次登陸的時候,驗證使用者賬號和密碼,並簽發 jwt token。

JwtMiddleware, err = jwt.New(&jwt.HertzJWTMiddleware{
    Key:           []byte("secret key"),
    Timeout:       time.Hour,
    MaxRefresh:    time.Hour,
    Authenticator: func(ctx context.Context, c *app.RequestContext) (interface{}, error) {
        var loginStruct struct {
            Account  string `form:"account" json:"account" query:"account" vd:"(len($) > 0 && len($) < 30); msg:'Illegal format'"`
            Password string `form:"password" json:"password" query:"password" vd:"(len($) > 0 && len($) < 30); msg:'Illegal format'"`
        }
        if err := c.BindAndValidate(&loginStruct); err != nil {
            return nil, err
        }
        users, err := mysql.CheckUser(loginStruct.Account, utils2.MD5(loginStruct.Password))
        if err != nil {
            return nil, err
        }
        if len(users) == 0 {
            return nil, errors.New("user already exists or wrong password")
        }
​
        return users[0], nil
    },
    PayloadFunc: func(data interface{}) jwt.MapClaims {
        if v, ok := data.(*model.User); ok {
            return jwt.MapClaims{
                jwt.IdentityKey: v,
            }
        }
        return jwt.MapClaims{}
    },
})
  • Authenticator:用於設定登入時認證使用者資訊的函數,demo 當中定義了一個 loginStruct 結構接收使用者登陸資訊,並進行認證有效性。這個函數的返回值 users[0] 將為後續生成 jwt token 提供 payload 資料來源。
  • PayloadFunc:它的入參就是 Authenticator 的返回值,此時負責解析 users[0],並將使用者名稱注入 token 的 payload 部分。
  • Key:指定了用於加密 jwt token 的金鑰為 "secret key"
  • Timeout:指定了 token 有效期為一個小時。
  • MaxRefresh:用於設定最大 token 重新整理時間,允許使用者端在 TokenTime + MaxRefresh 內重新整理 token 的有效時間,追加一個 Timeout 的時長。

Token 的返回

JwtMiddleware, err = jwt.New(&jwt.HertzJWTMiddleware{
    LoginResponse: func(ctx context.Context, c *app.RequestContext, code int, token string, expire time.Time) {
        c.JSON(http.StatusOK, utils.H{
            "code":    code,
            "token":   token,
            "expire":  expire.Format(time.RFC3339),
            "message": "success",
        })
    },
})
  • LoginResponse:在登陸成功之後,jwt token 資訊會隨響應返回,你可以自定義這部分的具體內容,但注意不要改動函數簽名,因為它與 LoginHandler 是強繫結的。

Token 的校驗

存取設定了 jwt 中介軟體的路由時,會經過 jwt token 的校驗流程。

JwtMiddleware, err = jwt.New(&jwt.HertzJWTMiddleware{
    TokenLookup:   "header: Authorization, query: token, cookie: jwt",
    TokenHeadName: "Bearer",
    HTTPStatusMessageFunc: func(e error, ctx context.Context, c *app.RequestContext) string {
        hlog.CtxErrorf(ctx, "jwt biz err = %+v", e.Error())
        return e.Error()
    },
    Unauthorized: func(ctx context.Context, c *app.RequestContext, code int, message string) {
        c.JSON(http.StatusOK, utils.H{
            "code":    code,
            "message": message,
        })
    },
})
  • TokenLookup:用於設定 token 的獲取源,可以選擇 headerquerycookieparam,預設為 header:Authorization,同時存在是以左側一個讀取到的優先。當前 demo 將以 header 為資料來源,因此在存取 /ping 介面時,需要你將 token 資訊存放在 HTTP Header 當中。
  • TokenHeadName:用於設定從 header 中獲取 token 時的字首,預設為 "Bearer"
  • HTTPStatusMessageFunc:用於設定 jwt 校驗流程發生錯誤時響應所包含的錯誤資訊,你可以自行包裝這些內容。
  • Unauthorized:用於設定 jwt 驗證流程失敗的響應函數,當前 demo 返回了錯誤碼和錯誤資訊。

使用者資訊的提取

JwtMiddleware, err = jwt.New(&jwt.HertzJWTMiddleware{
    IdentityKey: IdentityKey,
    IdentityHandler: func(ctx context.Context, c *app.RequestContext) interface{} {
        claims := jwt.ExtractClaims(ctx, c)
        return &model.User{
            UserName: claims[IdentityKey].(string),
        }
    },
})
​
// Ping .
func Ping(ctx context.Context, c *app.RequestContext) {
    user, _ := c.Get(mw.IdentityKey)
    c.JSON(200, utils.H{
        "message": fmt.Sprintf("username:%v", user.(*model.User).UserName),
    })
}
  • IdentityHandler:用於設定獲取身份資訊的函數,在 demo 中,此處提取 token 的負載,並配合 IdentityKey 將使用者名稱存入上下文資訊。
  • IdentityKey:用於設定檢索身份的鍵,預設為 "identity"
  • Ping:構造響應結果,從上下文資訊中取出使用者名稱資訊並返回。

其他元件

程式碼生成

上述程式碼大部分是通過 hz 命令列工具生成的腳手架程式碼,開發者無需花費大量時間在構建一個良好的程式碼結構上,專注於業務的編寫即可。

hz new -mod github.com/cloudwego/hertz-examples/bizdemo/hertz_jwt

更進一步,在使用程式碼生成命令時,指定 IDL 檔案,可以一併生成通訊實體、路由註冊程式碼。

範例程式碼(源自 hz 官方檔案):

// idl/hello.thrift
namespace go hello.example

struct HelloReq {
    1: string Name (api.query="name"); // 新增 api 註解為方便進行引數繫結
}

struct HelloResp {
    1: string RespBody;
}

service HelloService {
    HelloResp HelloMethod(1: HelloReq request) (api.get="/hello");
}

// 在 GOPATH 下執行
hz new -idl idl/hello.thrift

引數繫結

hertz 使用開源庫 go-tagexpr 進行引數的繫結及驗證,demo 中也頻繁使用了這個特性。

var loginStruct struct {
    // 通過宣告 tag 進行引數繫結和驗證
    Account  string `form:"account" json:"account" query:"account" vd:"(len($) > 0 && len($) < 30); msg:'Illegal format'"`
    Password string `form:"password" json:"password" query:"password" vd:"(len($) > 0 && len($) < 30); msg:'Illegal format'"`
}
if err := c.BindAndValidate(&loginStruct); err != nil {
    return nil, err
}

更多操作可以參考檔案

Gorm

更多 Gorm 操作 MySQL 的資訊可以參考 Gorm

Demo 執行

  • 執行 mysql docker 容器
cd bizdemo/hertz_jwt && docker-compose up
  • 建立 mysql 資料庫

連線 mysql 之後,執行 user.sql

  • 執行 demo
cd bizdemo/hertz_jwt && go run main.go

API 請求

註冊

# 請求
curl --location --request POST 'localhost:8888/register' \
--header 'Content-Type: application/json' \
--data-raw '{
    "Username": "admin",
    "Email": "[email protected]",
    "Password": "admin"
}'
# 響應
{
    "code": 200,
    "message": "success"
}

登陸

# 請求
curl --location --request POST 'localhost:8888/login' \
--header 'Content-Type: application/json' \
--data-raw '{
    "Account": "admin",
    "Password": "admin"
}'
# 響應
{
    "code": 200,
    "expire": "2022-11-16T11:05:24+08:00",
    "message": "success",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2Njg1Njc5MjQsImlkIjoyLCJvcmlnX2lhdCI6MTY2ODU2NDMyNH0.qzbDJLQv4se6dOHN51p21Rp3DjV1Lf131l_5k4cK6Wk"
}

授權存取 Ping

# 請求
curl --location --request GET 'localhost:8888/auth/ping' \
--header 'Authorization: Bearer ${token}'
# 響應
{
    "message": "username:admin"
}

參考文獻