最近跟着学长再写河南师范大学附属中学图书馆的项目,学长交给了我一个任务,把本项目的权限管理给吃透,然后应用到下一个项目上。
我当然是偷着乐呐,因为读代码的时候,总是莫名给我一种公费旅游的感觉。
本来就想去了解图书管理这个项目的全貌。但一直腾不出时间。
现在正巧,我要写一个权限管理,正好可以拐回来细细品读图书管理系统的代码( ̄﹃ ̄)。
熟悉的配方,本项目使用的是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、活动广场 - 河南师范大学附属中学图书馆