Golang基於JWT與Casbin身份驗證授權實例詳解
JWT
JSON Web Toekn(JWT)是一個開放標準RFC 7519,以JSON的方式進行通信,是目前最流行的一種身份驗證方式之一。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
下圖是通過JWT.io解碼,查看JWT token的組成
可以看出JWT是由下面三個部分組成的:
- 頭部(Header)
- 載荷(Payload)
- 簽名(Signature)
Header
Header是Token的構成的第一部分,包含瞭Token類型、Token使用的加密算法(加密算法可以是HMAC, SHA256或者是RSA等)。在某些場景下,可以使用kid字段,用來標識一個密鑰的ID
Payload
Payload是token的第二部分,由JWT標準中註冊的、公共的、私有的聲明三部分組成。Payload通常包含一些用戶的聲明信息,比如簽發者、過期時間、簽發時間等。其中最常見的是issuer, expiration, subject。
- issuer被用來標識token的頒發人
- expiration是token的過期時間
- subject被用來標識token主體部分
Signature
Signature是由頭部和載荷加密後連接起來的,程序通過驗證Signature是否合法來決定認證是否通過
JWT的優勢
- 體積小。JWT是采用JSON進行通信的,JSON比XML更加簡潔,因此在對其編碼時,JWT的體積比SAML更小(SAML是一種基於XML的開放標準,用在身份提供者和服務提供者之間交換身份驗證和授權的數據,SAML的一個重要的應用就是基於Web的單點登錄)
- 更加安全。JWT能夠使用公鑰或者私鑰對證書進行加密或解密,雖然SAML也可以使用JWT等公鑰或私鑰進行加密或解密,但是與JSON相比,使用XML數字簽名容易引進比較晦澀的安全漏洞
- 更加通用。JSON可以轉換成很多語言的對象方式,而XML沒有一種可以轉為對象的映射
- 更容易處理。不管是在PC端還是在移動端,JSON都能夠很好的進行通信
JWT的使用場景
- 身份驗證。
- 授權
- 信息交換
需要註意的是不要將敏感信息存在Token裡面!!!
Casbin
Casbin是一個強大的、高效的、開源的權限訪問控制庫,它提供瞭多種權限控制訪問模型,比如ACL(權限控制列表)、RBAC(基於角色的訪問控制)、ABAC(基於屬性的權限驗證)等。除此之外Casbin還支持多種編程語言
Casbin可以做什麼
- 通過經典的
{subject, object, action}
或者自定義的模式執行想要的策略,同時支持allow和deny兩種授權方式 - 處理控制訪問存儲和權限
- 管理用戶-角色-資源權限控制訪問映射(RBAC)
- 支持超級管理員授權方式
- 可以使用內置的函數配置訪問規則
Casbin不可以做什麼
- 使用用戶名或密碼登錄的身份驗證
- 管理用戶或者角色列表,這些由系統本身管理更加方便,casbin主要是用來作為用戶-角色的一種權限訪問控制映射
Casbin的工作原理
在Casbin中,訪問控制模型被抽象為PERM(Policy, Effect, Request, Matcher)
的一個文件
- Request
定義請求參數。基本請求時一個元組對象,至少需要主題(訪問實體), 對象(訪問資源), 動作(訪問方式),例如r={sub, obj, act}
,它實際定義瞭我們應該提供訪問控制匹配功能的參數名稱和順序
- Policy
定義訪問策略模式,例如p={sub, obj, act}或p={sub, obj, act, eff}
, 它定義字段的名稱和順序
- Matcher
匹配請求和策略的規則,例如m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
,它的意思是如果請求的參數被匹配,那麼結果就會被返回
- Effect
匹配後的結果會儲存於Effect當中,可以對匹配結果再次做出邏輯判斷,例如e = some (where (p.eft == allow))
Casbin中最基本的model是ACL,下面是ACL的model配置
[request_definition] r = sub, obj, act # Policy definition [policy_definition] p = sub, obj, act # Policy effect [policy_effect] e = some(where (p.eft == allow)) # Matchers [matchers] m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
實踐
編寫一個簡單的TODO
RESTful API。
創建一個simple-jwt-auth
的目錄,然後通過go mod
管理依賴 go mod init simple-jwt-auth
建立的目錄結構如下:
在model定義User
和Todo
的結構體
// models/model.go type User struct { ID string `json:"id"` UserName string `json:"username"` Password string `json:"password"` } type Todo struct { UserID string `json:"user_id"` Title string `json:"title"` Body string `json:"body"` } // SetPassword sets a new password stored as hash. func (m *User) SetPassword(password string) error { if len(password) < 6 { return fmt.Errorf("new password for %s must be at least 6 characters", m.UserName) } m.Password = password return nil } // InvalidPassword returns true if the given password does not match the hash. func (m *User) InvalidPassword(password string) bool { if password == "" { return true } if m.Password != password { return true } return false }
登錄接口請求
當用戶通過用戶名和密碼等信息登錄系統服務時,需要驗證是否已註冊、密碼是否正確等,然後返回信息, 下面在api
層實現Login
的接口:
// api/auth_api.go func Login(c *gin.Context) { var u models.User if err := c.ShouldBindJSON(&u); err != nil { c.JSON(http.StatusUnprocessableEntity, "Invalid json provided") return } //find user with username user, err := models.UserRepo.FindByID(1) //compare the user from the request, with the one we defined: if user.UserName != u.UserName || user.Password != u.Password { c.JSON(http.StatusUnauthorized, "Please provide valid login details") return } c.JSON(http.StatusOK, "Login successfully") } func Logout(c *gin.Context) { c.JSON(http.StatusOK, "Successfully logged out") }
在真實的項目中,數據都是存在數據庫中。在該教程中,為瞭方便,創建一個mock文件user_repository.go
// models/user_repository.go var us = []User{ { ID: "2", UserName: "users", Password: "pass", }, { ID: "3", UserName: "username", Password: "password", }, } var UserRepo = UserRepository{ Users: us, } type UserRepository struct { Users []User } func (r *UserRepository) FindAll() ([]User, error) { return r.Users, nil } func (r *UserRepository) FindByID(id int) (User, error) { for _, v := range r.Users { uid, err := strconv.Atoi(v.ID) if err != nil { return User{}, err } if uid == int(id) { return v, nil } } return User{}, errors.New("Not found") } func (r *UserRepository) Save(user User) (User, error) { r.Users = append(r.Users, user) return user, nil } func (r *UserRepository) Delete(user User) { id := -1 for i, v := range r.Users { if v.ID == user.ID { id = i break } } if id == -1 { log.Fatal("Not found user ") return } r.Users[id] = r.Users[len(r.Users)-1] // Copy last element to index i. r.Users[len(r.Users)-1] = User{} // Erase last element (write zero value). r.Users = r.Users[:len(r.Users)-1] // Truncate slice. return }
為瞭不讓Login
函數變得臃腫,生成token
的邏輯放在auth
目錄中, 下面實現token
驗證邏輯
Token實現
用JWT
實現的系統中,用戶登錄後,系統會生成並返回一個token
給用戶,下次請求時將會帶上該token
進行身份驗證。token有以下問題需要處理:
- 用戶退出登錄的時候,需要使token失效
token
有可能被黑客劫持和使用- 當
token
過期後需要用戶重新登錄,體驗不友好
上面的問題可以通過以下兩種方式解決:
- 使用Redis存儲token的信息。當用戶退出時,使token失效, 這在一定程度上提高的安全性
- 在token過期的時候,使用刷新token的方式重新生成一個token, 不用用戶退出登錄,提高用戶體驗
使用Redis存儲Token信息
使用uuid
作為redis中的key, token信息作為value, 下面定義TokenManager
結構體,通過接口的方式實現token
type TokenManager struct{} func NewTokenService() *TokenManager { return &TokenManager{} } type TokenInterface interface { CreateToken(userId, userName string) (*TokenDetails, error) ExtractTokenMetadata(*http.Request) (*AccessDetails, error) } //Token implements the TokenInterface var _ TokenInterface = &TokenManager{} func (t *TokenManager) CreateToken(userId, userName string) (*TokenDetails, error) { td := &TokenDetails{} td.AtExpires = time.Now().Add(time.Minute * 30).Unix() //expires after 30 min td.TokenUuid = uuid.NewV4().String() td.RtExpires = time.Now().Add(time.Hour * 24 * 7).Unix() td.RefreshUuid = td.TokenUuid + "++" + userId var err error //Creating Access Token atClaims := jwt.MapClaims{} atClaims["access_uuid"] = td.TokenUuid atClaims["user_id"] = userId atClaims["user_name"] = userName atClaims["exp"] = td.AtExpires at := jwt.NewWithClaims(jwt.SigningMethodHS256, atClaims) td.AccessToken, err = at.SignedString([]byte(os.Getenv("ACCESS_SECRET"))) if err != nil { return nil, err } //Creating Refresh Token td.RtExpires = time.Now().Add(time.Hour * 24 * 7).Unix() td.RefreshUuid = td.TokenUuid + "++" + userId rtClaims := jwt.MapClaims{} rtClaims["refresh_uuid"] = td.RefreshUuid rtClaims["user_id"] = userId rtClaims["user_name"] = userName rtClaims["exp"] = td.RtExpires rt := jwt.NewWithClaims(jwt.SigningMethodHS256, rtClaims) td.RefreshToken, err = rt.SignedString([]byte(os.Getenv("REFRESH_SECRET"))) if err != nil { return nil, err } return td, nil } func (t *TokenManager) ExtractTokenMetadata(r *http.Request) (*AccessDetails, error) { token, err := VerifyToken(r) if err != nil { return nil, err } acc, err := Extract(token) if err != nil { return nil, err } return acc, nil } func TokenValid(r *http.Request) error { token, err := VerifyToken(r) if err != nil { return err } if _, ok := token.Claims.(jwt.Claims); !ok && !token.Valid { return err } return nil } func VerifyToken(r *http.Request) (*jwt.Token, error) { tokenString := ExtractToken(r) token, err := jwt.Parse(tokenString, 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 []byte(os.Getenv("ACCESS_SECRET")), nil }) if err != nil { return nil, err } return token, nil } //get the token from the request body func ExtractToken(r *http.Request) string { bearToken := r.Header.Get("Authorization") strArr := strings.Split(bearToken, " ") if len(strArr) == 2 { return strArr[1] } return "" } func Extract(token *jwt.Token) (*AccessDetails, error) { claims, ok := token.Claims.(jwt.MapClaims) if ok && token.Valid { accessUuid, ok := claims["access_uuid"].(string) userId, userOk := claims["user_id"].(string) userName, userNameOk := claims["user_name"].(string) if ok == false || userOk == false || userNameOk == false { return nil, errors.New("unauthorized") } else { return &AccessDetails{ TokenUuid: accessUuid, UserId: userId, UserName: userName, }, nil } } return nil, errors.New("something went wrong") } func ExtractTokenMetadata(r *http.Request) (*AccessDetails, error) { token, err := VerifyToken(r) if err != nil { return nil, err } acc, err := Extract(token) if err != nil { return nil, err } return acc, nil }
上面的代碼設置token的有效時間為30分鐘,30分鐘過後token將失效,用戶不能使用該token進行正確驗證。
另外,使用瞭從.env
配置文件獲取的密鑰(ACCESS_SECRET)簽名。在真實的項目,不能在代碼中公開這個密鑰!!!
REDIS_HOST=127.0.0.1 REDIS_PORT=6379 REDIS_PASSWORD= ACCESS_SECRET=98hbun98hsdfsdwesdfs REFRESH_SECRET=786dfdbjhsbsdfsdfsdf PORT=8081
定義AuthInterface
處理會話
package auth import ( "errors" "fmt" "github.com/go-redis/redis/v7" "time" ) type AccessDetails struct { TokenUuid string UserId string UserName string } type TokenDetails struct { AccessToken string RefreshToken string TokenUuid string RefreshUuid string AtExpires int64 RtExpires int64 } type AuthInterface interface { CreateAuth(string, *TokenDetails) error FetchAuth(string) (string, error) DeleteRefresh(string) error DeleteTokens(*AccessDetails) error } type RedisAuthService struct { client *redis.Client } var _ AuthInterface = &RedisAuthService{} func NewAuthService(client *redis.Client) *RedisAuthService { return &RedisAuthService{client: client} } //Save token metadata to Redis func (tk *RedisAuthService) CreateAuth(userId string, td *TokenDetails) error { at := time.Unix(td.AtExpires, 0) //converting Unix to UTC(to Time object) rt := time.Unix(td.RtExpires, 0) now := time.Now() atCreated, err := tk.client.Set(td.TokenUuid, userId, at.Sub(now)).Result() if err != nil { return err } rtCreated, err := tk.client.Set(td.RefreshUuid, userId, rt.Sub(now)).Result() if err != nil { return err } if atCreated == "0" || rtCreated == "0" { return errors.New("no record inserted") } return nil } //Check the metadata saved func (tk *RedisAuthService) FetchAuth(tokenUuid string) (string, error) { userid, err := tk.client.Get(tokenUuid).Result() if err != nil { return "", err } return userid, nil } //Once a user row in the token table func (tk *RedisAuthService) DeleteTokens(authD *AccessDetails) error { //get the refresh uuid refreshUuid := fmt.Sprintf("%s++%s", authD.TokenUuid, authD.UserId) //delete access token deletedAt, err := tk.client.Del(authD.TokenUuid).Result() if err != nil { return err } //delete refresh token deletedRt, err := tk.client.Del(refreshUuid).Result() if err != nil { return err } //When the record is deleted, the return value is 1 if deletedAt != 1 || deletedRt != 1 { return errors.New("something went wrong") } return nil } func (tk *RedisAuthService) DeleteRefresh(refreshUuid string) error { //delete refresh token deleted, err := tk.client.Del(refreshUuid).Result() if err != nil || deleted == 0 { return err } return nil }
用Casbin做授權管理
在Casbin中,一個權限訪問控制模型的配置文件是基於PERM(Policy, Effect, Role, Matcher)
的方式,因此當要修改或升級權限的時候非常方便,隻需要修改配置文件就行瞭。使用者可以自定義配置文件,例如定義RBAC
或者ACL
最基本的也是最簡單的模型是ACL
, 下面創建一個ACL
的模型配置文件
[request_definition] r = sub, obj, act [policy_definition] p = sub, obj, act [role_definition] g = _, _ [policy_effect] e = some(where (p.eft == allow)) [matchers] m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
Casbin 的權限是存儲在.csv
文件中或者是SQL
數據庫中, 在該教程是通過csv
文件的方式存儲
p, user, resource, read p, username, resource, read p, admin, resource, read p, admin, resource, write g, alice, admin g, bob, user
上面權限的意思是:
- 所有的用戶可以讀數據,但是不能寫
- 所有的admin用戶可以讀數據,也可以寫數據
- alice是admin用戶,bob是普通用戶 因此Alice有控制整個系統數據的權限,而Bob隻有讀的權限
實現Casbin的策略
首先,定義一個policies的中間件
import ( "fmt" "github.com/casbin/casbin" "github.com/casbin/casbin/persist" "github.com/gin-gonic/gin" "github.com/simple-jwt-auth/auth" "log" "net/http" ) func TokenAuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { err := auth.TokenValid(c.Request) if err != nil { c.JSON(http.StatusUnauthorized, "unauthorized") c.Abort() return } c.Next() } } // Authorize determines if current subject has been authorized to take an action on an object. func Authorize(obj string, act string, adapter persist.Adapter) gin.HandlerFunc { return func(c *gin.Context) { err := auth.TokenValid(c.Request) if err != nil { c.JSON(http.StatusUnauthorized, "user hasn't logged in yet") c.Abort() return } metadata, err := auth.ExtractTokenMetadata(c.Request) if err != nil { c.JSON(http.StatusUnauthorized, "unauthorized") return } // casbin enforces policy ok, err := enforce(metadata.UserName, obj, act, adapter) //ok, err := enforce(val.(string), obj, act, adapter) if err != nil { log.Println(err) c.AbortWithStatusJSON(500, "error occurred when authorizing user") return } if !ok { c.AbortWithStatusJSON(403, "forbidden") return } c.Next() } } func enforce(sub string, obj string, act string, adapter persist.Adapter) (bool, error) { enforcer := casbin.NewEnforcer("config/rbac_model.conf", adapter) err := enforcer.LoadPolicy() if err != nil { return false, fmt.Errorf("failed to load policy from DB: %w", err) } ok := enforcer.Enforce(sub, obj, act) return ok, nil }
然後,修改上面的Login
和Logout
接口, 增加身份驗證及授權信息
func Login(c *gin.Context) { var u models.User if err := c.ShouldBindJSON(&u); err != nil { c.JSON(http.StatusUnprocessableEntity, "Invalid json provided") return } //find user with username user, err := models.UserRepo.FindByID(1) //compare the user from the request, with the one we defined: if user.UserName != u.UserName || user.Password != u.Password { c.JSON(http.StatusUnauthorized, "Please provide valid login details") return } ts, err := tokenManager.CreateToken(user.ID, user.UserName) if err != nil { c.JSON(http.StatusUnprocessableEntity, err.Error()) return } save token to redis saveErr := servers.HttpServer.RD.CreateAuth(user.ID, ts) if saveErr != nil { c.JSON(http.StatusUnprocessableEntity, saveErr.Error()) } tokens := map[string]string{ "access_token": ts.AccessToken, "refresh_token": ts.RefreshToken, } c.JSON(http.StatusOK, tokens) } func Logout(c *gin.Context) { //If metadata is passed and the tokens valid, delete them from the redis store metadata, _ := tokenManager.ExtractTokenMetadata(c.Request) if metadata != nil { deleteErr := servers.HttpServer.RD.DeleteTokens(metadata) if deleteErr != nil { c.JSON(http.StatusBadRequest, deleteErr.Error()) return } } c.JSON(http.StatusOK, "Successfully logged out") }
創建Todo
定義Todo
的結構體
type Todo struct { UserID string `json:"user_id"` Title string `json:"title"` Body string `json:"body"` }
創建Todo
的接口
package api import ( "github.com/gin-gonic/gin" "github.com/simple-jwt-auth/auth" "github.com/simple-jwt-auth/models" "net/http" ) func CreateTodo(c *gin.Context) { var td models.Todo if err := c.ShouldBindJSON(&td); err != nil { c.JSON(http.StatusUnprocessableEntity, "invalid json") return } metadata, err := auth.ExtractTokenMetadata(c.Request) if err != nil { c.JSON(http.StatusUnauthorized, "unauthorized") return } td.UserID = metadata.UserId //you can proceed to save the to a database c.JSON(http.StatusCreated, td) } func GetTodo(c *gin.Context) { metadata, err := auth.ExtractTokenMetadata(c.Request) if err != nil { c.JSON(http.StatusUnauthorized, "unauthorized") return } userId := metadata.UserId c.JSON(http.StatusOK, models.Todo{ UserID: userId, Title: "Return todo", Body: "Return todo for testing", }) }
註冊路由
func (s *Server) InitializeRoutes() { s.Router.POST("/login", api.Login) authorized := s.Router.Group("/") authorized.Use(gin.Logger()) authorized.Use(gin.Recovery()) authorized.Use(middleware.TokenAuthMiddleware()) { authorized.POST("/api/todo", middleware.Authorize("resource", "write", s.FileAdapter), api.CreateTodo) authorized.GET("/api/todo", middleware.Authorize("resource", "read", s.FileAdapter), api.GetTodo) authorized.POST("/logout", api.Logout) authorized.POST("/refresh", api.Refresh) } }
最後在main.go
文件中調用server
層的Run方法即可運行
package main import ( "github.com/joho/godotenv" "github.com/simple-jwt-auth/servers" "log" ) func init() { if err := godotenv.Load(); err != nil { log.Print("No .env file found") } } func main() { servers.Run() log.Println("Server exiting") }
結果如下:
原作者倉庫地址github
以上就是Golang基於JWT與Casbin身份驗證授權實例詳解的詳細內容,更多關於Go JWT Casbin身份驗證授權的資料請關註WalkonNet其它相關文章!
推薦閱讀:
- golang進行簡單權限認證的實現
- 如何使用Casbin作為ThinkPHP的權限控制中間件
- golang中gin框架接入jwt使用token驗證身份
- go gin+token(JWT)驗證實現登陸驗證
- golang gin框架獲取參數的操作