Golang - 基于JWT与Casbin的身份验证与授权

5,192 阅读10分钟

本文主要翻译于Authentication and authorization in Gin application with JWT and Casbin | by DPBD90 | Medium

JWT

JSON Web Toekn(JWT)是一个开放标准RFC 7519,以JSON的方式进行通信,是目前最流行的一种身份验证方式之一。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

下图是通过JWT.io解码,查看JWT token的组成

image.png 可以看出JWT是由下面三个部分组成的:

  1. 头部(Header)
  2. 载荷(Payload)
  3. 签名(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还支持多种编程语言

image.png

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 建立的目录结构如下:

image.png

在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有以下问题需要处理:

  1. 用户退出登录的时候,需要使token失效
  2. token有可能被黑客劫持和使用
  3. token过期后需要用户重新登录,体验不友好

上面的问题可以通过以下两种方式解决:

  1. 使用Redis存储token的信息。当用户退出时,使token失效, 这在一定程度上提高的安全性
  2. 在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

上面权限的意思是:

  1. 所有的用户可以读数据,但是不能写
  2. 所有的admin用户可以读数据,也可以写数据
  3. 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")
}

结果如下: image.png

原作者仓库地址github

本文正在参加技术专题18期-聊聊Go语言框架