Jelajahi Sumber

feat(auth): 添加基于 Casbin 的权限控制功能

- 新增 AdminUser、Role 和 Api 模型,用于管理后台用户、角色和 API 资源
- 添加 AuthMiddleware 中间件,用于权限验证和授权
- 更新 HTTP服务器,集成 Casbin Enforcer 和权限控制逻辑
- 新增错误消息,用于处理权限不足等场景
fusu 1 bulan lalu
induk
melakukan
9202e34b41
8 mengubah file dengan 141 tambahan dan 8 penghapusan
  1. 8 5
      api/v1/errors.go
  2. 5 2
      go.mod
  3. 6 0
      go.sum
  4. 49 0
      internal/middleware/rabc.go
  5. 46 0
      internal/model/admin.go
  6. 24 0
      internal/model/menu.go
  7. 3 1
      internal/server/http.go
  8. TEMPAT SAMPAH
      storage/nunu-test-v1.db

+ 8 - 5
api/v1/errors.go

@@ -1,12 +1,15 @@
 package v1
 
 var (
-	// common errors
 	ErrSuccess             = newError(0, "ok")
-	ErrBadRequest          = newError(400, "Bad Request")
-	ErrUnauthorized        = newError(401, "Unauthorized")
-	ErrNotFound            = newError(404, "Not Found")
-	ErrInternalServerError = newError(500, "Internal Server Error")
+	ErrBadRequest          = newError(400, "参数错误")
+	ErrUnauthorized        = newError(401, "登录失效,请重新登录~")
+	ErrNotFound            = newError(404, "数据不存在")
+	ErrForbidden           = newError(403, "权限不足,请联系管理员开通权限~")
+	ErrInternalServerError = newError(500, "服务器错误~")
+
+	// more biz errors
+	ErrUsernameAlreadyUse = newError(1001, "The username is already in use.")
 
 	// more biz errors
 	ErrEmailAlreadyUse = newError(1001, "The email is already in use.")

+ 5 - 2
go.mod

@@ -8,6 +8,7 @@ require (
 	github.com/AlekSi/pointer v1.2.0
 	github.com/DATA-DOG/go-sqlmock v1.5.2
 	github.com/PuerkitoBio/goquery v1.10.3
+	github.com/casbin/casbin/v2 v2.108.0
 	github.com/duke-git/lancet/v2 v2.3.5
 	github.com/gavv/httpexpect/v2 v2.16.0
 	github.com/gin-gonic/gin v1.9.1
@@ -15,6 +16,7 @@ require (
 	github.com/go-co-op/gocron v1.28.3
 	github.com/golang-jwt/jwt/v5 v5.2.2
 	github.com/golang/mock v1.6.0
+	github.com/google/uuid v1.3.1
 	github.com/google/wire v0.5.0
 	github.com/jinzhu/copier v0.4.0
 	github.com/mcuadros/go-defaults v1.2.0
@@ -32,6 +34,7 @@ require (
 	go.mongodb.org/mongo-driver v1.17.4
 	go.uber.org/zap v1.26.0
 	golang.org/x/crypto v0.39.0
+	golang.org/x/net v0.41.0
 	golang.org/x/sync v0.15.0
 	google.golang.org/grpc v1.55.1
 	gorm.io/driver/mysql v1.5.7
@@ -46,7 +49,9 @@ require (
 	github.com/ajg/form v1.5.1 // indirect
 	github.com/andybalholm/brotli v1.0.4 // indirect
 	github.com/andybalholm/cascadia v1.3.3 // indirect
+	github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect
 	github.com/bytedance/sonic v1.10.2 // indirect
+	github.com/casbin/govaluate v1.3.0 // indirect
 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 	github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
 	github.com/chenzhuoyu/iasm v0.9.0 // indirect
@@ -72,7 +77,6 @@ require (
 	github.com/golang/protobuf v1.5.4 // indirect
 	github.com/golang/snappy v1.0.0 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
-	github.com/google/uuid v1.3.1 // indirect
 	github.com/gorilla/websocket v1.4.2 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
 	github.com/hpcloud/tail v1.0.0 // indirect
@@ -128,7 +132,6 @@ require (
 	go.uber.org/multierr v1.11.0 // indirect
 	golang.org/x/arch v0.3.0 // indirect
 	golang.org/x/exp v0.0.0-20221208152030-732eee02a75a // indirect
-	golang.org/x/net v0.41.0 // indirect
 	golang.org/x/sys v0.33.0 // indirect
 	golang.org/x/text v0.26.0 // indirect
 	golang.org/x/tools v0.33.0 // indirect

+ 6 - 0
go.sum

@@ -64,6 +64,8 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV
 github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
 github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
 github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
+github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
+github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
 github.com/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao=
 github.com/bsm/ginkgo/v2 v2.7.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w=
 github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y=
@@ -72,6 +74,10 @@ github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1
 github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
 github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
 github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
+github.com/casbin/casbin/v2 v2.108.0 h1:aMc3I81wfLpQe/uzMdElB1OBhEmPZoWMPb2nfEaKygY=
+github.com/casbin/casbin/v2 v2.108.0/go.mod h1:Ee33aqGrmES+GNL17L0h9X28wXuo829wnNUnS0edAco=
+github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc=
+github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=

+ 49 - 0
internal/middleware/rabc.go

@@ -0,0 +1,49 @@
+package middleware
+
+import (
+	"github.com/casbin/casbin/v2"
+	"github.com/duke-git/lancet/v2/convertor"
+	"github.com/gin-gonic/gin"
+	v1 "github.com/go-nunu/nunu-layout-advanced/api/v1"
+	"github.com/go-nunu/nunu-layout-advanced/internal/model"
+	"github.com/go-nunu/nunu-layout-advanced/pkg/jwt"
+	"net/http"
+)
+
+func AuthMiddleware(e *casbin.SyncedEnforcer) gin.HandlerFunc {
+	return func(ctx *gin.Context) {
+		// 从上下文获取用户信息(假设通过 JWT 或其他方式设置)
+		v, exists := ctx.Get("claims")
+		if !exists {
+			v1.HandleError(ctx, http.StatusUnauthorized, v1.ErrUnauthorized, nil)
+			ctx.Abort()
+			return
+		}
+		uid := v.(*jwt.MyCustomClaims).UserId
+		if convertor.ToString(uid) == model.AdminUserID {
+			// 防呆设计,超管跳过API权限检查
+			ctx.Next()
+			return
+		}
+
+		// 获取请求的资源和操作
+		sub := convertor.ToString(uid)
+		obj := model.ApiResourcePrefix + ctx.Request.URL.Path
+		act := ctx.Request.Method
+
+		// 检查权限
+		allowed, err := e.Enforce(sub, obj, act)
+		if err != nil {
+			v1.HandleError(ctx, http.StatusForbidden, v1.ErrForbidden, nil)
+			ctx.Abort()
+			return
+		}
+		if !allowed {
+			v1.HandleError(ctx, http.StatusForbidden, v1.ErrForbidden, nil)
+			ctx.Abort()
+			return
+		}
+
+		ctx.Next()
+	}
+}

+ 46 - 0
internal/model/admin.go

@@ -0,0 +1,46 @@
+package model
+
+import "gorm.io/gorm"
+
+const (
+	AdminRole          = "admin"
+	AdminUserID        = "1"
+	MenuResourcePrefix = "menu:"
+	ApiResourcePrefix  = "api:"
+	PermSep            = ","
+)
+
+type AdminUser struct {
+	gorm.Model
+	Username string `gorm:"type:varchar(50);not null;uniqueIndex;comment:'用户名'"`
+	Nickname string `gorm:"type:varchar(50);not null;comment:'昵称'"`
+	Password string `gorm:"type:varchar(255);not null;comment:'密码'"`
+	Email    string `gorm:"type:varchar(100);not null;comment:'电子邮件'"`
+	Phone    string `gorm:"type:varchar(20);not null;comment:'手机号'"`
+}
+
+func (m *AdminUser) TableName() string {
+	return "admin_users"
+}
+
+type Role struct {
+	gorm.Model
+	Name string `json:"name" gorm:"column:name;type:varchar(100);uniqueIndex;comment:角色名"`
+	Sid  string `json:"sid" gorm:"column:sid;type:varchar(100);uniqueIndex;comment:角色标识"`
+}
+
+func (m *Role) TableName() string {
+	return "roles"
+}
+
+type Api struct {
+	gorm.Model
+	Group  string `gorm:"type:varchar(100);not null;comment:'API分组'"`
+	Name   string `gorm:"type:varchar(100);not null;comment:'API名称'"`
+	Path   string `gorm:"type:varchar(255);not null;comment:'API路径'"`
+	Method string `gorm:"type:varchar(20);not null;comment:'HTTP方法'"`
+}
+
+func (m *Api) TableName() string {
+	return "api"
+}

+ 24 - 0
internal/model/menu.go

@@ -0,0 +1,24 @@
+package model
+
+import "gorm.io/gorm"
+
+type Menu struct {
+	gorm.Model
+	ParentID   uint   `json:"parentId,omitempty" gorm:"column:parent_id;index;comment:父级菜单的id,使用整数表示"`     // 父级菜单的id,使用整数表示
+	Path       string `json:"path" gorm:"column:path;type:varchar(255);comment:地址"`                        // 地址
+	Title      string `json:"title" gorm:"column:title;type:varchar(100);comment:标题,使用字符串表示"`              // 标题,使用字符串表示
+	Name       string `json:"name,omitempty" gorm:"column:name;type:varchar(100);comment:同路由中的name,用于保活"`  // 同路由中的name,用于保活
+	Component  string `json:"component,omitempty" gorm:"column:component;type:varchar(255);comment:绑定的组件"` // 绑定的组件,默认类型:Iframe、RouteView、ComponentError
+	Locale     string `json:"locale,omitempty" gorm:"column:locale;type:varchar(100);comment:本地化标识"`       // 本地化标识
+	Icon       string `json:"icon,omitempty" gorm:"column:icon;type:varchar(100);comment:图标,使用字符串表示"`      // 图标,使用字符串表示
+	Redirect   string `json:"redirect,omitempty" gorm:"column:redirect;type:varchar(255);comment:重定向地址"`   // 重定向地址
+	URL        string `json:"url,omitempty" gorm:"column:url;type:varchar(255);comment:iframe模式下的跳转url"`   // iframe模式下的跳转url,不能与path重复
+	KeepAlive  bool   `json:"keepAlive,omitempty" gorm:"column:keep_alive;default:false;comment:是否保活"`     // 是否保活
+	HideInMenu bool   `json:"hideInMenu,omitempty" gorm:"column:hide_in_menu;default:false;comment:是否保活"`  // 是否保活
+	Target     string `json:"target,omitempty" gorm:"column:target;type:varchar(20);comment:全连接跳转模式"`      // 全连接跳转模式:'_blank'、'_self'、'_parent'
+	Weight     int    `json:"weight" gorm:"column:weight;type:int;default:0;comment:排序权重"`
+}
+
+func (m *Menu) TableName() string {
+	return "menu"
+}

+ 3 - 1
internal/server/http.go

@@ -1,6 +1,7 @@
 package server
 
 import (
+	"github.com/casbin/casbin/v2"
 	"github.com/gin-gonic/gin"
 	apiV1 "github.com/go-nunu/nunu-layout-advanced/api/v1"
 	"github.com/go-nunu/nunu-layout-advanced/docs"
@@ -19,6 +20,7 @@ func NewHTTPServer(
 	logger *log.Logger,
 	conf *viper.Viper,
 	jwt *jwt.JWT,
+	e *casbin.SyncedEnforcer,
 	limiterInstance *limiter.Limiter,
 	rateLimitMiddleware gin.HandlerFunc,
 	userHandler *handler.UserHandler,
@@ -130,7 +132,7 @@ func NewHTTPServer(
 		}
 
 		// Strict permission routing group
-		strictAuthRouter := v1.Group("/").Use(middleware.StrictAuth(jwt, logger))
+		strictAuthRouter := v1.Group("/").Use(middleware.StrictAuth(jwt, logger), middleware.AuthMiddleware(e))
 		{
 			strictAuthRouter.PUT("/user", userHandler.UpdateProfile)
 		}

TEMPAT SAMPAH
storage/nunu-test-v1.db