EasyAdminBlazor 权限系统源码解析:菜单、按钮、API 是如何完成授权的?

原创 2026-09-26 872 次阅读

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

三个细节值得注意:

  1. 管理员走的是"全部菜单",不是"勾选的菜单"。只要角色上 IsAdministrator = true,RoleMenus 就是整张菜单表。
  2. 父菜单会自动补全。如果角色只勾了子菜单,AsTreeCte(up: true) 会把祖先链补上——否则导航树渲染不出来,面包屑也会缺层级。
  3. 结果进缓存 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 把菜单、按钮、角色、数据权限做成了一套开箱可用的模型,需要深度定制时也能顺着源码改。