最近跟著學長再寫河南師范大學附屬中學圖書館的項目,學長交給了我一個任務,把本項目的權限管理給吃透,然后應用到下一個項目上。
我當然是偷著樂吶,因為讀代碼的時候,總是莫名給我一種公費旅游的感覺。
本來就想去了解圖書管理這個項目的全貌。但一直騰不出時間。
現在正巧,我要寫一個權限管理,正好可以拐回來細細品讀圖書管理系統的代碼( ̄﹃ ̄)。
熟悉的配方,本項目使用的是RBAC模型來管理權限。
目錄
一、RBAC
1、傳統方案
2、如何通過RBAC改進?
3、如何設計代碼?
二、中間件設計
1、理論設計方式
2、圖書館項目的設計
a.跳過重復路徑
b.獲取JWT憑證
可以拓展一下(AI):
三、基于圖書館的權限樹設計
1、權限樹設計
2、權限層級映射:
四、圖書館項目
1、整體架構:
2、核心組件解析
a、 用戶信息結構 (UserInfo)
b、角色模型 (Role)
c、權限樹結構 (Permission)
3、權限設計層級
a、三級權限結構:
五、前端如何進行權限控制
現實場景:
完整權限控制
首先從后端獲取權限數據
第一層防護:菜單不顯示
第二層防護:系統管理子菜單過濾
第三層防護:權限指令控制
第四層防護:組合式函數權限檢查
第五層防護:直接URL訪問
收獲:
一、RBAC
為什么需要RBAC來管理項目的呢
大家可以想象這樣一個場景:
想象你是一所大學圖書館的IT負責人。新學期開始了,
圖書館迎來了以下用戶:
學生小王:只想借書還書,查看自己的借閱記錄
老師張三:除了借書,還需要幫學生查詢圖書,管理班級借閱情況
管理員李四:需要添加新書、管理用戶賬號、查看所有借閱統計
系統管理員王五:擁有系統的完全控制權,包括備份數據、修改系統配置
假設沒有權限管理,可能會發生什么事情呢?
學生小王誤點了"刪除所有圖書"按鈕
老師張三想查看其他班級的借閱情況被拒絕了
管理員李四無法訪問系統設置,找你求助
......
所以權限管理,是非常必要的!!
現在問題來了:在咱們項目中,如何為他們添加權限?
1、傳統方案
傳統的解決方式是什么?在代碼里寫死:
// 傳統方式:硬編碼權限檢查
func DeleteBook(userType string) {if userType == "student" {return // 學生不能刪除}if userType == "teacher" {return // 老師也不能刪除}if userType == "admin" {// 只有管理員能刪除deleteBook()}
}
每次有新角色加入,你都要修改代碼...
這樣寫有什么問題?
1. 新增一個"圖書管理員"角色,要改遍所有函數
2. 權限規則散落在各處,難以維護
3. 想臨時給某個老師管理員權限?改代碼重新部署!
我在面向對象的七大設計原則一文中提到,接口的設計中的開閉原則中的,閉原則,就是為了解決解決每次有新改動,就要修改原有的代碼。
所以直接把代碼寫死,極其不合理,那該如何解決?
2、如何通過RBAC改進?
什么是RBAC呢?
大家可以想象到這樣一種場景:
公司的門禁卡系統
? ?- 員工卡:只能進辦公區
? ?- 管理卡:能進辦公區+會議室
? ?- 主管卡:能進所有區域
這就是靈感:能不能給用戶分配"權限卡"?
咱們可以這樣設計系統:
用戶(User) ←→ 角色(Role) ←→?權限(Permission)
這里的角色就相當于上方公司的門禁卡。
具體來說:
- 小王?→ 學生角色 → [借書, 還書, 查看個人記錄]
- 張三 → 教師角色 → [借書, 還書, 查看班級記錄, 推薦圖書]
- 李四 → 管理員角色 → [所有學生權限 + 添加圖書 + 用戶管理]
3、如何設計代碼?
第一步:定義角色和權限
// 不再硬編碼,而是用數據庫存儲
1、定義角色
type Role struct {ID uint `json:"id"`Name string `json:"name"` // "學生", "教師", "管理員"Description string `json:"description"` // "普通學生用戶"
}2、定義權限
type Permission struct {ID uint `json:"id"`Name string `json:"name"` // "book:borrow", "user:create"Action string `json:"action"` // "借閱圖書", "創建用戶"
}
第二步:新方式如何檢查權限
// 現在的權限檢查
func DeleteBook(userID uint) error {if !permission.HasPermission(userID, "book:delete") {return errors.New("權限不足:您無法刪除圖書")}return deleteBook()
}新增角色?只需要配置數據,無需改代碼!
// 2. 權限檢查 - 如何工作的?
func (r *RoleService) HasPermission(roleID uint, permission string) bool {// 具體的權限驗證邏輯// 為什么這樣設計?
}
第三步:前后對比
// 傳統方式:硬編碼權限
if userType == "teacher" {// 教師相關操作
} else if userType == "student" {// 學生相關操作
}
// 問題:新增角色需要修改代碼// 你的方案:動態權限
if permission.HasRole(user.RoleID, "teacher") {// 教師相關操作
}
// 優勢:新增角色只需要配置數據
哈哈,這就像及了接口(interface)的設計方式。
讓人感覺賞心悅目。
二、中間件設計
雖然在引入RBAC后,確實能優化代碼,但是又遇到了新問題。
每個API都要手動檢查權限,代碼重復。
如下,這里是調用DeleteBook,需要permission認證
// 現在的權限檢查
func DeleteBook(userID uint) error {if !permission.HasPermission(userID, "book:delete") {return errors.New("權限不足:您無法刪除圖書")}return deleteBook()
}
如果咱們調用其他不同的函數,你都需要在每個函數上添加如下這段代碼:
if !permission.HasPermission(userID, "book:delete") {return errors.New("權限不足:您無法刪除圖書")}
是不是特別麻煩(~ ̄▽ ̄)~
1、理論設計方式
咱們可以設計成如下,通過middleware中間件:
// 展示如何在路由中應用權限中間件
router.POST("/role", middleware.RequirePermission("role:create"), roleHandler.CreateRole)
router.GET("/role", middleware.RequirePermission("role:read"), roleHandler.GetRoles)
2、圖書館項目的設計
// - 1. 路徑跳過檢查 - 檢查當前請求路徑是否在跳過列表中,如果是則直接放行
// - 2. 獲取用戶信息 - 從請求頭 X-Userinfo 中獲取 Base64 編碼的用戶信息
// - 3. 解碼和反序列化 - 將 Base64 字符串解碼后,反序列化為 UserInfo 結構體
// - 4. 租戶ID處理 - 清理租戶ID格式(移除前導斜杠),從請求頭獲取目標租戶ID
// - 5. 權限驗證 - 檢查用戶是否有權限訪問請求的租戶(用戶的租戶列表中是否包含目標租戶)
// - 6. 設置上下文 - 驗證通過后,將用戶信息和租戶ID設置到 Gin 上下文中供后續使用
func Auth() gin.HandlerFunc {return AuthWithConfig(AuthConfig{})
}func AuthWithConfig(config AuthConfig) gin.HandlerFunc {notAuth := config.SkipPathsvar skip map[string]struct{}if len(notAuth) > 0 {skip = make(map[string]struct{})for _, path := range notAuth {skip[path] = struct{}{}}}return func(c *gin.Context) {if _, ok := skip[c.FullPath()]; ok {c.Next()return}userInfos := c.Request.Header.Get("X-Userinfo")if userInfos == "" {err := errs.NewUnauthorizedError("missing user information")response.BuildErrorResponse(err, c)c.Abort()return}userProfile := &userModel.UserInfo{}user, err := base64.StdEncoding.DecodeString(userInfos)if err != nil {logrus.Error("x-userinfo base64 decoding failed", err)err = errs.NewUnauthorizedError("invalid user info encoding")response.BuildErrorResponse(err, c)c.Abort()return}err = json.Unmarshal(user, &userProfile)if err != nil {logrus.Error("x-userinfo json unmarshal failed", err)err = errs.NewUnauthorizedError("invalid user info format")response.BuildErrorResponse(err, c)c.Abort()return}// Remove the leading slash from each tenant IDfor i, tenantId := range userProfile.TenantIds {if len(tenantId) > 0 && tenantId[0] == '/' {userProfile.TenantIds[i] = tenantId[1:]}}// Get tenantId from request headerrequestTenantId := c.GetHeader("tenantId")c.Set("tenantId", requestTenantId)if requestTenantId == "" && len(userProfile.TenantIds) > 0 {requestTenantId = userProfile.TenantIds[0]c.Set("tenantId", requestTenantId)}if requestTenantId == "" {logrus.Error("Missing tenantId in request header")err = errs.NewUnauthorizedError("missing tenantId in request header")response.BuildErrorResponse(err, c)c.Abort()return}// Check if the requested tenantId is in user's tenant listauthorized := falsefor _, tenantId := range userProfile.TenantIds {if tenantId == requestTenantId {authorized = truebreak}}if !authorized {logrus.Warnf("User attempted to access unauthorized tenant: %s", requestTenantId)err = errs.NewUnauthorizedError("unauthorized tenant access")response.BuildErrorResponse(err, c)c.Abort()return}// Authorized, continuec.Set("user", userProfile)c.Next()}
}
咱們在這里詳細解釋一下代碼:
a.跳過重復路徑
// 從配置中獲取,需要跳過的路徑
// 通過map存儲實現O(1)查詢
notAuth := config.SkipPathsvar skip map[string]struct{}if len(notAuth) > 0 {skip = make(map[string]struct{})for _, path := range notAuth {skip[path] = struct{}{}}}
// 跳過
if _, ok := skip[c.FullPath()]; ok {c.Next()return
}
b.獲取JWT憑證
// 1. 獲取網關傳遞的用戶信息 userInfos := c.Request.Header.Get("X-Userinfo")....// 2. Base64解碼
userProfile := &userModel.UserInfo{}
user, err := base64.StdEncoding.DecodeString(userInfos)....// 3. JSON反序列化為用戶對象
err = json.Unmarshal(user, &userProfile)....// 4. 租戶權限驗證....
認證的思路如下:
客戶端 → 網關/認證服務 → 業務服務↓JWT驗證/登錄↓生成用戶信息↓Base64編碼后放入Header↓轉發到后端服務
這里的采用的是第三方驗證身份,并且采用Keycloak解決問題
Keycloak 是一個開源的身份和訪問管理(IAM)解決方案
可以拓展一下(AI):
1.單點登錄(SSO)
- 用戶只需登錄一次,即可訪問多個應用系統
- 支持SAML 2.0、OpenID Connect、OAuth 2.0等標準協議
2.身份認證- 用戶名密碼認證
- 多因素認證(MFA)
- 社交登錄(Google、Facebook、GitHub等)
- LDAP/Active Directory集成
3.授權管理- 基于角色的訪問控制(RBAC)
- 細粒度權限控制
- 資源和策略管理
4.用戶管理- 用戶注冊、密碼重置
- 用戶組織和角色分配
- 用戶會話管理
圖書館項目生成用于驗證的JWT的方式
本項目JWT令牌的生成方式
通過對項目代碼的深入分析,我發現本項目的JWT令牌生成采用了以下架構:JWT令牌生成流程
1. Keycloak作為JWT令牌簽發中心- 項目使用 `keycloak.go` 中的 `GetAdminToken` 方法
- 通過調用 k.client.LoginAdmin() 向Keycloak服務器請求JWT令牌
- 使用配置文件中的管理員賬戶(AdminUser/AdminPass)進行認證
2. JWT令牌的具體生成過程```
token,?err?:=?k.client.LoginAdmin(k.ctx,?global.Config.Keycloak.
AdminUser,?global.Config.Keycloak.AdminPass,?"master")
```
3. 令牌使用場景- 管理操作 :在用戶創建、更新、刪除等管理操作中使用
- 權限驗證 :通過 `auth.go` 中間件驗證用戶身份
- API調用 :所有需要認證的API都通過JWT令牌進行權限控制
JWT是在創建角色的時候生成的,有興趣的可以了解一下:
// CreateUser 在 Keycloak 中創建新用戶
// 實現了完整的用戶創建流程,包括權限分配和事務回滾
func (k *KeycloakService) CreateUser(req *UserCreateRequest) (string, error) {// 步驟1: 獲取 Keycloak 管理員訪問令牌token, err := k.GetAdminToken()if err != nil {logrus.Error(err)return "", err}// 步驟2: 檢查用戶名(身份證號)是否已存在exists, err := k.CheckUsernameExists(req.IdNumber)if err != nil {logrus.Error(err)return "", err}if exists {logrus.Errorf("User with idNumber %s already exists", req.IdNumber)return "", errors.NewResourceAlreadyExistError("身份證重復!")}// 步驟3: 設置默認密碼(如果未提供)if len(req.Password) == 0 {req.Password = "Aa123456" // 建議:提取為配置項}// 步驟4: 構建 Keycloak 用戶對象keycloakUser := gocloak.User{Username: gocloak.StringP(req.IdNumber), // 使用身份證作為用戶名Enabled: gocloak.BoolP(true), // 啟用用戶LastName: gocloak.StringP(req.Name), // 設置姓名Credentials: &[]gocloak.CredentialRepresentation{{Type: gocloak.StringP("password"),Value: gocloak.StringP(req.Password),Temporary: gocloak.BoolP(false), // 非臨時密碼},},}// 步驟5: 在 Keycloak 中創建用戶userID, err := k.client.CreateUser(k.ctx, token.AccessToken, k.realm, keycloakUser)if err != nil {logrus.Errorf("Failed to create user %s in realm %s: %v", req.Name, k.realm, err)return "", err}// 步驟6: 添加用戶到指定組(帶事務回滾)err = k.AddUserToGroup(userID, req.GroupName)if err != nil {logrus.Errorf("Failed to add user %s to group %s: %v", userID, req.GroupName, err)// 回滾:刪除已創建的用戶if rollbackErr := k.DeleteUser(userID); rollbackErr != nil {logrus.Errorf("Rollback failed: %v", rollbackErr)}return "", err}// 步驟7: 為用戶分配角色(帶事務回滾)err = k.AddRoleToUser(userID, req.Role)if err != nil {logrus.Errorf("Failed to add role %s to user %s: %v", req.Role, userID, err)// 回滾:刪除已創建的用戶if rollbackErr := k.DeleteUser(userID); rollbackErr != nil {logrus.Errorf("Rollback failed: %v", rollbackErr)}return "", err}return userID, nil
}
三、基于圖書館的權限樹設計
1、權限樹設計
// Permission 權限結構體
type Permission struct {Key string `json:"key"`Title string `json:"title"`Children []Permission `json:"children,omitempty"`
}// DefaultPermissions 默認權限樹結構
var DefaultPermissions = []Permission{{Key: "home",Title: "首頁",},{Key: "bookshelf",Title: "個人書架",},{Key: "borrow-history",Title: "借閱記錄",},{Key: "activity-center",Title: "活動中心",},{Key: "message-center",Title: "消息中心",},{Key: "system-manage",Title: "系統管理",Children: []Permission{{Key: "book-manage",Title: "圖書管理",Children: []Permission{{Key: "book-entry", Title: "圖書錄入"},{Key: "book-list", Title: "圖書列表"},{Key: "book-recommend", Title: "圖書推薦"},{Key: "book-check", Title: "圖書清查"},},},{Key: "borrow-manage",Title: "借閱管理",Children: []Permission{{Key: "book-borrow", Title: "圖書借閱"},{Key: "book-return", Title: "圖書歸還"},{Key: "flow-approve", Title: "漂流審批"},{Key: "reserve-list", Title: "候補列表"},{Key: "borrow-record", Title: "借閱記錄"},},},{Key: "activity-manage",Title: "活動管理",Children: []Permission{{Key: "activity-create", Title: "活動創建"},{Key: "activity-approve", Title: "活動審批"},{Key: "activity-list", Title: "活動列表"},},},{Key: "notice-manage",Title: "通知管理",Children: []Permission{{Key: "notice-create", Title: "通知創建"},{Key: "notice-list", Title: "通知列表"},},},{Key: "system-setting",Title: "系統設置",Children: []Permission{{Key: "user-manage", Title: "讀者管理"},{Key: "role-manage", Title: "角色配置"},{Key: "system-configure", Title: "系統配置"},{Key: "grade-configure", Title: "年級配置"},{Key: "venue-configure", Title: "館場地配置"},{Key: "activity-configure", Title: "活動配置"},},},},},
}
2、權限層級映射:
大白話來說就是能快速找到子節點與父節點之間的關系
// BuildPermissionParentMap 從DefaultPermissions構建權限層級關系映射
func BuildPermissionParentMap() map[string]string {parentMap := make(map[string]string)buildParentMapRecursive(DefaultPermissions, "", parentMap)return parentMap
}// buildParentMapRecursive 遞歸構建權限父子關系映射
// 能夠快速找到子權限的父權限
func buildParentMapRecursive(permissions []Permission, parentKey string, parentMap map[string]string) {for _, perm := range permissions {if parentKey != "" {parentMap[perm.Key] = parentKey}if len(perm.Children) > 0 {buildParentMapRecursive(perm.Children, perm.Key, parentMap)}}
}
四、圖書館項目
1、整體架構:
用戶(User) → 角色(Role) → 權限(Permission) → 資源(Resource)↓ ↓ ↓ ↓身份認證 角色分配 權限控制 資源訪問
2、核心組件解析
a、 用戶信息結構 (UserInfo)
type UserInfo struct {Name string // 用戶姓名Username string // 用戶名AccountId string // 賬戶IDRoles []string // 用戶角色列表TenantIds []string // 租戶ID列表(多租戶支持)// ... 其他字段
}
b、角色模型 (Role)
type Role struct {Name string // 角色名稱Description string // 角色描述BorrowLimit int // 借閱數量限制BorrowDays int // 借閱天數限制TenantId string // 租戶IDPermissions string // 權限配置JSONStatus enum.Status // 狀態
}
c、權限樹結構 (Permission)
type Permission struct {Key string // 權限標識Title string // 權限名稱Children []Permission // 子權限
}
3、權限設計層級
a、三級權限結構:
1.?一級權限 :模塊級別(如:系統管理)
2.二級權限 :功能級別(如:圖書管理)
3.三級權限 :操作級別(如:圖書錄入、圖書列表)
系統管理 (system-manage)
├── 圖書管理 (book-manage)
│ ├── 圖書錄入 (book-entry)
│ ├── 圖書列表 (book-list)
│ └── 圖書推薦 (book-recommend)
├── 借閱管理 (borrow-manage)
│ ├── 圖書借閱 (book-borrow)
│ └── 圖書歸還 (book-return)
└── 系統設置 (system-setting)├── 讀者管理 (user-manage)└── 角色配置 (role-manage)
五、前端如何進行權限控制
現實場景:
假設:
一個普通讀者(角色:student)
他的權限只有 ["home", "personal-bookshelf", "borrow-record", "activity-center", "message-center"] ,想要訪問"讀者管理"頁面。
完整權限控制
用戶登錄↓
后端返回用戶權限列表: ["home", "personal-bookshelf", "borrow-record", "activity-center", "message-center"]↓
前端存儲權限到 Pinia Store↓
菜單渲染時過濾權限↓
系統管理菜單不顯示(因為沒有任何系統管理權限)↓
用戶無法通過正常途徑訪問讀者管理頁面↓
即使通過直接URL訪問,組件內部也會進行權限檢查↓
最終被拒絕訪問或跳轉到403頁面
首先從后端獲取權限數據
當用戶登錄后,前端會調用 `user.ts` 中的 fetchAndSetStaffInfo() 方法:
async fetchAndSetStaffInfo() {try {this.isLoading = true;const response = await getCurrentStaff(); // 調用后端API獲取用戶信息if (response && (response as any).data && (response as any).code === 0) {const staffData = (response as any).data;// 設置用戶權限this.permissions = staffData.permissions || []; // 普通讀者只有基礎權限}} catch (error) {console.error('獲取用戶信息失敗:', error);}
}
結果:
普通讀者的 permissions 數組為: ["home", "personal-bookshelf", "borrow-record", "activity-center", "message-center"] , 不包含 "user-manage" 權限。
第一層防護:菜單不顯示
在 `index.vue` 中,菜單會根據權限進行過濾:
const filterRoute = (routeList: TRouter[], currentPermissions: string[]) => {// 檢查用戶是否有系統管理權限const hasSystemManagePermission = systemManagePermissions.some((permission) =>currentPermissions.includes(permission),);for (let i = routeList.length - 1; i >= 0; i--) {const route = routeList[i];const routeName = route.name as string;// 特殊處理系統管理菜單if (routeName === 'SystemManage') {if (!hasSystemManagePermission) {routeList.splice(i, 1); // 移除系統管理菜單}}}
};
結果:
由于普通讀者沒有任何系統管理相關權限(如 user-manage 、 role-manage 等),整個"系統管理"菜單都不會顯示在導航欄中。
第二層防護:系統管理子菜單過濾
即使用戶通過某種方式進入了系統管理頁面,在 `layout.vue` 中還有二級權限過濾:
// 菜單權限映射
const menuPermissionMap = {'user-manage': 'user-manage','role-manage': 'role-manage',// ... 其他權限映射
};// 根據權限過濾菜單組
const filteredMenuGroups = computed(() => {return menuGroups.map((group) => ({...group,items: group.items.filter((item) => {const requiredPermission = menuPermissionMap[item.key];return !requiredPermission || permissions.value.includes(requiredPermission);}),})).filter((group) => group.items.length > 0); // 過濾掉沒有可用菜單項的組
});
結果:
"讀者管理" 菜單項不會出現在系統管理的側邊欄中。
第三層防護:權限指令控制
在具體的頁面組件中,還可以使用權限指令 `permission.ts` 來控制元素顯示:
<!-- 在任何組件中使用權限指令 -->
<a-button v-permission="'user-manage'" type="primary">讀者管理
</a-button>
權限指令的實現:
const permission: Directive = {mounted(el: HTMLElement, binding) {const { value } = binding;const user = useUserStore();const { permissions } = user;if (value) {let hasPermission = false;if (typeof value === 'string') {hasPermission = permissions.includes(value); // 檢查是否有該權限}if (!hasPermission) {el.style.display = 'none'; // 沒有權限則隱藏元素}}},
};
結果: 任何帶有 v-permission="'user-manage'" 指令的元素都會被隱藏。
第四層防護:組合式函數權限檢查
在組件邏輯中,可以使用 `usePermission.ts` 進行權限檢查:
export function usePermission() {const user = useUserStore();// 檢查是否有指定權限const hasPermission = (permission: string): boolean => {return user.hasPermission(permission);};return {hasPermission,// ... 其他權限檢查方法};
}
在組件中使用:
<script setup>
import { usePermission } from '@/hooks/usePermission';const { hasPermission } = usePermission();// 檢查權限
if (!hasPermission('user-manage')) {// 沒有權限,執行相應邏輯router.push('/403'); // 跳轉到無權限頁面
}
</script>
第五層防護:直接URL訪問
如果用戶直接在瀏覽器地址欄輸入 /systemManage/user-manage :
1、路由存在 :路由配置中確實有這個路徑
2、組件加載 :UserManage 組件會被加載
3、權限檢查 :組件內部會進行權限檢查
4、訪問被拒絕 :如果沒有權限,會顯示無權限提示或跳轉到403頁面
收獲:
在學習權限控制的時候,由于我需要專門設計一套簡單的權限控制,我專門找來我們的前端。
想要深入了解一下,我后端傳遞數據到前端后,前端進行的權限控制流程。
瀏覽器上的頁面是靜態頁面,當點擊發送url時,會被前端攔截(Vue Router)的工作原理;
然后經過代碼書寫的一系列操作之后,在傳遞到后端,
后端返回的具體數據,是先返回到前端,
經前端處理,才最終到顯示的頁面。
網站:
1、活動廣場 - 河南師范大學附屬中學圖書館