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定義UserTodo的結構體

// 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
}

然後,修改上面的LoginLogout接口, 增加身份驗證及授權信息

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其它相關文章!

推薦閱讀: