后台权限不是隐藏按钮:用 ShiyuAdmin 看懂 JWT + RBAC + Redis 权限缓存

很多刚开始写后台管理系统的人,对“权限”的理解是这样的:
管理员 -> 显示删除按钮
普通用户 -> 隐藏删除按钮
于是前端写一段:
{isAdmin && (
<Button danger>
删除用户
</Button>
)}
看起来没问题。
但这里有一个非常危险的误区:
前端不显示按钮,不代表用户没有权限。
用户完全可以绕过你的 React 页面,直接使用 Postman、curl,甚至自己写 Python 请求后端接口。
比如:
curl -X DELETE \
http://localhost:18000/api/v1/system/users/1001
如果后端没有真正做权限校验,那么所谓的“权限系统”基本等于没有。
最近在看我自己的开源后台项目 ShiyuAdmin:
https://github.com/Rodert/ShiyuAdmin
它里面其实就有一套比较完整的权限链路:
登录
↓
JWT 身份认证
↓
解析当前用户
↓
用户关联角色
↓
角色关联菜单/权限
↓
接口权限判断
↓
Redis 权限缓存
↓
真正执行接口
ShiyuAdmin 当前包含 JWT 登录认证、RBAC、动态路由以及超级管理员权限,并且后台本身还有用户、角色、菜单和部门等管理能力。
今天不介绍整个项目。
我们就从一个问题开始:
一个真正的后台权限系统,到底应该怎么设计?
一、认证和授权不是一回事
这是权限系统里最容易被混淆的两个概念。
Authentication
认证解决的是:
你是谁?
比如:
王仕宇登录系统
↓
输入用户名密码
↓
服务器验证成功
↓
签发 JWT
之后访问接口:
Authorization: Bearer xxxxxxx
服务器解析 Token,确认:
这个请求来自用户 10001
这叫认证。
Authorization
授权解决的是:
你能干什么?
比如同样登录成功:
张三:普通员工
李四:部门经理
王五:系统管理员
三个人都是合法用户。
但是权限完全不同:
张三
├── 查看用户
└── 修改自己的资料
李四
├── 查看用户
├── 查看部门
└── 查看本部门数据
王五
├── 创建用户
├── 删除用户
├── 创建角色
├── 修改权限
└── 系统配置
所以一个完整后台至少存在两层:
第一层:你有没有登录?
第二层:你有没有权限?
二、第一道门:JWT 认证
ShiyuAdmin 在 Gin 中间件里处理 JWT。
项目中的认证中间件会读取:
Authorization: Bearer TOKEN
然后大致执行:
func Auth(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.Abort()
return
}
// 解析 Bearer Token
claims, err := ParseToken(secret, token)
if err != nil {
c.Abort()
return
}
c.Set("currentUser", claims)
c.Next()
}
}
实际 ShiyuAdmin 的实现还多做了一件很重要的事情:除了验证 JWT,还支持检查当前 Session 是否已经被撤销,并把解析后的用户 Claims 和 Session ID 放进 Gin Context,提供给后面的权限中间件使用。
为什么一定要放到 Context?
因为后面的所有接口都需要知道:
当前是谁在请求?
这样 Controller 就不用重复解析 Token。
后续直接:
claims, exists := c.Get("currentUser")
即可。
这实际上是一种很经典的中间件设计:
Request
↓
JWT Middleware
↓
Context 写入 User
↓
Permission Middleware
↓
Controller
↓
Service
三、JWT 能证明你是谁,但不能证明你能做什么
很多项目写到这里就结束了。
只要:
JWT 正确
就允许调用接口。
这是明显不够的。
假设系统有接口:
GET /api/v1/system/users
用于查看用户。
还有:
DELETE /api/v1/system/users/1001
用于删除用户。
一个普通员工登录之后,同样拥有合法 JWT。
如果只验证 JWT,那么他理论上也可以直接请求:
DELETE /api/v1/system/users/1001
所以还需要第二层:
Permission Middleware
也就是:
权限中间件
四、RBAC 到底是什么?
ShiyuAdmin 采用的是比较常见的 RBAC 思路。
RBAC 全称:
Role-Based Access Control
中文:
基于角色的访问控制
如果系统直接做:
用户 -> 权限
会很麻烦。
假设公司有:
10000 个员工
每个人都分别配置:
用户查看
用户新增
用户修改
订单查看
订单修改
财务查看
权限关系很快就会爆炸。
所以增加一个中间层:
User
↓
Role
↓
Permission
例如:
张三
↓
运营人员
↓
order:list
order:update
product:list
李四:
李四
↓
管理员
↓
user:list
user:create
user:update
user:delete
权限管理瞬间简单很多。
五、ShiyuAdmin 是怎么把权限串起来的?
它的核心关系可以理解成:
User
↓
UserRole
↓
Role
↓
RoleMenu
↓
Menu
↓
Perms
为什么 Menu 里面还要有 Perms?
因为一个后台菜单,例如:
用户管理
并不只有一个权限。
它下面可能有:
system:user:list
system:user:create
system:user:update
system:user:delete
所以菜单和按钮只是权限的表现形式。
真正控制后端接口的应该是:
Permission Code
六、获取一个用户全部权限
ShiyuAdmin 的权限 Service 思路非常清晰。
第一步:
userCode
↓
查询用户角色
第二步:
roleCode
↓
查询角色关联菜单
第三步:
读取:
menu.Perms
最后去重,得到:
[]string{
"system:user:list",
"system:user:create",
"system:user:update",
}
项目当前的 Permission Service 确实采用了“用户角色 → 角色菜单 → Menu.Perms”的查询方式,同时过滤禁用角色、禁用菜单和空权限标识。
最终可以写一个非常简单的判断:
func CheckPermission(
userPermissions []string,
required string,
) bool {
for _, permission := range userPermissions {
if permission == required {
return true
}
}
return false
}
于是权限模型变成:
用户请求接口
↓
拿到 userCode
↓
查用户拥有的 Role
↓
查 Role 拥有的 Permission
↓
是否包含当前接口所需 Permission
↓
YES → 放行
NO → 403
七、真正拦接口的是 Permission Middleware
这一点非常重要。
权限不能只判断:
菜单显示不显示
而是必须拦:
API
例如:
router.DELETE(
"/system/users/:id",
Auth(jwtSecret),
RequirePermission(
permissionService,
"system:user:delete",
),
userHandler.Delete,
)
整个请求流程变成:
DELETE /system/users/1001
↓
Auth
↓
JWT 是否合法?
↓
RequirePermission
↓
有没有 system:user:delete?
↓
有
│
↓
Delete Handler
没有
│
↓
403 Forbidden
ShiyuAdmin 当前的权限中间件除了单权限判断,还分别提供了“任意一个权限满足”和“全部权限满足”的判断,并对超级管理员直接放行。
这三个方法非常实用。
八、单权限、ANY 和 ALL 权限有什么区别?
比如:
RequirePermission(
permissionService,
"system:user:delete",
)
意思是:
必须拥有 system:user:delete
有时候业务要求:
A 或者 B
例如:
RequireAnyPermission(
permissionService,
[]string{
"order:update",
"order:admin",
},
)
只要满足其中一个即可:
order:update
OR
order:admin
还有一种:
RequireAllPermissions(
permissionService,
[]string{
"finance:view",
"finance:export",
},
)
意味着:
finance:view
AND
finance:export
全部拥有才允许通过。
这样权限系统的表达能力会强很多。
九、超级管理员为什么要单独设计?
很多后台都会有:
super_admin
ShiyuAdmin 同样如此。
原因很简单。
如果超级管理员也必须一条条查询:
system:user:list
system:user:add
system:user:update
system:user:delete
system:role:list
system:role:add
...
系统新增一个权限之后,还得给超级管理员再绑定一次。
非常麻烦。
所以更加常见的做法是:
if claims.IsSuperAdmin {
c.Next()
return
}
也就是:
超级管理员
↓
直接通过权限检查
这也是权限系统里的一个特殊分支。
十、但是这里马上又出现一个性能问题
假设一个页面会请求:
用户接口
部门接口
菜单接口
系统信息接口
统计接口
日志接口
每个请求都需要:
查用户角色
↓
查角色菜单
↓
查询权限
如果一个页面几十个请求:
几十次权限查询
用户数量上来以后:
数据库压力会迅速增加
所以这里就出现一个非常适合 Redis 的场景:
权限缓存。
十一、用 Redis 缓存用户权限
ShiyuAdmin 里面已经实现了一个 CachedPermissionService。
它使用类似这样的 Key:
user:perms:v1:USER_CODE
例如:
user:perms:v1:10001
Value 可以直接存:
[
"system:user:list",
"system:user:create",
"system:user:update"
]
读取流程:
请求到达
↓
Redis 有没有权限缓存?
├── 有
│
└── 直接返回
│
└── 没有
↓
查询数据库
↓
得到权限
↓
写 Redis
↓
返回
ShiyuAdmin 当前就是类似实现:优先读取 Redis,缓存未命中时调用基础 Permission Service,再把结果序列化写入 Redis,并通过 TTL 控制缓存生命周期。
于是原本:
API
↓
MySQL/PostgreSQL
↓
UserRole
↓
RoleMenu
↓
Menu
变成:
API
↓
Redis
↓
Permission List
性能差距就出来了。
十二、权限缓存最难的其实不是“缓存”,而是“失效”
这是很多程序员第一次写 RBAC 时容易踩的坑。
假设:
张三
原来拥有:
system:user:delete
Redis 已缓存:
[
"system:user:list",
"system:user:delete"
]
现在管理员把张三的删除权限取消了。
数据库已经变成:
[
"system:user:list"
]
但是 Redis 还是:
[
"system:user:list",
"system:user:delete"
]
那会发生什么?
张三还能继续:
删除用户
直到 Redis 缓存过期。
所以:
权限系统使用缓存以后,真正困难的问题变成了缓存一致性。
十三、修改权限时必须主动删除缓存
比如:
修改用户角色
之后:
InvalidateUserCache(ctx, userCode)
删除:
user:perms:v1:10001
下一次访问:
Redis Miss
↓
重新查询数据库
↓
生成最新权限
↓
重新缓存
这样才是正确流程。
ShiyuAdmin 当前已经提供用户级权限缓存失效方法;而对于“角色权限发生改变,需要清除该角色下所有用户缓存”这一场景,代码也明确注明目前是简化实现,生产环境可以维护 role → users 映射,或通过 SCAN 等方式进一步完善。
这一点其实特别值得学习。
因为真正的工程问题不是:
Redis 怎么 Set?
而是:
什么时候删?
删谁?
怎么保证删干净?
十四、一个更完整的 RBAC 可以这样设计
如果让我设计一套通用后台权限模型,大概会包含:
sys_user
用户表。
sys_role
角色表。
sys_user_role
用户角色关系。
sys_menu
菜单和权限定义。
sys_role_menu
角色权限关系。
关系:
User
│
│
UserRole
│
↓
Role
│
│
RoleMenu
│
↓
Menu
│
↓
Perms
例如:
王仕宇
↓
admin
↓
system:user
↓
system:user:delete
十五、前端权限和后端权限应该怎么配合?
这里有一个原则:
前端权限负责用户体验,后端权限负责安全。
前端可以根据权限:
if (
permissions.includes(
"system:user:delete"
)
) {
// 显示删除按钮
}
作用是:
没有权限的人不看到按钮
但是后端仍然必须:
RequirePermission(
permissionService,
"system:user:delete",
)
防止直接请求 API。
所以正确架构应该是:
Permission
/ \
前端 后端
↓ ↓
控制显示 控制访问
↓ ↓
用户体验 安全
千万不要把这两个东西混在一起。
十六、为什么我觉得后台项目非常适合学习 RBAC?
很多人学习 Go:
Gin
Gorm
Redis
JWT
都是分别学。
最后会发现:
每个技术都会一点
但是:
不知道怎么组合
后台管理系统其实特别适合做这种综合练习。
因为你会一次性遇到:
JWT
RBAC
Gin Middleware
Gorm
Redis
数据库设计
接口设计
缓存一致性
动态菜单
操作日志
Docker
这些东西单独看都不复杂。
难的是:
它们在一个真实项目里面应该怎么连接起来。
ShiyuAdmin 本身就是按照这种思路做的,目前后端采用 Go、Gin、Gorm、Viper、JWT,前端使用 React、Umi Max、Ant Design Pro,并提供 PostgreSQL、MySQL、SQLite 等数据库部署方式。
项目地址:
https://github.com/Rodert/ShiyuAdmin
我是王仕宇 JavaPub。
如果你正在学习 Go 后端,建议不要只是不断写:
Todo List
可以尝试真正做一次:
用户
+
角色
+
菜单
+
权限
+
JWT
+
Redis
当你把这一整条链路真正跑通之后,对后台权限系统的理解会完全不一样。
因为真正的权限系统从来不是:
“这个按钮给不给用户看?”
而是:
这个用户是谁?
他拥有哪些角色?
角色拥有哪些权限?
当前接口需要什么权限?
数据能看到什么范围?
权限修改以后缓存什么时候失效?
最终是谁在后端阻止了非法请求?
当你开始考虑这些问题时,
你写的就已经不再是一个“后台页面”。
而是一套真正的:

浙公网安备 33010602011771号