Skip to content
Go back

构建安全的 ASP.NET Core API:角色与权限协同的实践指南

Published:  at  12:00 AM

构建安全的 ASP.NET Core API:角色与权限协同的实践指南

为什么 RBAC 能解决 API 授权的痛点

在大多数企业级应用中,身份验证往往先行完成,但真正耗费精力的是确认“这个用户能做什么”。早期我们常直接判断用户是不是 Admin,可随着业务扩张,这种硬编码的方式会让授权逻辑散落在各处,难以维护。角色基础的访问控制(Role-Based Access Control,RBAC)通过“用户 → 角色 → 权限”三段链条让授权抽象化,把可执行的操作定义成权限,再由角色组合权限并分配给用户。这样我们就可以在不修改代码的情况下快速调整权限组合,并且把授权策略上升为可配置的业务能力。

RBAC 的价值不仅在于减少 if-else 判断,还在于提升可扩展性:一个呼叫中心系统可能同时需要主管、审核员、外包团队等多种角色。只要权限粒度足够细,就能灵活应对新角色的出现。更重要的是,权限名称(例如 tickets:approve)可以直接映射到业务用语,让开发、产品和安全团队拥有共同的语境。

建模角色与权限:从领域语言开始

实施 RBAC 的第一步是围绕业务语言定义权限清单,而非先画数据库表。推荐从“动作 + 资源”出发命名权限,例如 users:readusers:updatereports:export,确保团队在讨论时无需转换上下文。权限一旦确定,再设计角色聚合这些权限就容易得多。

数据建模可以采用三表结构:

CREATE TABLE app_users (
    id            INT PRIMARY KEY,
    email         NVARCHAR(256) NOT NULL,
    is_active     BIT           NOT NULL DEFAULT 1
);

CREATE TABLE app_roles (
    id            INT PRIMARY KEY,
    name          NVARCHAR(64)  NOT NULL UNIQUE
);

CREATE TABLE role_permissions (
    role_id       INT NOT NULL,
    permission    NVARCHAR(100) NOT NULL,
    PRIMARY KEY (role_id, permission),
    FOREIGN KEY (role_id) REFERENCES app_roles(id)
);

角色与用户之间通常还会有一张关联表,这里省略。关键是保持权限表的唯一性约束与审计字段,方便追踪谁变更了角色配置。在建模阶段可以顺带规划命名空间,如 users:*orders:*,为未来做权限前缀匹配留出口。

在 ASP.NET Core 中实现权限驱动的授权管线

ASP.NET Core 的授权模型基于“策略(Policy)—需求(Requirement)—处理器(Handler)”。我们可以把“用户是否拥有某个权限”视为一项需求,再编写自定义处理器来从 Claims 中验证。下面是一个将需求与处理器结合的实现:

public sealed class PermissionAuthorizationRequirement(
    params string[] allowedPermissions)
    : AuthorizationHandler<PermissionAuthorizationRequirement>, IAuthorizationRequirement
{
    public IReadOnlyCollection<string> AllowedPermissions { get; } = allowedPermissions;

    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        PermissionAuthorizationRequirement requirement)
    {
        bool hasPermission = requirement.AllowedPermissions.Any(permission =>
            context.User.Claims.Any(claim =>
                claim.Type == CustomClaimTypes.Permission &&
                claim.Value.Equals(permission, StringComparison.OrdinalIgnoreCase)));

        if (hasPermission)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

上面的处理器做了一个“至少拥有其一”的判定。若业务需要全部满足,也可以改用 All 操作即可。自定义的 Claim Type 建议集中定义,便于调用方与 API 保持一致:

public static class CustomClaimTypes
{
    public const string Permission = "permission";
}

在用户登录或刷新令牌时,需要把角色和权限塞进 Claims。可以借助 Entity Framework Core 查询角色拥有的权限,并生成 JWT:

string[] roles = await userManager.GetRolesAsync(user);

string[] permissions = await (
    from role in dbContext.Roles
    join link in dbContext.RolePermissions on role.Id equals link.RoleId
    where roles.Contains(role.Name)
    select link.Permission)
    .Distinct()
    .ToArrayAsync();

List<Claim> claims =
[
    new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
    new(JwtRegisteredClaimNames.Email, user.Email!),
    ..roles.Select(role => new Claim(ClaimTypes.Role, role)),
    ..permissions.Select(permission => new Claim(CustomClaimTypes.Permission, permission))
];

var descriptor = new SecurityTokenDescriptor
{
    Subject = new ClaimsIdentity(claims),
    Expires = DateTime.UtcNow.AddMinutes(options.TokenExpirationMinutes),
    SigningCredentials = signingCredentials,
    Issuer = options.Issuer,
    Audience = options.Audience
};

var tokenHandler = new JsonWebTokenHandler();
string accessToken = tokenHandler.CreateToken(descriptor);

上述查询应配合缓存或数据同步策略,避免每次生成令牌都访问数据库。对于长生命周期的刷新令牌,还需要考虑权限变更时的强制失效机制。

让 API 更易读:策略封装与复用

虽然可以直接在 AddAuthorization 中编写策略,但重复声明字符串既费时又容易出错。通过扩展方法把策略封装起来可以显著提升可读性:

public static class AuthorizationPolicyBuilderExtensions
{
    public static AuthorizationPolicyBuilder RequirePermission(
        this AuthorizationPolicyBuilder builder,
        params string[] allowedPermissions)
    {
        return builder.AddRequirements(
            new PermissionAuthorizationRequirement(allowedPermissions));
    }
}

Minimal API 的写法因此更紧凑:

app.MapGet("/users/me", async (ApplicationDbContext dbContext, ClaimsPrincipal user) =>
    {
        int userId = int.Parse(user.FindFirstValue(JwtRegisteredClaimNames.Sub)!);
        var profile = await dbContext.Users
            .AsNoTracking()
            .Where(u => u.Id == userId)
            .Select(u => new UserDto(u.Id, u.Email!, u.FirstName, u.LastName))
            .SingleOrDefaultAsync();

        return profile is null ? Results.NotFound() : Results.Ok(profile);
    })
    .RequireAuthorization(policy =>
        policy.RequirePermission(Permissions.UsersRead));

对于 MVC 或 Razor Pages,可以用自定义特性隐藏授权细节:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public sealed class RequirePermissionAttribute(params string[] permissions) : AuthorizeAttribute
{
    public RequirePermissionAttribute(params string[] permissions)
        : base(policy: string.Join(",", permissions))
    {
    }
}

随后在 Program.cs 中集中注册:

builder.Services.AddAuthorizationBuilder()
    .AddPolicy(Permissions.UsersRead, policy => policy.RequirePermission(Permissions.UsersRead))
    .AddPolicy(Permissions.UsersUpdate, policy => policy.RequirePermission(Permissions.UsersUpdate));

这种做法将“策略声明 → 控制器/端点装饰”分离开,便于代码审查时快速识别某个 API 对应的权限。

生产级强化:类型安全、权限分发与缓存

在生产环境里,RBAC 方案还需要考虑多个维度:

首先是类型安全。把权限定义为枚举或 readonly record 可以让编译器捕捉拼写错误,再通过转换器统一映射为字符串。对于需要本地化的系统,还可以在枚举上添加描述属性,提供更友好的管理界面。

其次是权限分发方式。把所有权限塞进 JWT 虽然方便,但会导致令牌体积不断膨胀,也难以及时感知权限变更。可以引入 IClaimsTransformation 在服务器端按需附加权限,并结合缓存降低数据库压力:

public sealed class PermissionClaimsTransformation(
    IPermissionService permissionService,
    IMemoryCache cache) : IClaimsTransformation
{
    public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        if (principal.Identity?.IsAuthenticated != true)
        {
            return principal;
        }

        string? userId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
        if (string.IsNullOrEmpty(userId))
        {
            return principal;
        }

        string cacheKey = $"permissions:{userId}";
        string[] permissions = await cache.GetOrCreateAsync(cacheKey, async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
            return await permissionService.GetUserPermissionsAsync(userId);
        }) ?? Array.Empty<string>();

        ClaimsIdentity identity = (ClaimsIdentity)principal.Identity;
        foreach (string permission in permissions)
        {
            identity.AddClaim(new Claim(CustomClaimTypes.Permission, permission));
        }

        return principal;
    }
}

最后要为权限变更建立观察点:记录谁修改了角色-权限关系、触发哪些刷新令牌需要作废、以及如何在分布式节点之间同步缓存。结合事件总线或 Change Token,可以把刷新动作做成后台任务,避免阻塞业务流程。

安全测试与运营监控

一个成熟的授权体系必须能被验证与监控。建议从以下角度出发:

运营阶段还可以定期生成“权限矩阵”报告,以业务对象为维度审计角色配置,让产品和合规团队参与审查。

常见陷阱与优化建议

实现 RBAC 时常见的问题包括:

当系统发展到多租户或模块化架构时,可以把角色与权限的定义下放到领域模块,再通过集中授权服务聚合,保持系统的可演进性。

结语

RBAC 是让授权逻辑从代码中抽象出来的有效途径。通过为权限建立统一命名、在 ASP.NET Core 中构建可复用的策略封装,以及在生产环境中加入类型安全、缓存与审计机制,我们能够在保证安全性的同时保持开发效率。面向未来的 API 授权体系不只是“拒绝未授权的请求”,更是让公司在角色变动、团队扩张时依旧可以从容调整授权策略。

参考资料



Previous Post
在 C# 中掌握 Expression Trees:构建可运行时组装的 LINQ 查询
Next Post
深入对比 IHostedService 与 BackgroundService:启动行为、适用场景与最佳实践