在 ASP.NET Core 项目中,权限控制常见的做法是 [Authorize] + Policy。但 SwitchData 项目采用了一套更轻量、更贴近业务码表的方案:默认拒绝(Default Deny)+ 方法级权限码 + 内存缓存。本文从 SwitchData.ApiNoPermissionAttributePermissionAttributeSecurityFilterProvider 出发,拆解这套权限过滤器的完整实现。

1. 设计思路:为什么不用 [Authorize]?

传统 [Authorize(Policy = "...")] 适合基于角色或策略的粗粒度控制。但项目中存在大量细粒度按钮级权限,例如 user:addrole:editne:export。这些权限码动态维护在数据库中,且经常变化。如果每个权限都写一个 Policy,代码会非常臃肿。

因此项目采用以下设计:

  • 控制器默认禁止访问:所有 Controller 继承的基类都标记 [NoPermission],没有显式声明权限的方法直接 403。
  • 方法级显式授权:需要开放的方法用 [Permission("user:add")] 声明所需权限。
  • 匿名接口放行:登录、验证码等接口用 [AllowAnonymous] 跳过检查。
  • 权限列表缓存:登录后将用户权限码写入 IMemoryCache,过滤器运行时直接读缓存,避免每次请求查库。

这种”默认拒绝、显式放行”的思路,比”默认放行、再加授权”更安全,也避免了遗漏。

2. NoPermissionAttribute:控制器级别的默认拒绝

NoPermissionAttribute 是一个 IAsyncActionFilter,只能标注在类(控制器)上。它的逻辑很简单:

  1. 如果方法或控制器标注了 [AllowAnonymous],放行。
  2. 如果方法明确标注了 [Permission],放行(交给 PermissionAttribute 细粒度校验)。
  3. 否则返回 403。

代码如下:

[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)]
public class NoPermissionAttribute : Attribute, IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        if (HasAllowAnonymousAttribute(context))
        {
            await next();
            return;
        }

        if (HasExplicitPermission(context))
        {
            await next();
            return;
        }

        SetForbiddenResult(context);
    }

    private bool HasAllowAnonymousAttribute(ActionExecutingContext context)
    {
        if (context.ActionDescriptor.EndpointMetadata
            .Any(x => x is Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute))
            return true;

        var controllerType = context.Controller.GetType();
        return controllerType.GetCustomAttributes(
            typeof(Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute), true).Any();
    }

    private bool HasExplicitPermission(ActionExecutingContext context)
    {
        return context.ActionDescriptor.EndpointMetadata.Any(x => x is PermissionAttribute);
    }

    private void SetForbiddenResult(ActionExecutingContext context) =>
        context.HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden;
}

关键点EndpointMetadata 是 ASP.NET Core 在 Endpoint Routing 阶段收集的元数据集合,包含了作用在控制器和方法上的所有 Attribute。通过它可以直接判断当前 Action 是否标注了目标特性,无需反射扫描 MethodInfo

3. PermissionAttribute:方法级权限校验

PermissionAttribute 标注在方法上,负责真正的权限校验。它从当前用户的 Claims 中取出 UserId,再到 IMemoryCache 中读取该用户的权限码列表,最后判断是否包含方法所需权限。

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class PermissionAttribute : Attribute, IAsyncActionFilter
{
    public string[] Permission { get; }

    public PermissionAttribute(params string[] permission)
    {
        if (permission == null || permission.Length == 0)
            throw new ArgumentException("权限字符串组不能为空", nameof(permission));
        Permission = permission;
    }

    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        if (HasAllowAnonymousAttribute(context))
        {
            await next();
            return;
        }

        var user = context.HttpContext.User;
        var userid = user.FindFirst("UserId")?.Value;
        var memoryCache = context.HttpContext.RequestServices.GetService<IMemoryCache>();
        var permissions = memoryCache.Get<List<string>>($"Permissions_{userid}");

        if (!user.Identity.IsAuthenticated || permissions == null || permissions.Count == 0)
        {
            SetUnauthorizedResult(context, "请重新登录");
            return;
        }

        if (!CheckPermissionAsync(context, permissions))
        {
            SetForbiddenResult(context);
            return;
        }

        await next();
    }

    private bool CheckPermissionAsync(ActionExecutingContext context, List<string> permissions)
    {
        try
        {
            if (Permission.Contains(PermissionPrivileges.White.Default))
                return true;

            return Permission.All(element => permissions.Contains(element));
        }
        catch (Exception ex)
        {
            var logger = context.HttpContext.RequestServices
                .GetService<ILogger<PermissionAttribute>>();
            logger?.LogError(ex, "权限检查出错: {Permission}", Permission);
            return false;
        }
    }

    private void SetForbiddenResult(ActionExecutingContext context) =>
        context.HttpContext.Response.StatusCode = StatusCodes.Status403Forbidden;

    private void SetUnauthorizedResult(ActionExecutingContext context, string message) =>
        context.HttpContext.Response.StatusCode = StatusCodes.Status401Unauthorized;
}

设计亮点

  • 支持多权限码params string[] 允许 [Permission("user:add", "user:edit")],内部使用 All(...Contains(...)) 要求用户同时拥有全部权限。
  • 白名单机制PermissionPrivileges.White.Default 是一个特殊的白名单权限码,标注它的接口可以被任何人访问(但仍需登录)。
  • 缓存优先IMemoryCache 的键是 Permissions_{userid},登录时由 AccountService 写入,过滤器运行时零数据库查询。
  • 异常不穿透:校验过程中任何异常都会记录日志并返回无权限,避免因为缓存异常导致接口 500。

4. SecurityFilterProvider:控制过滤器执行顺序

到这里你可能会有疑问:NoPermissionAttributePermissionAttribute 都是 IAsyncActionFilter,ASP.NET Core 默认会按什么顺序执行?默认情况下,全局过滤器、控制器过滤器、Action 过滤器的执行顺序由 FilterScope 和注册顺序共同决定,但两个特性标注的过滤器并不一定按我们期望的”先默认拒绝、再细粒度校验”执行。

为了确保 NoPermissionAttribute 一定在 PermissionAttribute 之前运行,项目实现了一个自定义的 IFilterProvider

public class SecurityFilterProvider : IFilterProvider
{
    public int Order => -1000;

    public void OnProvidersExecuting(FilterProviderContext context)
    {
        if (context.ActionContext.ActionDescriptor is ControllerActionDescriptor descriptor)
        {
            // 1. 先注入控制器级别的 NoPermissionAttribute
            var noPermissionAttributes = descriptor.ControllerTypeInfo
                .GetCustomAttributes(typeof(NoPermissionAttribute), true)
                .Cast<NoPermissionAttribute>();

            foreach (var attribute in noPermissionAttributes)
            {
                context.Results.Add(new FilterItem(
                    new FilterDescriptor(attribute, FilterScope.Controller),
                    attribute));
            }

            // 2. 再注入方法级别的 PermissionAttribute
            var permissionAttributes = descriptor.MethodInfo
                .GetCustomAttributes(typeof(PermissionAttribute), true)
                .Cast<PermissionAttribute>();

            foreach (var attribute in permissionAttributes)
            {
                context.Results.Add(new FilterItem(
                    new FilterDescriptor(attribute, FilterScope.Action),
                    attribute));
            }
        }
    }

    public void OnProvidersExecuted(FilterProviderContext context) { }
}

Program.cs 中注册:

builder.Services.AddSingleton<IFilterProvider, SecurityFilterProvider>();

IFilterProvider 的核心作用:ASP.NET Core 在构建过滤器管道时,会调用所有注册的 IFilterProviderSecurityFilterProvider 通过 Order = -1000 确保自己最先执行,然后按”先 NoPermission、后 Permission”的顺序把过滤器加入 FilterItem 集合。这样最终的执行顺序就是:

  1. NoPermissionAttribute:先检查是否有 AllowAnonymous 或 Permission。
  2. PermissionAttribute:再校验具体权限码。

可以用下面的流程图概括整个决策过程:

flowchart TD
    A[请求到达 Action] --> B{SecurityFilterProvider<br/>注入过滤器}
    B --> C[NoPermissionAttribute 执行]
    C --> D{是否存在 AllowAnonymous?}
    D -->|是| E[直接放行]
    D -->|否| F{是否存在 PermissionAttribute?}
    F -->|否| G[返回 403]
    F -->|是| H[PermissionAttribute 执行]
    H --> I{用户已登录<br/>且缓存有权限?}
    I -->|否| J[返回 401]
    I -->|是| K{是否包含所需权限码?}
    K -->|否| G
    K -->|是| L[执行目标 Action]

5. 使用方式

定义基控制器:

[NoPermission]
[ApiController]
[Route("api/[controller]")]
public abstract class ApiBaseController : ControllerBase
{
}

业务控制器:

public class UserController : ApiBaseController
{
    [HttpGet]
    [AllowAnonymous]
    public IActionResult HealthCheck() => Ok();

    [HttpPost]
    [Permission("user:add")]
    public async Task<IActionResult> AddUser([FromBody] UserDto dto)
    {
        // ...
    }

    [HttpPut]
    [Permission("user:edit", "user:audit")]
    public async Task<IActionResult> EditUser([FromBody] UserDto dto)
    {
        // ...
    }
}

效果:

  • HealthCheck:AllowAnonymous,直接访问。
  • AddUser:NoPermission 发现方法有 Permission,放行;Permission 校验 user:add
  • EditUser:需要同时具有 user:edituser:audit
  • 未标注任何特性的方法:NoPermission 直接 403。

6. 总结

这套权限过滤器的精髓在于三点:

  1. 默认拒绝比默认放行更安全,新接口必须显式声明权限才能被访问。
  2. 方法级权限码比角色更细粒度,适合按钮级权限控制。
  3. IFilterProvider 可以精确控制过滤器执行顺序,避免默认顺序带来的不确定性。

它并不是要替代 [Authorize] 和 Policy,而是在”权限码多、变化快、需要按钮级控制”的业务场景下,提供了一种更轻量、更可控的 ASP.NET Core 权限方案。