Go Gin 建立 API - 完成CRUD, Login, JWT Auth

tags: Go Gin API
category: Back-End
description: Go Gin 建立 API - 完成CRUD, Login, JWT Auth
created_at: 2021/07/09 16:00:00

cover image


回到 手把手開始 Go Gin 建立 API


寫法偏舊,未來有空會再寫一個新的系列,有興趣再往下閱讀


上一篇最後的檔案在Github


上一篇偷懶沒做完RUD,現在快速把他寫完吧,依照上次的設計

router -> api -> service -> dao -> model

可以先在 api 挖空的 function 出來,之後讓 router 去註冊,再來寫完 model -> dao -> service,再把 api 與 service 組裝起來。

但在 service 之前,要先寫好相應的 request

request

// ...
type LoginUserRequest struct {
    Username string `form:"username" binding:"required,max=100"`
    Password string `form:"password" binding:"required,max=100"`
}

type GetUserRequest struct {
    ID   uint   `form:"id" binding:"required"`
}

type UpdateUserRequest struct {
    ID   uint   `form:"id" binding:"required"`
    Name string `form:"name" binding:"max=100"`
    Username string `form:"username" binding:"max=100"`
    Password string `form:"password" binding:"max=100"`
}

api

// ...
func (u User) Login(c *gin.Context) {}
func (u User) Get(c *gin.Context) {}
func (u User) Update(c *gin.Context) {}
func (u User) Delete(c *gin.Context) {}

router

// ...
router.POST("/login", user.Login)
router.GET("/users/:id", user.Get)
router.PATCH("/users/:id", user.Update)
router.DELETE("/users/:id", user.Delete)
// ...

model

// ...
func (u User) Update(db *gorm.DB, newUser User) error {
    return db.Model(&u).Updates(newUser).Error
}

func (u User) Delete(db *gorm.DB) error {
    return db.Unscoped().Delete(&u).Error
}

func (u User) Get(db *gorm.DB) (User, error) {
    var user User
    var err error

    if u.Username != "" {
        db = db.Where("username = ?", u.Username)
    }

    if u.ID != 0 {
        err = db.First(&user, u.ID).Error
    } else {
        err = db.First(&user).Error
    }

    if err != nil {
        return user, err
    }

    return user, nil
}

dao

// ...
func (dao *Dao) UpdateUser(id uint, newUser model.User) error {
    user := model.User{
        Model: gorm.Model{
            ID: id,
        },
    }

    return user.Update(dao.Engine, newUser)
}

func (dao *Dao) DeleteUser(id uint) error {
    user := model.User{
        Model: gorm.Model{
            ID: id,
        },
    }

    return user.Delete(dao.Engine)
}

func (dao *Dao) GetUserByUsername(username string) (model.User, error) {
    user := model.User{
        Username: username,
    }

    return user.Get(dao.Engine)
}

func (dao *Dao) GetUserByID(id uint) (model.User, error) {
    user := model.User{
        Model: gorm.Model{
            ID: id,
        },
    }

    return user.Get(dao.Engine)
}

service

// ...
func (s Service) UpdateUser(id uint, params *requests.UpdateUserRequest) error {
    newUser := model.User{}

    if params.Name != "" {
        newUser.Name = params.Name
    }

    if params.Username != "" {
        newUser.Username = params.Username
    }

    if params.Password != "" {
        newUser.Password, _ = utils.HashPassword(params.Password)
    }

    return s.Dao.UpdateUser(id, newUser)
}

func (s Service) DeleteUser(id uint) error {
    return s.Dao.DeleteUser(id)
}

func (s Service) GetUserById(id uint) (model.User, error) {
    return s.Dao.GetUserByID(id)
}

在建立一個 pkg/converts/convert.go 負責處理字串轉數字

package converts

import "strconv"

type StrTo string

func (str StrTo) String() string {
    return string(str)
}

func (str StrTo) Int() (int, error){
    value, err := strconv.Atoi(str.String())
    return value, err
}

func (str StrTo) MustInt() int {
    value, _ := str.Int()
    return value
}

func (str StrTo) UInt32() (uint32, error) {
    value, err := strconv.Atoi(str.String())
    return uint32(value), err
}

func (str StrTo) MustUInt32() uint32 {
    value, _ := str.UInt32()
    return value
}

func (str StrTo) UInt() (uint, error) {
    value, err := strconv.Atoi(str.String())
    return uint(value), err
}

func (str StrTo) MustUInt() uint {
    value, _ := str.UInt()
    return value
}

工具都準備齊全之後,可以寫核心 api code 了 api

// ...
func (u User) Get(c *gin.Context) {
    id := converts.StrTo(c.Param("id")).MustUInt()
    params := requests.GetUserRequest {
        ID: id,
    }
    response := app.NewResponse(c)
    valid, err := app.BindAndValidation(c, &params)

    if !valid {
        errorResponse := errors.InvalidParams.WithDetails(err...)
        response.MakeErrorResponse(errorResponse)
        return
    }

    s := service.New(c.Request.Context())
    user, userErr := s.GetUserById(id)

    if userErr != nil {
        response.MakeErrorResponse(errors.NotFound)
        return
    }

    response.MakeResponse(user)
}

func (u User) Update(c *gin.Context) {
    id := converts.StrTo(c.Param("id")).MustUInt()
    params := requests.UpdateUserRequest {
        ID: id,
    }
    response := app.NewResponse(c)
    valid, err := app.BindAndValidation(c, &params)

    if !valid {
        errorResponse := errors.InvalidParams.WithDetails(err...)
        response.MakeErrorResponse(errorResponse)
        return
    }

    s := service.New(c.Request.Context())
    updateErr := s.UpdateUser(id, &params)

    if updateErr != nil {
        response.MakeErrorResponse(errors.UpdateUserFail)
        return
    }

    response.MakeResponse("")
}

func (u User) Delete(c *gin.Context) {
    id := converts.StrTo(c.Param("id")).MustUInt()
    response := app.NewResponse(c)
    s := service.New(c.Request.Context())
    err := s.DeleteUser(id)
    if err != nil {
        response.MakeErrorResponse(errors.DeleteUserFail)
        return
    }
    response.MakeResponse("")
}
// ...

這樣子剩下的 RUD 就完成了,再來做登入取得 JWT Token

在登入之前先安裝 JWT 使用的套件

$ go get github.com/golang-jwt/jwt

先打開 config.yaml 加入 JWT 的相關設定

# ...
JWT:
  Secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  Expire: 3600
  • Secret: 放一個只有你知道長字串,加密用的
  • Expire: Token 發出來之後的時效(秒數)

設定好之後要在 pkg/setting/setting.go 建立設定的結構

type JWTSetting struct {
    Secret string
    Expire time.Duration
}

再到 global/setting.go 註冊到全域 global/setting.go

var (
    // ...
    JWTSetting      *setting.JWTSetting
)

之後回到 main.go 激活 global setting

func setupSetting() error {
    // ...

    err = setting.ReadSection("JWT", &global.JWTSetting)
    if err != nil {
        return err
    }

    global.JWTSetting.Expire *= time.Second
    // ...
}

再來建立 pkg/app/jwt 檔案,負責生成和解析 Token (假設我只在 payloadusername 與當前時間)

package app

import (
    "github.com/LaiJunBin/gin-api/global"
    "github.com/golang-jwt/jwt"
    "time"
)

type Claims struct {
    Username    string `json:"username"`
    NowTime     time.Time `json:"created_at"`
    jwt.StandardClaims
}

func GetJWTSecret() []byte {
    return []byte(global.JWTSetting.Secret)
}

func GenerateToken(username string) (string, error) {
    nowTime := time.Now()
    expireTime := nowTime.Add(global.JWTSetting.Expire)
    claims := Claims{
        Username: username,
        NowTime: nowTime,
        StandardClaims: jwt.StandardClaims{
            ExpiresAt: expireTime.Unix(),
        },
    }

    tokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    token, err := tokenClaims.SignedString(GetJWTSecret())
    return token, err
}

func ParseToken(token string) (*Claims, error) {
    tokenClaims, err := jwt.ParseWithClaims(token, &Claims{}, func(token *jwt.Token) (interface{}, error) {
        return GetJWTSecret(), nil
    })

    if tokenClaims != nil {
        claims, ok := tokenClaims.Claims.(*Claims)
        if ok && tokenClaims.Valid {
            return claims, nil
        }
    }

    return nil, err
}

再來就可以開始做登入的功能了

service 只要增加一個

func (s Service) GetUserByUsername(username string) (model.User, error) {
    user, err := s.Dao.GetUserByUsername(username)
    return user, err
}

api 的部分則是填滿 Login() function

func (u User) Login(c *gin.Context) {
    params := requests.LoginUserRequest{}
    response := app.NewResponse(c)
    valid, err := app.BindAndValidation(c, &params)

    if !valid {
        errorResponse := errors.InvalidParams.WithDetails(err...)
        response.MakeErrorResponse(errorResponse)
        return
    }

    s := service.New(c.Request.Context())
    user, userErr := s.GetUserByUsername(params.Username)

    if userErr != nil {
        response.MakeErrorResponse(errors.LoginFail)
        return
    }

    if !utils.CheckPasswordHash(params.Password, user.Password) {
        response.MakeErrorResponse(errors.LoginFail)
        return
    }

    token, tokenErr := app.GenerateToken(user.Username)
    if tokenErr != nil {
        response.MakeErrorResponse(errors.UnauthorizedTokenGenerate)
        return
    }

    response.MakeResponse(token)
}

登入成功會看到以下回應格式,data 就是 token

{
    "data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIwNCIsImNyZWF0ZWRfYXQiOiIyMDIxLTA3LTA5VDIyOjI0OjAyLjA4ODkzMzgrMDg6MDAiLCJleHAiOjE2MjU4NDQyNDJ9.2SR5Jux906-wZbw9sKW55aeGUOTRPW1_u7YeLUBNsag",
    "success": true
}

再來為了要解析 token , 還會需要一個中介層(middleware),所以建立一個新檔案 internal/middleware/jwt.go

package middleware

import (
    "github.com/LaiJunBin/gin-api/pkg/app"
    "github.com/LaiJunBin/gin-api/pkg/errors"
    "github.com/gin-gonic/gin"
    "github.com/golang-jwt/jwt"
    "strings"
)

func JWT() gin.HandlerFunc {
    return func(ctx *gin.Context) {
        var errResponse *errors.Error
        authorization := ctx.GetHeader("Authorization")
        splitAuthorization := strings.SplitN(authorization, "Bearer ", 2)
        if len(splitAuthorization) != 2{
            errResponse = errors.InvalidParams
        } else {
            token := strings.TrimSpace(splitAuthorization[1])
            if token == "" {
                errResponse = errors.InvalidParams
            } else {
                claims, err := app.ParseToken(token)
                if err != nil {
                    switch err.(*jwt.ValidationError).Errors {
                    case jwt.ValidationErrorExpired:
                        errResponse = errors.UnauthorizedTokenTimeout
                        break
                    default:
                        errResponse = errors.UnauthorizedTokenError
                        break
                    }
                }

                ctx.Set("claims", claims)
            }
        }


        if errResponse != nil {
            response := app.NewResponse(ctx)
            response.MakeErrorResponse(errResponse)
            ctx.Abort()
            return
        }

        ctx.Next()
    }
}

寫好中介層之後要記得去 Use() , 要想一下有哪些路由需要經過 JWT 驗證在包進去,所以要稍微整理一下路由。

func NewRouter() *gin.Engine {
    router := gin.New()
    router.Use(middleware.Translations())

    user := api.NewUser()
    router.POST("/register", user.Register)
    router.POST("/login", user.Login)
    router.GET("/users/:id", user.Get)

    authRouter := router.Group("/")
    authRouter.Use(middleware.JWT())
    authRouter.PATCH("/users/:id", user.Update)
    authRouter.DELETE("/users/:id", user.Delete)

    return router
}

這樣子基本的驗證就完成了

  • 不需要 Token
    • 註冊
    • 登入
    • 查User
  • 需要 Token
    • 更新
    • 刪除

簡單的拿刪除User當作範例,首先先在 service 加上一個 function,負責判斷有沒有權限

func (s Service) CheckPermission(id uint, claims *app.Claims) bool {
    user, err := s.GetUserById(id)
    if err != nil {
        return false
    }

    return user.Username == claims.Username
}

在來把 api 中的 Delete() 函數改一下

func (u User) Delete(c *gin.Context) {
    s := service.New(c.Request.Context())
    id := converts.StrTo(c.Param("id")).MustUInt()
    claims := c.Keys["claims"].(*app.Claims)

    response := app.NewResponse(c)
    if !s.CheckPermission(id, claims) {
        response.MakeErrorResponse(errors.StatusUnauthorized)
        return
    }

    err := s.DeleteUser(id)
    if err != nil {
        response.MakeErrorResponse(errors.DeleteUserFail)
        return
    }
    response.MakeResponse("")
}

這樣的話只有自己可以刪除自己,如果 token 對不上的話就會噴401

本來這一篇還要寫檔案上傳的,不過好像已經有點長了,還是延到下一篇吧!絕對不是我累了

這篇進度的Source code




最後更新時間: 2021年07月09日.