☰
EasyAdminBlazor 权限系统源码解析:菜单、按钮、API 是如何完成授权的?
2026/9/27 23:22:15 网站建设 项目流程

已发布的《权限控制——后台系统的核心命脉》讲的是 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

菜单类型决定了它在授权里扮演什么角色:

publicenumSysMenuType{[Display(Name="菜单")]Menu,[Display(Name="按钮")]Button,[Display(Name="外部链接")]ExternalLink,[Display(Name="增删改查")]Crud}

这里有个经常被忽略的设计:按钮也是菜单。它和页面共用一张表、一套关联关系,只是Type不同、Path是add/edit/remove这样的动作码。这样"角色能看哪些页、能点哪些按钮"就是一次勾选能解决的事,不需要第二套权限表。

框架初始化时会写入标准的增删改查按钮:

// EasyAdminBlazor/SeedData.csList<SysMenu>getCudButtons(paramsSysMenu[]btns){varlist=newList<SysMenu>{newSysMenu{Label="添加",Path="add",Sort=10011,Type=SysMenuType.Button,IsSystem=true},newSysMenu{Label="编辑",Path="edit",Sort=10012,Type=SysMenuType.Button,IsSystem=true},newSysMenu{Label="删除",Path="remove",Sort=10013,Type=SysMenuType.Button,IsSystem=true}};...}

也就是说,"按钮权限码"在框架里是约定好的字符串:add/edit/remove。自定义动作可以自己加,比如角色页的alloc_menus。


二、登录后,权限是怎么加载的

1. InitRoles:从数据库到内存

AdminContext.InitRoles()是权限的加载入口:

publicasyncTaskInitRoles(){if(User==null)return;varversion=awaitGetPermissionVersionAsync();Roles=awaitcache.GetOrCreateAsync<List<SysRole>>(ScopedUserRolesKey(version),async()=>{returnawaitOrm.Select<SysRole>().Where(a=>Orm.Select<SysRoleUser>().Any(b=>b.UserId==User.Id&&b.RoleId==a.Id)).ToListAsync();},TimeSpan.FromMinutes(30))??[];RoleMenus=awaitcache.GetOrCreateAsync<List<SysMenu>>(ScopedUserRoleMenusKey(version),async()=>{if(Roles.Any(a=>a.IsAdministrator)){returnawaitOrm.Select<SysMenu>().ToListAsync();}varroleIds=Roles.Select(a=>a.Id).ToList();varroleMenus=awaitOrm.Select<SysMenu>().Where(a=>Orm.Select<SysRoleMenu>().Any(b=>roleIds.Contains(b.RoleId)&&b.MenuId==a.Id)).ToListAsync();varparentIds=roleMenus.Select(a=>a.ParentId).Distinct().ToList();if(parentIds.Count>0){varparentMenus=awaitOrm.Select<SysMenu>().Where(a=>parentIds.Contains(a.Id)).AsTreeCte(up:true).ToListAsync();roleMenus.AddRange(parentMenus);}returnroleMenus.DistinctBy(a=>a.Id).ToList();},TimeSpan.FromMinutes(30))??[];}

三个细节值得注意:

  1. 管理员走的是"全部菜单",不是"勾选的菜单"。只要角色上IsAdministrator = true,RoleMenus就是整张菜单表。
  2. 父菜单会自动补全。如果角色只勾了子菜单,AsTreeCte(up: true)会把祖先链补上——否则导航树渲染不出来,面包屑也会缺层级。
  3. 结果进缓存 30 分钟。这就是为什么"改了权限要重新登录或等缓存失效"这个说法存在。2.3 用权限版本号把这个体验改好了,见下一节。

2. 权限版本号:让缓存立即失效

privateconststringPermissionVersionKey="PermissionVersion";privatestringScopedPermissionVersionKey=>$"{TenantCachePrefix}{PermissionVersionKey}";privatestringScopedUserRolesKey(intversion)=>$"{TenantCachePrefix}UserRoles:{version}:{User!.Id}";privatestringScopedUserRoleMenusKey(intversion)=>$"{TenantCachePrefix}UserRoleMenus:{version}:{User!.Id}";publicasyncTaskInvalidatePermissionCacheAsync(){varversion=awaitGetPermissionVersionAsync();awaitcache.SetAsync(ScopedPermissionVersionKey,version+1,TimeSpan.FromDays(30));}

思路很直接:缓存键里带版本号,版本号一涨,所有用户的旧键就都读不到了,等于全量失效。菜单、角色、用户角色的变更页面都会调用它:

页面调用点
Pages/Menu.razor菜单新增/编辑/删除后
Pages/Role.razor角色保存、分配菜单、删除后
Pages/User.razor用户角色变更后

缓存键还带了TenantCachePrefix(tenant:{code}:),所以租户之间不会互相清缓存、更不会串权限——这部分在多租户那篇会展开。


三、路由级授权:AuthPath

1. 判定逻辑

publicasyncTask<bool>AuthPath(stringpath,stringbutton=""){if(User==null||string.IsNullOrEmpty(path))returnfalse;path=RemoveAfterThirdSlash(path).ToLower().Trim('/');awaitInitRoles();if(Roles.Count==0)returnfalse;CurrentMenu=RoleMenus.FirstOrDefault(a=>a.PathLower==path);if(new[]{"admin","admin/login","admin/profile"}.Contains(path))returntrue;if(CurrentMenu!=null){CurrentMenu.Parent=RoleMenus.FirstOrDefault(a=>a.Id==CurrentMenu.ParentId)!;}if(button!=""){AuthPathSuccess=CurrentMenu!=null;returnAuthPathSuccess&&FindButtonRecursive([CurrentMenu],button.ToLower())!=null;}returnAuthPathSuccess=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" ...>
asyncTask<bool>OnAuthorizing(stringpath){// 等待初始化完成await_initializationCompleted.Task;if(!awaitadmin.CheckOtherLogin()){returntrue;}returnawaitadmin.AuthPath(newUri(path).AbsolutePath);}

而页面主体渲染前还会再看一次结果:

<Main> <CascadingValue Value="this" IsFixed="true"> @if (admin.AuthPathSuccess) { @Body } </CascadingValue> </Main>

另外,MainLayout在OnAfterRenderAsync里注册了导航拦截:

locationChangingHandler=nav.RegisterLocationChangingHandler(asynce=>{varpath=nav.ToAbsoluteUri(e.TargetLocation).AbsolutePath.ToLower().Trim('/');if(!AdminOptions.Value.EnableMessage&&path.Contains("message")){return;}IsFavorite=FavoriteMenus.Any(x=>x.Value.ToLower()==path);awaitadmin.AuthPath(path);});

所以路由级授权有两条防线:进入页面时的OnAuthorizing,以及后续每一次导航时的LocationChanging回调。

3. 导航菜单也来自同一份数据

侧边栏菜单不是写死的,而是从RoleMenus里筛出来的:

SysMenuType[]menuTypes=[SysMenuType.Menu,SysMenuType.Crud];vardata=admin.RoleMenus.Where(a=>a.IsHidden==false).Where(a=>menuTypes.Contains(a.Type)).OrderBy(a=>a.Sort).ToList();

只渲染"菜单"和"增删改查"两种类型,Button不会出现在侧边栏,IsHidden的也不会。这就是"权限即导航":能看到的菜单和能进的页面是同一份数据决定的,不会出现两处配置不一致。


四、按钮级授权:AuthButton

publicasyncTask<bool>AuthButton(stringpath){if(User==null||CurrentMenu==null)returnfalse;awaitInitRoles();if(Roles.Any(a=>a.IsAdministrator))returntrue;returnFindButtonRecursive([CurrentMenu],path.ToLower())!=null;}

注意它依赖CurrentMenu。CurrentMenu是AuthPath阶段确定的,所以AuthButton的语义是"当前页面下有没有这个按钮权限",不是全局权限码。

递归查找的实现:

privateSysMenu?FindButtonRecursive(List<SysMenu>findMenus,stringpathLower){varparentIds=newHashSet<long>(findMenus.Select(m=>m.Id));varchilds=RoleMenus.Where(a=>parentIds.Contains(a.ParentId)).ToList();if(childs.Count==0)returnnull;varbutton=childs.FirstOrDefault(a=>a.Type==SysMenuType.Button&&a.PathLower==pathLower);if(button!=null)returnbutton;returnFindButtonRecursive(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.razorAuthPath("/Admin/File", "add")/AuthPath("/Admin/File", "remove")

最后一行说明AuthPath(path, button)这个重载的用途:跨页面判定别的页面的按钮权限。文件选择器要判断的不是当前页,而是文件管理页的权限。


五、服务端二次校验:这才是安全边界

界面上隐藏按钮只是体验,真正的安全边界在服务端。2.3 里至少有三层。

1. AdminTable 的写路径

保存和删除都会先过AuthButton(第 04 篇有完整代码):

varaction=changedType==ItemChangedType.Add?"add":"edit";if(!awaitadmin.AuthButton(action)){awaitMessageService.Error(string.Format(CommonLocalizer["没有权限执行该操作"]));returnfalse;}

2. AdminButtonAttribute:给方法加权限

框架提供了一个 AOP 特性,基于 Rougamo 实现:

[AttributeUsage(AttributeTargets.Method)]publicclassAdminButtonAttribute:Rougamo.AsyncMoAttribute{publicstringName{get;set;}=string.Empty;publicoverrideasyncValueTaskOnEntryAsync(MethodContextcontext){varservice=context.GetServiceProvider();varadmin=service.GetService<AdminContext>();varswalService=service.GetService<SwalService>();if(adminisnull){context.ReplaceReturnValue(this,context.ReturnType.CreateInstanceGetDefaultValue());return;}if(!awaitadmin.AuthButton(Name)){context.ReplaceReturnValue(this,CreateDeniedReturnValue(context.ReturnType));if(swalServiceisnotnull)awaitswalService.Warning("你没有权限执行该操作!");}}}

用法就是在服务方法上标权限码:

[AdminButton("edit")]privateasyncTaskAllocMenus(){...}

被拦截时,Task<bool>返回false、Task返回已完成任务,方法体不会执行。源码里对返回值类型的处理是有意保守的:无法安全构造返回值的泛型Task<T>,它会返回null交给调用方处理。

3. 真正的 HTTP 端点:SysFileController

Blazor Server 后台大部分操作走 SignalR 电路,但文件下载这类必须走 HTTP,因此授权要单独做。SysFileController里的私有文件判定:

privateasyncTask<bool>IsAuthorizedAsync(SysFilefile){await_adminContext.InitRoles();if(_adminContext.Roles.Any(r=>r.IsAdministrator)){returntrue;}if(_adminContext.Roles.Any(r=>r.DataPermission==DataPermissionType.AllData)){returntrue;}returnfile.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 把菜单、按钮、角色、数据权限做成了一套开箱可用的模型,需要深度定制时也能顺着源码改。

  • 文档:https://easyadmin.wang-zhan.com.cn/doc
  • 源码:https://gitee.com/gudufy/EasyAdminBlazor

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询