【Golang 1.19】Go! Go! 購物趣! 我是VIP

續上集,這次要介紹的是JWT - JSON Web Token的使用者登入功能,雖然功能做好了,但總不能讓路人甲路人乙都可以來亂加亂刪吧?還是要有個權限設定才行,使用者註冊之後,要如何實現權限的分類呢?這次的介紹會比較偏重網頁端,所以Vue的部分也會比較吃重一點…對了,axios終於升成1.0了…🤣;狂賀,突破60篇文,考慮寫點吃吃喝喝的,寫程式文太累了,抖內抖內業配也可以…

做一個長得像這樣的東西

作業環境

項目 版本
CPU Apple M1
macOS Big Sur 12.6 arm64
Golang 1.19.1 arm64
Visual Studio Code 1.71.2 arm64
Postman 10.0 arm64
Node.js 16.15 arm64
Yarn 1.22.18 arm64
Vue CLI 5.0.4 arm64
DB Browser for SQLite 3.12.1 x86_64

說明

基本參數

package main

import "william/model"

const (
	AutoUpdate              bool   = true
	ValidityDays            int    = 14
	SqliteDatabasePath      string = "./material/database.sqlite3"
	AutoUpdateCronRule      string = "*/1 * * * *"
	AutoRemoveTokenCronRule string = "*/60 * * * *"
	Port                    string = ":12345"
	Sheet                   string = "App版本相關訊息"
	AuthorizationKey        string = "Authorization"
)

const (
	UserMinimumLength     int = 8
	PasswordMinimumLength int = 8
)

var (
	// 預先設定的帳號
	SuperUsers = map[string]map[string]model.UserLevel{
		"root":    {"3939889": model.Root},
		"admin":   {"28825252": model.Admin},
		"william": {"987987": model.Account},
	}

	// API的通行規則
	ApiRules = map[model.UserLevel]map[string][]string{
		model.Account: {
			"GET":  {"/appVersion/:id"},
			"POST": {"/checkToken", "/versionList/:page", "/saveAppVersion", "/forceUpdate"},
		},
		model.Admin: {"GET": {"*"}, "POST": {"*"}, "PUT": {"*"}, "PATCH": {"*"}},
		model.Root:  {"GET": {"*"}, "POST": {"*"}, "PUT": {"*"}, "PATCH": {"*"}, "DELETE": {"*"}},
	}
)

API設定

  • 在這裡多了幾支API,主要是跟使用者 / 權限有關的…
  • 結果就請各位用Postman打看看,Code內有附匯出的Postman.js檔,這裡就不細說了…
    功能 API 參數 方式 權限
    取得該AppId相關訊息 /appVersion/:id - GET -
    取得該AppId相關訊息 (簡單版) /appVersion/mobile/:id - GET -
    搜尋使用者 /user/:username - GET Root / Admin
    新增AppId與名稱 /appVersion {"type":1,"id":"com.ubercab.driver","name":"Uber Driver"} POST Root / Admin
    取得所有AppId相關訊息 /versionList/:page {"limit":10,"offset":0} POST Root / Admin
    儲存成excel /saveAppVersion - POST Root / Admin
    上傳Excel /uploadExcel - POST Root / Admin
    新增使用者 /user {"username":"William123","password":"12345678","level":9} POST Root / Admin
    使用者登入 /login {"username":"William123","password":"12345678"} POST -
    測試Token合法性 /checkToken {"token":"Bearer 0800.092.000"} POST -
    Token列表 /authToken {"where":"id > 0","limit":10,"offset":0} POST Root / Admin
    強制更新資料 /forceUpdate - POST Root / Admin
    取得在該Store的APP版本號 /appVersion/:id/:type - PUT Root / Admin
    更新單一Update的型式 /updateType/:id {"type":"DEV","level":2} PUTCH Root / Admin
    更新相關資訊 /appVersion/:id/:type {"auto":true,"name":"優食 - Android","icon":"/images/icon.jpg","version":{"store":"1.2.3","dev":"1.2.0","prod":"1.3.0"}} PUTCH Root / Admin
    刪除該APP資訊 /appVersion/:id - DELETE Root

使用者相關API

type UserLevel uint

const (
	Account UserLevel = 0
	Admin   UserLevel = 9
	Root    UserLevel = 99
)

const (
	UsernameMinimumLength int = 8
	PasswordMinimumLength int = 8
)

type User struct {
	gorm.Model
	Username string    `json:"username" gorm:"index:idx_username,unique"`
	Password string    `json:"password"`
	Level    UserLevel `json:"level"`
}

使用者登入

新增使用者

產生Token

Basic Authentication

GET /private/index.html HTTP/1.0
Host: localhost
Authorization: Basic V2lsbGlhbTk4Nzk4Nw==
  • 對方傳回來的大約是長這樣…
HTTP/1.0 200 OK
Server: HTTPd/1.0
Date: Sat, 27 Nov 2022 10:19:07 GMT
Content-Type: text/html
Content-Length: 87

JSON Web Token

GET /private/index.html HTTP/1.0
Host: localhost
Authorization: Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.VFb0qJ1LRg_4ujbZoRMXnVkUgiuKq5KxWqNdbKq_G9Vvz-S1zZa9LPxtHWKa64zDl2ofkT8F6jBt_K4riU-fPg
  • 對方傳回來的大約是長這樣…
HTTP/1.0 200 OK
Server: HTTPd/1.0
Date: Sat, 27 Nov 2022 10:19:07 GMT
Content-Type: text/html
Content-Length: 87

JWT實踐

  • 這裡呢,使用jwt-go這個套件來處理…
  • 因為Token在有效的期間之內都是可以合法使用的,那被盜帳號,那中間要把它停掉怎麼辦呢?
  • 凍結帳號?這樣子是在懲罰好人吧?…刪帳號?這就更不行了吧?難怪…好人不長命?禍害遺千年?🤣
  • 所以這邊會多一個步驟,先把產生的Token存在資料庫內,經由比對資料庫,再去解析Token,如此一來,就可以去刪Token了,就算是有效的Token也進不來…🤣
  • 最後,這裡的Token還加入了到期時間 / 使用者名稱 / 使用者等級的資訊,JWT真的是滿好用的…
package model

import (
	"errors"
	"fmt"
	"net/url"
	"regexp"
	"time"

	"github.com/golang-jwt/jwt/v4"
	"gorm.io/gorm"
)

// 紀錄Token
type AuthToken struct {
	gorm.Model
	Username    string    `json:"username"`
	Token       string    `json:"token" gorm:"index:idx_token,unique"`
	ExpiresTime time.Time `json:"expiresTime"`
}

func (_authToken *AuthToken) CheckToken(database *gorm.DB, token string) (map[string]interface{}, error) {

	authToken := _authToken.Select(database, token)

	if authToken.ID == 0 {
		return nil, errors.New("token is null")
	}

	result, error := _authToken.validateToken(token)
	return result, error
}

func (_authToken *AuthToken) JwtToken(user User, expiresTime time.Time) (*string, error) {

	claims := jwt.RegisteredClaims{
		Subject:   user.Username,
		ExpiresAt: jwt.NewNumericDate(expiresTime),
	}

	authClaims := authClaims{
		RegisteredClaims: claims,
		UserID:           user.ID,
		Level:            user.Level,
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS512, authClaims)
	tokenString, error := token.SignedString(jwtKey)

	if error != nil {
		return nil, error
	}

	return &tokenString, nil
}

func (_authToken *AuthToken) validateToken(jwtToken string) (map[string]interface{}, error) {

	var claims authClaims

	result := map[string]interface{}{
		"id":       0,
		"username": nil,
		"level":    Account,
	}

	token, error := jwt.ParseWithClaims(jwtToken, &claims, func(token *jwt.Token) (interface{}, error) {

		_, isSuccess := token.Method.(*jwt.SigningMethodHMAC)

		if !isSuccess {
			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
		}

		return jwtKey, nil
	})

	if error != nil {
		return result, error
	}

	if !token.Valid {
		return result, errors.New("invalid token")
	}

	result = map[string]interface{}{
		"id":          claims.UserID,
		"username":    claims.Subject,
		"level":       claims.Level,
		"expiresTime": claims.ExpiresAt.Time,
	}

	return result, nil
}

API + JWT Token

  • 在使用Postman的時候,Token是手動輸入的,但在網頁上,總不能純手工輸入吧?

儲存Authorization

export default defineComponent({
  name: 'App',
  components: {},
  setup() {

	const API = {

      // 使用者登入
      userLogin: async (username: string, password: string) => {
        
        const url = Constant.GolangAPI.userLogin()
        const response = await axios.post(url, {
          "username": username,
          "password": password,
        })

        const { result } = response.data
        return result
      },
	}

	const Utility = {

	  // 使用者登入
      userLogin: () => {

        const username = Utility.tempUser.value.username
        const password = Utility.tempUser.value.password

        API.userLogin(username, password).then((result) => {

          if (result.token === undefined) {
            Utility.tips("登入失敗", "error")
            Utility.clearTemporary(); return 
          }

          localStorage.setItem(API.AuthorizationKey, `Bearer ${result.token}`)
        })
      },
	}
  }

使用Authorization

  • 在打API的時候,要先加上AuthorizationToken在Header,不然會401 Unauthorized
  • 其實,也不一定要放在Header,放在Body也可以,後台取得到就好,只是…大家都放在那裡,還是合群點比較好…

export default defineComponent({
  name: 'App',
  components: {},
  setup() {

	const API = {
      
	  // 取得記在localStorage的token
      authToken: () => {
        return localStorage.getItem(API.AuthorizationKey)
      },

      // 加上認證的headers
      authorizationHeaders: () => {
        const headers = { "Authorization": API.authToken() ?? "" }
        return headers
      },

	  // 取得App版本列表
      appVersions: async (page: number) => {

        const parameters = {
          limit: Constant.PaginationSetting.value.pageSize,
          offset: (page - 1) * Constant.PaginationSetting.value.pageSize,
        }

        const authAPI = axios.create({ headers: API.authorizationHeaders() })
        const url = Constant.GolangAPI.appVersionList(Constant.PaginationSetting.value.currentPage)          

        try {
          const response = await authAPI.post(url, parameters)
          if (response.status === 401) { Utility.tips("尚未授權", "error"); return }

          const { result } = response.data
          return result

        } catch (error: any) {
          if (error.response.status === 401) { Utility.tips("尚未授權", "error"); return }
        }
      },
	}
  }

判斷授權

// 註冊API
func registerWebAPI(router *gin.Engine, database *gorm.DB) {

	initAdminUser(database)
	checkToken(router, database)
	selectAppVersion(router, database)
	selectAppVersionMini(router, database)
	userLogin(router, database)
	cronSchedule(database)

	router.Use(checkTokenMiddleware)

	checkAppVersion(router)
	insertAppVersion(router, database)
	selectAppVersionList(router, database)
	updateAppVersion(router, database)
	deleteAppVersion(router, database)
	saveAppVersions(router, database)
	uploadAppVersions(router, database)
	forceUpdateVersions(router, database)
	updateVersionType(router, database)

	insertUser(router, database)
	selectUser(router, database)

	authToken(router, database)
}
// 中間件 (卡在中間擋路的) => 測試是否登入?沒有沒Token
func checkTokenMiddleware(context *gin.Context) {

	var authToken model.AuthToken

	token := phaseAuthorizationToken(context)
	result, error := authToken.CheckToken(gormDatabase, token)
	level := result["level"]

	if error != nil || level == nil {
		utility.AbortWithStatusJSON(context, http.StatusUnauthorized, result, error)
		return
	}

	isMatch := authToken.CheckLevel(level.(model.UserLevel), context.Request.URL, context.Request.Method, ApiRules)

	if !isMatch {
		utility.AbortWithStatusJSON(context, http.StatusUnauthorized, result, error)
		return
	}

	context.Next()
}
var (
	// API的通行規則
	ApiRules = map[model.UserLevel]map[string][]string{
		model.Account: {
			"GET":  {"/appVersion/:id"},
			"POST": {"/checkToken", "/versionList/:page", "/saveAppVersion", "/forceUpdate"},
		},
		model.Admin: {"GET": {"*"}, "POST": {"*"}, "PUT": {"*"}, "PATCH": {"*"}},
		model.Root:  {"GET": {"*"}, "POST": {"*"}, "PUT": {"*"}, "PATCH": {"*"}, "DELETE": {"*"}},
	}
)
func (_authToken *AuthToken) CheckLevel(level UserLevel, url *url.URL, method string, rules map[UserLevel]map[string][]string) bool {

	isMatch := false
	urlString := url.String()

	urls := rules[level][method]

	for _, _url := range urls {

		if _url == "*" {
			isMatch = true
			return isMatch
		}

		rule := `:[\w]+`
		regular := regexp.MustCompile(rule)
		newRule := regular.ReplaceAllString(_url, `[\w]+`)

		regular = regexp.MustCompile(newRule)
		isMatch = regular.MatchString(urlString)

		if isMatch {
			return isMatch
		}
	}

	return isMatch
}

Vue3環境設定檔

yarn serve	# vue-cli-service serve 					=> .env / .env.development
yarn prod	# vue-cli-service serve --mode production	=> .env.production
yarn dev	# vue-cli-service serve --mode development" => .env.development
export default defineComponent({
  name: 'App',
  components: {},
  setup() {

	const Constant = {
      GolangAPI: {
        userLogin: () => { return `${Constant.baseAPI}/login` },
        userRegistry: () => { return `${Constant.baseAPI}/user` },
        appVersionList: (page: number) => { return `${Constant.baseAPI}/versionList/${page}` },
        appendVersion: () => { return `${Constant.baseAPI}/appVersion` },
        deleteVersion: (id: string) => { return `${Constant.baseAPI}/appVersion/${id}`},
        editVersion: (id: string, type: StoreType) => { return `${Constant.baseAPI}/appVersion/${id}/${type}`},
        saveVersion: () => { return `${Constant.baseAPI}/saveAppVersion` },
        uploadVersion: () => { return `${Constant.baseAPI}/uploadExcel` },
        refreshVersion: () => { return `${Constant.baseAPI}/forceUpdate` },
        checkToken: () => { return `${Constant.baseAPI}/checkToken` },
        updateVersionType: (id: string) => { return `${Constant.baseAPI}/updateType/${id}` },
      },
	},
	
	const Utility = {
		initSetting: () => {
    		Constant.baseAPI = `${process.env.VUE_APP_BASE_API}`
    	},
  	}
}

範例程式碼下載 - GitHub

後記

做完之後發現,平時簡簡單單使用的API,原來背後有這麼深的學問啊,果然每個行業都有它的專業…而且,看起來簡簡單單的設定介面,居然要16支API,真的是超乎我的想象啊…學完了該學的基礎之後,有空再來學學說話的高超技巧吧,物美價廉的我,便宜一樣有好貨… 理論總是簡單,但要實做出來就是另外一回事了,就像有人說,籃球不就是把球投進籃框就好了嗎?是…是沒錯啦;真奇怪,都寫了60篇了,怎麼都沒有人找我業配啊?🤣