已发布的《权限控制——后台系统的核心命脉》讲的是 RBAC 的概念和用法。这篇往上走一层,讲 2.3 源码里权限到底是怎么判的:一条路由为什么会被拦、一个按钮为什么时有时无、服务端在哪些地方又校验了一遍。
一、权限模型:只有三层,但每层都要用对
EasyAdminBlazor 的权限模型很朴素:
SysUser ──(SysRoleUser)──> SysRole ──(SysRoleMenu)──> SysMenu
├── Type = 菜单 → 能进这个页面吗
└── Type = 按钮 → 能点这个操作吗
对应的实体:
| 实体 | 作用 | 关键字段 |
|---|---|---|
SysUser |
用户 | OrgId(数据权限用)、Roles(多对多) |
SysRole |
角色 | IsAdministrator、DataPermission、CustomDataPermission |
SysMenu |
菜单 / 按钮 / 外链 / 增删改查 | Path、PathLower、ParentId、Type |
SysRoleUser |
用户-角色关联 | UserId、RoleId |
SysRoleMenu |
角色-菜单关联 | RoleId、MenuId |
菜单类型决定了它在授权里扮演什么角色:
public enum SysMenuType
{
[Display(Name = "菜单")] Menu,
[Display(Name = "按钮")] Button,
[Display(Name = "外部链接")] ExternalLink,
[Display(Name = "增删改查")] Crud
}
这里有个经常被忽略的设计:按钮也是菜单。它和页面共用一张表、一套关联关系,只是 Type 不同、Path 是 add / edit / remove 这样的动作码。这样"角色能看哪些页、能点哪些按钮"就是一次勾选能解决的事,不需要第二套权限表。
框架初始化时会写入标准的增删改查按钮:
// EasyAdminBlazor/SeedData.cs
List<SysMenu> getCudButtons(params SysMenu[] btns)
{
var list = new List<SysMenu>
{
new SysMenu { Label = "添加", Path = "add", Sort = 10011, Type = SysMenuType.Button, IsSystem = true },
new SysMenu { Label = "编辑", Path = "edit", Sort = 10012, Type = SysMenuType.Button, IsSystem = true },
new SysMenu { Label = "删除", Path = "remove", Sort = 10013, Type = SysMenuType.Button, IsSystem = true }
};
...
}
也就是说,"按钮权限码"在框架里是约定好的字符串:add / edit / remove。自定义动作可以自己加,比如角色页的 alloc_menus。
二、登录后,权限是怎么加载的
1. InitRoles:从数据库到内存
AdminContext.InitRoles() 是权限的加载入口:
public async Task InitRoles()
{
if (User == null) return;
var version = await GetPermissionVersionAsync();
Roles = await cache.GetOrCreateAsync<List<SysRole>>(ScopedUserRolesKey(version), async () =>
{
return await Orm.Select<SysRole>()
.Where(a => Orm.Select<SysRoleUser>().Any(b => b.UserId == User.Id && b.RoleId == a.Id))
.ToListAsync();
}, TimeSpan.FromMinutes(30)) ?? [];
RoleMenus = await cache.GetOrCreateAsync<List<SysMenu>>(ScopedUserRoleMenusKey(version), async () =>
{
if (Roles.Any(a => a.IsAdministrator))
{
return await Orm.Select<SysMenu>().ToListAsync();
}
var roleIds = Roles.Select(a => a.Id).ToList();
var roleMenus = await Orm.Select<SysMenu>()
.Where(a => Orm.Select<SysRoleMenu>().Any(b => roleIds.Contains(b.RoleId) && b.MenuId == a.Id))
.ToListAsync();
var parentIds = roleMenus.Select(a => a.ParentId).Distinct().ToList();
if (parentIds.Count > 0)
{
var parentMenus = await Orm.Select<SysMenu>()
.Where(a => parentIds.Contains(a.Id))
.AsTreeCte(up: true)
.ToListAsync();
roleMenus.AddRange(parentMenus);
}
return roleMenus.DistinctBy(a => a.Id).ToList();
}, TimeSpan.FromMinutes(30)) ?? [];
}
三个细节值得注意:
- 管理员走的是"全部菜单",不是"勾选的菜单"。只要角色上
IsAdministrator = true,RoleMenus就是整张菜单表。 - 父菜单会自动补全。如果角色只勾了子菜单,
AsTreeCte(up: true)会把祖先链补上——否则导航树渲染不出来,面包屑也会缺层级。 - 结果进缓存 30 分钟。这就是为什么"改了权限要重新登录或等缓存失效"这个说法存在。2.3 用权限版本号把这个体验改好了,见下一节。
2. 权限版本号:让缓存立即失效
private const string PermissionVersionKey = "PermissionVersion";
private string ScopedPermissionVersionKey => $"{TenantCachePrefix}{PermissionVersionKey}";
private string ScopedUserRolesKey(int version) => $"{TenantCachePrefix}UserRoles:{version}:{User!.Id}";
private string ScopedUserRoleMenusKey(int version) => $"{TenantCachePrefix}UserRoleMenus:{version}:{User!.Id}";
public async Task InvalidatePermissionCacheAsync()
{
var version = await GetPermissionVersionAsync();
await cache.SetAsync(ScopedPermissionVersionKey, version + 1, TimeSpan.FromDays(30));
}
思路很直接:缓存键里带版本号,版本号一涨,所有用户的旧键就都读不到了,等于全量失效。菜单、角色、用户角色的变更页面都会调用它:
| 页面 | 调用点 |
|---|---|
Pages/Menu.razor |
菜单新增/编辑/删除后 |
Pages/Role.razor |
角色保存、分配菜单、删除后 |
Pages/User.razor |
用户角色变更后 |
缓存键还带了 TenantCachePrefix(tenant:{code}:),所以租户之间不会互相清缓存、更不会串权限——这部分在多租户那篇会展开。
三、路由级授权:AuthPath
1. 判定逻辑
public async Task<bool> AuthPath(string path, string button = "")
{
if (User == null || string.IsNullOrEmpty(path)) return false;
path = RemoveAfterThirdSlash(path).ToLower().Trim('/');
await InitRoles();
if (Roles.Count == 0) return false;
CurrentMenu = RoleMenus.FirstOrDefault(a => a.PathLower == path);
if (new[] { "admin", "admin/login", "admin/profile" }.Contains(path)) return true;
if (CurrentMenu != null)
{
CurrentMenu.Parent = RoleMenus.FirstOrDefault(a => a.Id == CurrentMenu.ParentId)!;
}
if (button != "")
{
AuthPathSuccess = CurrentMenu != null;
return AuthPathSuccess && FindButtonRecursive([CurrentMenu], button.ToLower()) != null;
}
return AuthPathSuccess = CurrentMenu != null;
}
逐条翻译成白话:
- 没登录直接拒绝。
- 路径先做归一化:
RemoveAfterThirdSlash(只保留前三段)、转小写、去掉首尾斜杠。这是为了让/Admin/User/123这样的详情页能匹配到/Admin/User这个菜单。 - 没角色直接拒绝。注意这里是
Roles.Count == 0就 false,不是"没有菜单才 false"。 - 白名单:
admin、admin/login、admin/profile恒放行。 - 菜单匹配用
PathLower(存库时由审计自动写入小写值),所以菜单路径大小写不敏感。 - 匹配成功后顺手把
Parent从RoleMenus里补上,供面包屑渲染。 - 第二个参数
button非空时,是"路由 + 按钮"联合判定,返回值为按钮是否存在。
2. 拦截点在哪
MainLayout 把 OnAuthorizing 交给了 BootstrapBlazor 的 Layout:
<Layout ... Menus="@Menus" OnAuthorizing="OnAuthorizing" ...>
async Task<bool> OnAuthorizing(string path)
{
// 等待初始化完成
await _initializationCompleted.Task;
if (!await admin.CheckOtherLogin())
{
return true;
}
return await admin.AuthPath(new Uri(path).AbsolutePath);
}
而页面主体渲染前还会再看一次结果:
<Main>
<CascadingValue Value="this" IsFixed="true">
@if (admin.AuthPathSuccess)
{
@Body
}
</CascadingValue>
</Main>
另外,MainLayout 在 OnAfterRenderAsync 里注册了导航拦截:
locationChangingHandler = nav.RegisterLocationChangingHandler(async e =>
{
var path = nav.ToAbsoluteUri(e.TargetLocation).AbsolutePath.ToLower().Trim('/');
if (!AdminOptions.Value.EnableMessage && path.Contains("message"))
{
return;
}
IsFavorite = FavoriteMenus.Any(x => x.Value.ToLower() == path);
await admin.AuthPath(path);
});
所以路由级授权有两条防线:进入页面时的 OnAuthorizing,以及后续每一次导航时的 LocationChanging 回调。
3. 导航菜单也来自同一份数据
侧边栏菜单不是写死的,而是从 RoleMenus 里筛出来的:
SysMenuType[] menuTypes = [SysMenuType.Menu, SysMenuType.Crud];
var data = admin.RoleMenus
.Where(a => a.IsHidden == false)
.Where(a => menuTypes.Contains(a.Type))
.OrderBy(a => a.Sort)
.ToList();
只渲染"菜单"和"增删改查"两种类型,Button 不会出现在侧边栏,IsHidden 的也不会。这就是"权限即导航":能看到的菜单和能进的页面是同一份数据决定的,不会出现两处配置不一致。
四、按钮级授权:AuthButton
public async Task<bool> AuthButton(string path)
{
if (User == null || CurrentMenu == null) return false;
await InitRoles();
if (Roles.Any(a => a.IsAdministrator)) return true;
return FindButtonRecursive([CurrentMenu], path.ToLower()) != null;
}
注意它依赖 CurrentMenu。CurrentMenu 是 AuthPath 阶段确定的,所以 AuthButton 的语义是"当前页面下有没有这个按钮权限",不是全局权限码。
递归查找的实现:
private SysMenu? FindButtonRecursive(List<SysMenu> findMenus, string pathLower)
{
var parentIds = new HashSet<long>(findMenus.Select(m => m.Id));
var childs = RoleMenus.Where(a => parentIds.Contains(a.ParentId)).ToList();
if (childs.Count == 0) return null;
var button = childs.FirstOrDefault(a => a.Type == SysMenuType.Button && a.PathLower == pathLower);
if (button != null) return button;
return FindButtonRecursive(childs, pathLower);
}
从当前菜单往下逐层找 Type = Button 且路径匹配的节点。所以:
- 按钮必须挂在目标菜单的子节点下;
- 挂在别的菜单下不算数;
- 层级别太深(虽然它是广度优先递归,但按钮挂在子菜单下面才符合语义)。
谁在调用 AuthButton
| 调用点 | 用途 |
|---|---|
AdminTable.OnParametersSetAsync |
决定 ShowAddButton / ShowEditButton / ShowDeleteButton 是否显示 |
AdminTable.OnSaveDataAsync / OnDeleteDataAsync |
保存、删除前的服务端校验 |
AdminTable.SaveWithPermissionAsync / DeleteWithPermissionAsync |
包装页面自定义回调 |
AdminTable 导入 |
AuthButton("add") |
Pages/User.razor、Role.razor、Org.razor、Menu.razor |
页面内自定义按钮与二次校验 |
AdminFilePicker.razor |
AuthPath("/Admin/File", "add") / AuthPath("/Admin/File", "remove") |
最后一行说明 AuthPath(path, button) 这个重载的用途:跨页面判定别的页面的按钮权限。文件选择器要判断的不是当前页,而是文件管理页的权限。
五、服务端二次校验:这才是安全边界
界面上隐藏按钮只是体验,真正的安全边界在服务端。2.3 里至少有三层。
1. AdminTable 的写路径
保存和删除都会先过 AuthButton(第 04 篇有完整代码):
var action = changedType == ItemChangedType.Add ? "add" : "edit";
if (!await admin.AuthButton(action))
{
await MessageService.Error(string.Format(CommonLocalizer["没有权限执行该操作"]));
return false;
}
2. AdminButtonAttribute:给方法加权限
框架提供了一个 AOP 特性,基于 Rougamo 实现:
[AttributeUsage(AttributeTargets.Method)]
public class AdminButtonAttribute : Rougamo.AsyncMoAttribute
{
public string Name { get; set; } = string.Empty;
public override async ValueTask OnEntryAsync(MethodContext context)
{
var service = context.GetServiceProvider();
var admin = service.GetService<AdminContext>();
var swalService = service.GetService<SwalService>();
if (admin is null)
{
context.ReplaceReturnValue(this, context.ReturnType.CreateInstanceGetDefaultValue());
return;
}
if (!await admin.AuthButton(Name))
{
context.ReplaceReturnValue(this, CreateDeniedReturnValue(context.ReturnType));
if (swalService is not null)
await swalService.Warning("你没有权限执行该操作!");
}
}
}
用法就是在服务方法上标权限码:
[AdminButton("edit")]
private async Task AllocMenus() { ... }
被拦截时,Task<bool> 返回 false、Task 返回已完成任务,方法体不会执行。源码里对返回值类型的处理是有意保守的:无法安全构造返回值的泛型 Task<T>,它会返回 null 交给调用方处理。
3. 真正的 HTTP 端点:SysFileController
Blazor Server 后台大部分操作走 SignalR 电路,但文件下载这类必须走 HTTP,因此授权要单独做。SysFileController 里的私有文件判定:
private async Task<bool> IsAuthorizedAsync(SysFile file)
{
await _adminContext.InitRoles();
if (_adminContext.Roles.Any(r => r.IsAdministrator))
{
return true;
}
if (_adminContext.Roles.Any(r => r.DataPermission == DataPermissionType.AllData))
{
return true;
}
return file.CreatedUserId == _adminContext.User?.Id;
}
注意它的注释写得很清楚:文件实体本身不实现 IDataPermission,所以这里用的是"与数据权限一致的判定",并且上传人信息缺失时按拒绝处理(fail-closed)。
六、关于"API 权限",需要说实话的部分
很多文章会把"后台权限"和"API 权限"混在一起讲。在 EasyAdminBlazor 的架构里,需要分清楚:
1. 后台页面本身没有独立 API 层。 页面是 MapRazorComponents<App>() 下的 Blazor 组件,交互走 SignalR 电路,不存在"前端调 REST API"这一步。所以授权点就是前面讲的:路由(AuthPath)+ 操作(AuthButton)+ 服务方法(AdminButtonAttribute)。
2. 需要对外提供 API 时,要单独设计。 框架提供的是登录票据认证处理器和 Cookie 认证(Infrastructure/Security/EasyAdminLoginTicketAuthenticationHandler.cs、CookieOptionsHelper.cs),以及 CSRF 防护(EasyAdminAntiforgeryFilter.cs、app.UseAntiforgery())。如果你要暴露给第三方或前端 SPA 用,需要自己加授权策略——这部分不要指望 Blazor 组件的权限判定自动生效。
3. 自定义的 HTTP 端点要自己写授权。 参考 SysFileController 的做法:注入 AdminContext,调用 InitRoles(),再按角色/数据权限判定,而不是只看"是否登录"。
把这条讲清楚,比随便写一句"框架自带 API 权限"要诚实得多。
七、实战排查:权限不对时按这个顺序查
| 现象 | 大概率原因 | 查什么 |
|---|---|---|
登录后直接跳回 /Admin/ |
AuthPath 返回 false |
用户的角色是否勾了该菜单;菜单 Path 是否和 @page 一致(忽略大小写) |
| 菜单能看到,点进去跳首页 | 菜单类型不是 Menu / Crud,或 IsHidden = true |
菜单管理里的类型和隐藏开关 |
| 添加/编辑/删除按钮不显示 | 按钮子节点缺失,或角色未勾 | 该菜单下是否有 add / edit / remove;角色的菜单树是否包含按钮 |
| 按钮显示了但保存失败提示"没有权限" | 按钮权限有,但服务端判定用的页面上下文不对 | CurrentMenu 是否是你以为的那个菜单;是否存在同名路径 |
| 改了权限要等一会儿才生效 | 权限缓存 | 变更页面是否调用了 InvalidatePermissionCacheAsync;当前 Circuit 是否需要重新登录 |
详情页 /Admin/User/123 匹配不上 |
路径截断规则 | RemoveAfterThirdSlash 只保留前三段,菜单必须注册在前两段(如 Admin/User) |
八、小结
2.3 的权限系统可以用一张图概括:
登录
→ InitRoles():User → Roles → RoleMenus(带缓存 + 版本号)
→ AuthPath(path):菜单级授权(路由守卫 + 导航守卫)
→ AuthButton(code):按钮级授权(UI 显隐 + 服务端校验)
→ AdminButtonAttribute:服务方法级授权(AOP)
→ HTTP 端点自行授权(参考 SysFileController)
穿透整条链路的关键认知只有一句:菜单和按钮是同一张表,页面能进、按钮能点、服务端放行,用的是同一套数据。 理解了这一点,权限问题的排查就变成了"这份数据里到底有没有这条记录"。
如果你正在用 .NET 10 + Blazor 做后台,权限是绕不开的基础设施。EasyAdminBlazor 把菜单、按钮、角色、数据权限做成了一套开箱可用的模型,需要深度定制时也能顺着源码改。