ASP.NET Core 用户认证授权设计:功能权限与数据权限
后台系统中的认证与授权解决的是两个不同问题:
- 认证用于确认当前请求来自哪个用户。
- 授权用于判断这个用户可以执行哪些操作、访问哪些数据。
在 IAM 平台中,授权又被分为功能权限和数据权限。功能权限控制菜单、按钮和接口,数据权限控制用户能够看到或处理的数据范围。
🔐 用户认证
用户登录时,系统先根据用户名查询账号,使用 BCrypt 校验密码,并检查账号是否处于启用状态。验证通过后生成 JWT,Token 中只保存用户身份,不直接保存角色和权限列表。
用户名和密码
↓
校验账号、密码与用户状态
↓
生成 JWT
↓
在 Redis 中保存当前有效 Token
↓
返回 Token 和过期时间
后续请求携带 Bearer Token。认证中间件负责验证签名、签发方和有效期,解析出用户 ID,并把当前用户保存到请求上下文中。
系统还会将请求中的 Token 与 Redis 中保存的当前 Token 比较。这样虽然不再是完全无状态的 JWT,但可以支持主动退出和 Token 失效:
- 用户退出后删除 Redis 中的 Token,旧 Token 立即失效。
- 同一用户产生新 Token 后,之前的 Token 无法继续使用。
- 权限不需要写入 JWT,角色发生变化时不用等待 Token 过期。
登录等公开接口可以跳过认证;获取当前用户信息、修改本人密码等接口只要求登录,不再检查具体功能权限。
🧭 授权模型
系统以 RBAC 为基础:用户关联角色,角色关联资源。
用户 ── 用户角色 ── 角色 ── 角色资源 ── 资源
│ ▲
└──────────── 用户直接资源 ─────────────┘
大部分权限通过角色统一分配。对于临时或特殊授权,也可以直接给用户分配资源,不必为少量例外创建新的角色。
用户的最终权限是启用角色权限与用户直接权限的并集:
用户有效权限 = 启用角色拥有的权限 ∪ 用户直接拥有的权限
超级管理员只进行身份和登录状态检查,随后跳过普通资源授权。
🎛️ 功能权限
功能权限回答的是“用户能不能执行某个操作”。系统使用统一的资源模型表示:
- 系统模块。
- 目录和菜单。
- 页面按钮或自定义功能点。
- IAM 平台自身的 API。
- 外部系统的 API。
每个资源都有稳定的资源编码,同时保存资源类型、访问路径和启用状态。角色或用户实际获得的是资源,而不是直接绑定 Controller 或页面组件。
前端获取当前用户信息时,系统根据用户拥有的资源生成模块列表、菜单路由和功能权限码。后端收到业务请求时,再根据同一套资源权限进行校验。
一次请求的检查顺序如下:
验证 JWT
↓
确认 Token 仍然有效
↓
读取当前用户
↓
判断接口是否需要功能授权
↓
检查用户是否拥有对应资源
↓
检查资源是否启用并与当前接口匹配
↓
进入业务逻辑
这样,前端隐藏按钮只是用户体验控制,真正的安全边界仍然位于后端。即使客户端手动构造请求,没有对应功能权限也无法进入业务接口。
🗃️ 数据权限
功能权限判断用户能否调用某个功能,数据权限则表示用户在其他业务系统中被分配了哪些具体数据,例如频道、栏目或其他业务对象。
系统没有实现按部门、本人或组织层级自动过滤的数据范围,而是支持两种数据权限来源:
- 通过 HTTP 调用其他服务提供的接口,定期同步业务数据。
- 由管理员直接配置 JSON 格式的数据权限。
从其他服务同步
远程数据权限会配置所属外部服务和查询地址。后台运行着专门的定时服务,每 30 秒扫描远程类型的数据权限,通过 HTTP 调用对应业务服务。
外部接口返回固定结构的响应,其中 data 是对象数组。数组中的每个对象至少需要包含:
guid:数据的稳定唯一标识。name:分配界面和返回结果中显示的名称。
对象还可以携带业务需要的其他字段,IAM 平台会保留完整对象。远程查询成功后,以数据权限定义 ID 组成 Redis Key,再以对象的 guid 作为 Hash Field,将完整 JSON 对象缓存到 Redis。
后台定时服务
↓ HTTP
其他业务服务
↓ 固定格式的对象数组
校验 guid
↓
按 guid 缓存完整对象到 Redis
这种方式适合频道等由其他业务系统维护、内容会动态变化的数据。IAM 平台不复制它们的业务表,只缓存用于授权的数据快照。
自定义 JSON
对于不需要从其他服务同步的数据,管理员可以直接配置 JSON 对象数组。数据格式与远程方式保持一致,每个对象同样需要包含 guid 和 name,完整数组存储在数据权限定义的 PermContent 字段中。
[
{ "guid": "channel-news", "name": "新闻频道" },
{ "guid": "channel-sports", "name": "体育频道" }
]
远程方式和自定义方式只是在数据来源与存储位置上不同:
| 类型 | 候选数据来源 | 候选数据存储位置 |
|---|---|---|
| 远程同步 | 其他服务的 HTTP 接口 | Redis Hash |
| 自定义配置 | 管理员填写的 JSON | 数据库 PermContent |
两种方式最终都转换成相同的对象数组,因此分配界面和用户查询逻辑不需要关心数据来自哪里。
权限分配
进入权限分配页面时,系统会读取所有数据权限定义:远程类型从 Redis 取得候选对象,自定义类型从数据库解析 JSON,然后把两种来源组合成统一的权限树。
管理员选择具体对象后,系统不会把完整 JSON 再复制一份,而是保存:
数据权限定义 ID + 对象 guid
分配结果仍然持久化到数据库,并且支持角色分配和用户直接分配:
角色 ── 角色数据权限 ── 数据权限
用户 ── 用户数据权限 ── 数据权限
查询用户的数据权限
查询某个用户的信息时,系统先从数据库合并角色数据权限和用户直接数据权限,得到用户已分配的“定义 ID + guid”。随后根据数据权限类型还原完整对象:
- 远程类型从 Redis 中读取对应
guid的对象。 - 自定义类型从数据库中的 JSON 数组查找对应
guid的对象。
最后按照数据权限的名称和编码进行分组,把匹配到的完整对象数组返回。这样远程数据更新后,用户下次查询可以获得 Redis 中的最新对象内容,而数据库中的分配关系仍然保持稳定。
⚡ 权限缓存与生效
功能权限和远程数据权限采用不同的缓存策略。
功能权限采用预热与懒加载结合的方式:
- 登录成功后预先加载用户的 API 和自定义功能权限。
- 请求到达时,如果缓存不存在,再从数据库计算。
- 用户、角色或资源权限变化后删除相关缓存。
- 下一次请求重新计算最新权限。
权限变更时不尝试逐项修改缓存,而是直接删除受影响用户的权限 Key。这种方式实现简单,也能减少增量更新遗漏造成的脏数据。
资源路径和启用状态还会缓存在每个应用实例的内存中。资源发生变化后,通过 Redis Pub/Sub 通知所有实例重新加载,避免多实例之间使用不同版本的资源信息。
远程数据权限则由后台服务周期性刷新到 Redis。自定义数据权限和最终分配关系始终保存在数据库中,不依赖 Redis 持久化。
🔄 完整请求链路
用户登录、功能授权和数据权限查询的关系如下:
HTTP 请求
↓
JWT 身份认证
↓
Redis 登录状态检查
↓
功能权限检查
↓
Controller / Service
↓
查询数据库中的用户分配关系
↓
远程类型读取 Redis / 自定义类型读取数据库
↓
组合并返回完整数据权限对象
认证失败时直接拒绝请求,功能权限不足时不能进入业务逻辑。查询用户信息时,系统再按照已保存的定义 ID 和 guid,从对应来源还原完整的数据权限对象。
🗣️ 面试时怎么介绍
可以这样概括这套设计:
系统使用 RBAC 管理用户、角色和资源,同时支持用户直接授权。认证使用 JWT,Redis 保存当前有效 Token 和功能权限。授权分为功能权限和数据权限:功能权限统一控制菜单、按钮和 API;数据权限支持从其他服务的 HTTP 接口定时同步,也支持直接配置 JSON。两种数据源都使用包含
guid、name的固定对象数组,分配时只把数据权限定义 ID 和对象 guid 存入数据库。查询用户时,再分别从 Redis 或数据库还原完整对象并组合返回。
这套设计的重点是把三个边界分开:
- JWT 负责证明用户身份。
- 功能权限负责控制用户能做什么。
- 数据权限负责管理用户被分配的具体业务数据对象。
三者职责明确后,权限模型更容易扩展,也更容易在面试中说明设计原因和请求执行过程。