【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
- 以下是使用者struct的相關欄位
- 主要是把使用者分成三個等級:Account (使用者) / Admin (管理者) / Root (老大)
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
- 這個就牽扯到密碼學的部分,其實認證的方法有很多,斯斯都有三種了,對吧?
- 因為世上沒有不能破解的密碼,所以才有雙重認證 / 二階段驗證 / 2FA (Two-factor Authentication)的產生…
- 所以呢,產生出來的Token就是…要破解很久,然後重覆性極低
Basic Authentication
- 首先,人類製造出來的東西,絕大部分是用來解決問題的,複雜問題簡單化…
- 先來介紹一個最簡單的 - Basic Authentication…
- RFC文件中有記載它的細節,RFC 2617 / RFC 7617,因為太文謅謅了,所以我從來沒看過…🤣
- 那為什麼需要認證碼呢?因為呢,如果登入成功之後,還是每點一個功能,就要輸入一次帳密,這不是很麻煩嗎?那要怎麼辦呢?
- 把帳密放在一起變成一串字,然後給它編碼一下,就變成亂碼了,當然也可以加點料…
- 不一定要加鹽喲,加糖、加胡椒也是可以的,還是不要吃太鹹比較好…🤣
- 而那一串看不懂的字串,就叫做Token,中文是要叫代幣?還是令牌?
- 因為民情不一,個人比較喜歡叫通行證啦,就像去101樓上,要拿身份證明文件換通行證一樣…🤣
- 也類似湯姆熊一樣,拿真錢換代幣(
假錢) / Token - 登入成功之後,我們電腦傳過去的大約是長這樣,是不是看不到帳密了啊?
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
- 文件在這裡 - RFC 7519,還是沒看過…🤣
- JSON Web Token - JWT的Token產生,其實就是基於上面說的 - Basic Authentication,只是弄亂的方式 (編碼)變的更複雜而已…
- 傳過去大約是長這樣,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
- 使用者登入後會取得Token,然後把它存起來,就算瀏覽器關掉,下次還是可以取得到…
- 像iOS會存在UserDefault,或者是Keychain裡面;Android,會存在SharedPreferences之內,當然可以使用KeyStore解密一下…
- 那在瀏覽器可以存在Cookie / SessionStorage / LocalStorage
- 這裡選擇儲存在LocalStorage中,打完login的API,就把它存起來吧…
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 }
}
},
}
}
判斷授權
- 這段我想超久的,好加在有寫過grails,就想仿一下設定…
- 其實一定有套件可以處理這一段,但目前還是先這樣做吧…
- 利用Middleware - 中間件來處理…
// 註冊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)
}
- 這個中間件,就是卡在中間擋路的…
- 要注意的是要使用AbortWithStatusJSON,才會中止Router的動作…
// 中間件 (卡在中間擋路的) => 測試是否登入?沒有沒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環境設定檔
- 總於寫完了,來上線吧…等等,不對啊,host是127.0.0.1耶,跟申請的不一樣,難到一定要上傳才能測試嗎?
- Vue CLI早就幫各位想好了,就是.env設定檔,在裡面可以設定一些常數值去做切換…
- 在這個package.json檔內去做設定,一般我們Key的npm run serve / yarn serve,就是裡面的scripts的值…
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篇了,怎麼都沒有人找我業配啊?🤣