后台权限不是隐藏按钮:用 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

当你把这一整条链路真正跑通之后,对后台权限系统的理解会完全不一样。

因为真正的权限系统从来不是:

“这个按钮给不给用户看?”

而是:

这个用户是谁?

他拥有哪些角色?

角色拥有哪些权限?

当前接口需要什么权限?

数据能看到什么范围?

权限修改以后缓存什么时候失效?

最终是谁在后端阻止了非法请求?

当你开始考虑这些问题时,

你写的就已经不再是一个“后台页面”。

而是一套真正的:

权限系统。

posted @ 2026-09-16 11:21  JavaPub  阅读(7)  评论(0)    收藏  举报