在 ASP.NET Core 项目中,权限控制常见的做法是 [Authorize] + Policy。但 SwitchData 项目采用了一套更轻量、更贴近业务码表的方案:默认拒绝(Default Deny)+ 方法级权限码 + 内存缓存。本文从 SwitchData.Api 的 NoPermissionAttribute、PermissionAttribute 和 SecurityFilterProvider 出发,拆解这套权限过滤器的完整实现。
1. 设计思路:为什么不用 [Authorize]?
传统 [Authorize(Policy = "...")] 适合基于角色或策略的粗粒度控制。但项目中存在大量细粒度按钮级权限,例如 user:add、role:edit、ne:export。这些权限码动态维护在数据库中,且经常变化。如果每个权限都写一个 Policy,代码会非常臃肿。
因此项目采用以下设计:
- 控制器默认禁止访问:所有 Controller 继承的基类都标记
[NoPermission],没有显式声明权限的方法直接 403。 - 方法级显式授权:需要开放的方法用
[Permission("user:add")]声明所需权限。 - 匿名接口放行:登录、验证码等接口用
[AllowAnonymous]跳过检查。 - 权限列表缓存:登录后将用户权限码写入
IMemoryCache,过滤器运行时直接读缓存,避免每次请求查库。
这种”默认拒绝、显式放行”的思路,比”默认放行、再加授权”更安全,也避免了遗漏。
2. NoPermissionAttribute:控制器级别的默认拒绝
NoPermissionAttribute 是一个 IAsyncActionFilter,只能标注在类(控制器)上。它的逻辑很简单:
- 如果方法或控制器标注了
[AllowAnonymous],放行。 - 如果方法明确标注了
[Permission],放行(交给PermissionAttribute细粒度校验)。 - 否则返回 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:控制过滤器执行顺序
到这里你可能会有疑问:NoPermissionAttribute 和 PermissionAttribute 都是 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 在构建过滤器管道时,会调用所有注册的 IFilterProvider。SecurityFilterProvider 通过 Order = -1000 确保自己最先执行,然后按”先 NoPermission、后 Permission”的顺序把过滤器加入 FilterItem 集合。这样最终的执行顺序就是:
NoPermissionAttribute:先检查是否有 AllowAnonymous 或 Permission。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:edit和user:audit。- 未标注任何特性的方法:NoPermission 直接 403。
6. 总结
这套权限过滤器的精髓在于三点:
- 默认拒绝比默认放行更安全,新接口必须显式声明权限才能被访问。
- 方法级权限码比角色更细粒度,适合按钮级权限控制。
IFilterProvider可以精确控制过滤器执行顺序,避免默认顺序带来的不确定性。
它并不是要替代 [Authorize] 和 Policy,而是在”权限码多、变化快、需要按钮级控制”的业务场景下,提供了一种更轻量、更可控的 ASP.NET Core 权限方案。