ASP.NET Core中基于策略的授权

ASP.NET Core授权策略是一组命名的一组或多项授权要求,框架评估该要求决定是否允许用户访问资源。

本文解释:

  • 如何创建要求。
  • 如何注册和应用策略。
  • 用于单个和多个要求项评估的授权处理程序。
  • 如何评估单个策略中的多个要求。

实际上,策略使用 [Authorize(Policy = "...")] (Razor 组件、页面和控制器)或 RequireAuthorization(...) (终结点)应用,框架使用处理程序来评估策略背后的要求。 IAuthorizationPolicyProvider(ASP.NET Core 文档中的自定义授权策略提供程序)动态生成策略,而不是在应用启动时注册它们。

基于角色的授权基于声明的授权 使用要求、要求处理程序和预配置的授权策略。 这些构建基块支持代码中的授权评估的表达式。

本文使用 Razor 组件示例,重点介绍 ASP.NET Core 3.1 或更高版本的 Blazor 授权方案。 有关适用于 ASP.NET Core 所有版本的 Razor Pages 和 MVC 指南,请在阅读本文后参阅以下资源:

本文中的一些示例(ASP.NET Core 8.0 或更高版本)使用 C# 12(.NET 8)或更高版本提供的主要构造函数。 有关详细信息,请参阅声明类和结构的主要构造函数(C# 文档教程)主要构造函数(C# 指南)。

要求和策略登记

授权策略包含一个或多个要求,策略使用这些 要求来评估当前用户主体的授权。 某个要求实现空的标记接口 IAuthorizationRequirement

当要求不包含数据或具有属性(参数)时,它将充当一个空标记来触发关联的 授权处理程序 ()来处理授权(IAuthorizationHandler本文后面详述)。 由于此示例中的处理程序完全依赖于 HTTP 上下文、用户声明或后端数据来决定满足要求的用户,因此要求类本身不需要内部数据或参数。 该要求仅指示框架评估哪条规则。

例如,请考虑以下最低年龄要求(MinimumAgeRequirement),它只是作为标记类实现:

public class MinimumAgeRequirement : IAuthorizationRequirement { }

上述要求用于创建一个策略,用于确认用户是否超过处理程序检查的特定年龄。 AuthorizationHandler<MinimumAgeRequirement> 检查 AuthorizationHandlerContext.User。 如果用户有一个表示其年龄超过某个年龄的出生日期声明,则要求会成功。 在这种情况下,要求对象不需要任何属性(参数)。 下一个示例演示了具有参数来设置最小年龄要求的最低年龄要求的完整实现。

请考虑以下 MinimumAgeRequirement 要求,其中描述了一个参数(最低年龄)来评估用户授权:

using Microsoft.AspNetCore.Authorization;

namespace BlazorWebAppAuthorization.Policies.Requirements;

public class MinimumAgeRequirement(int minimumAge) : IAuthorizationRequirement
{
    public int MinimumAge { get; } = minimumAge;
}
using Microsoft.AspNetCore.Authorization;

public class MinimumAgeRequirement : IAuthorizationRequirement
{
    public MinimumAgeRequirement(int minimumAge) =>
        MinimumAge = minimumAge;

    public int MinimumAge { get; }
}
using Microsoft.AspNetCore.Authorization;

public class MinimumAgeRequirement : IAuthorizationRequirement
{
    public int MinimumAge { get; }

    public MinimumAgeRequirement(int minimumAge)
    {
        MinimumAge = minimumAge;
    }
}

通过调用Program将策略注册为应用文件中授权AuthorizationBuilder.AddPolicy服务配置的一部分。 以下示例创建一个 AtLeast21 策略,该策略只有一项最低年龄要求,并将最低年龄设置为 21 岁。

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("AtLeast21", policy => 
        policy.Requirements.Add(new MinimumAgeRequirement(21)));

通过调用Program将策略注册为应用文件中授权AuthorizationBuilder.AddPolicy服务配置的一部分。 以下示例创建一个仅包含最低年龄这一项要求的 AtLeast21 策略,并将最低年龄设置为 21 岁:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AtLeast21", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(21)));
});

Startup.ConfigureServicesStartup.cs)中,通过调用AuthorizationBuilder.AddPolicy,将策略注册为授权服务配置的一部分。 以下示例创建一个仅包含最低年龄这一项要求的 AtLeast21 策略,并将最低年龄设置为 21 岁:

services.AddAuthorization(options =>
{
    options.AddPolicy("AtLeast21", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(21)));
});

如果授权策略包含多个授权要求,则所有要求都必须通过才能使策略评估成功。 也就是说,添加到单个授权策略的多个授权要求将基于 AND 进行处理

将策略应用于 Razor 组件

使用具有策略名称的属性将Razor策略应用于[Authorize]组件:

@using Microsoft.AspNetCore.Authorization
@attribute [Authorize(Policy = "CustomerServiceMember")]

如果应用了多个策略,则在授予访问权限之前,所有策略都必须通过:

@using Microsoft.AspNetCore.Authorization
@attribute [Authorize(Policy = "CustomerServiceMember")]
@attribute [Authorize(Policy = "HumanResourcesMember")]

将策略应用到终结点

使用具有策略名称的 RequireAuthorization 将策略应用到终结点。 例如:

app.MapGet("/helloworld", () => "Hello World!")
    .RequireAuthorization("AtLeast21");

在 MVC 和 Razor Pages 应用中应用策略

有关在 Razor Pages 和 MVC 应用中应用策略的指导,请参阅以下资源:

授权服务接口 (IAuthorizationService

IAuthorizationService 主要负责确定在调用 IAuthorizationService.AuthorizeAsync 重载时授权是否成功:

  • AuthorizeAsync(ClaimsPrincipal user, object resource, IEnumerable<IAuthorizationRequirement> requirements):检查用户是否满足指定资源的一组特定授权要求。
  • AuthorizeAsync(ClaimsPrincipal user, object resource, string policyName):检查用户是否满足指定资源的特定授权策略。

如果策略评估不需要某个资源,则将 null 作为资源参数传递。

上述方法返回 AuthorizationResult 包装在一个 Task

每个 IAuthorizationHandler 都负责通过 IAuthorizationHandler.HandleAsync 检查是否满足要求。 该 AuthorizationHandlerContext 类包含实现使用的 IAuthorizationHandler 授权信息。 IAuthorizationRequirement 是一个不包含任何方法的标记接口,用于跟踪授权是否成功。 当使用 AuthorizationHandlerContext.Succeed 调用 IAuthorizationRequirement 时,即满足该策略:

context.Succeed(requirement);

授权处理程序

授权处理程序负责评估要求属性。 授权处理程序根据提供的 AuthorizationHandlerContext 评估要求来决定是否允许访问。

一个要求可以有多个处理程序。 处理程序可以继承 AuthorizationHandler<TRequirement>,其中 TRequirement 是要处理的要求。 处理程序也可直接实现 IAuthorizationHandler 以处理多种类型的要求。

针对一个要求使用处理程序

以下示例显示一对一关系,其中最小年龄处理程序处理单个要求:

using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using BlazorWebAppAuthorization.Policies.Requirements;

namespace BlazorWebAppAuthorization.Policies.Handlers;

public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
    {
        var dateOfBirthClaim = 
            context.User.FindFirst(c => c.Type == ClaimTypes.DateOfBirth);

        if (dateOfBirthClaim is null)
        {
            return Task.CompletedTask;
        }

        var dateOfBirth = Convert.ToDateTime(dateOfBirthClaim.Value);
        var calculatedAge = DateTime.Today.Year - dateOfBirth.Year;

        if (dateOfBirth > DateTime.Today.AddYears(-calculatedAge))
        {
            calculatedAge--;
        }

        if (calculatedAge >= requirement.MinimumAge)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;

public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
    {
        var dateOfBirthClaim = 
            context.User.FindFirst(c => c.Type == ClaimTypes.DateOfBirth);

        if (dateOfBirthClaim is null)
        {
            return Task.CompletedTask;
        }

        var dateOfBirth = Convert.ToDateTime(dateOfBirthClaim.Value);
        var calculatedAge = DateTime.Today.Year - dateOfBirth.Year;

        if (dateOfBirth > DateTime.Today.AddYears(-calculatedAge))
        {
            calculatedAge--;
        }

        if (calculatedAge >= requirement.MinimumAge)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}
using System;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;

public class MinimumAgeHandler : AuthorizationHandler<MinimumAgeRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
        MinimumAgeRequirement requirement)
    {
        if (!context.User.HasClaim(c => c.Type == ClaimTypes.DateOfBirth))
        {
            // Use the following if targeting a version of
            // .NET Framework older than 4.6:
            // return Task.FromResult(0);
            return Task.CompletedTask;
        }

        var dateOfBirth = Convert.ToDateTime(
            context.User.FindFirst(c => c.Type == ClaimTypes.DateOfBirth).Value);

        var calculatedAge = DateTime.Today.Year - dateOfBirth.Year;

        if (dateOfBirth > DateTime.Today.AddYears(-calculatedAge))
        {
            calculatedAge--;
        }

        if (calculatedAge >= requirement.MinimumAge)
        {
            context.Succeed(requirement);
        }

        // Use the following if targeting a version of
        // .NET Framework older than 4.6:
        // return Task.FromResult(0);
        return Task.CompletedTask;
    }
}

前面的代码确定当前用户主体是否具有出生日期声明。 如果缺少声明,则无法进行授权,在这种情况下,将返回已完成的任务。 当有索赔时,将计算用户的年龄。 如果用户满足要求定义的最低年龄,则视为授权成功。 授权成功后,将调用 context.Succeed,并将满足的要求作为其唯一参数。

针对多个需求使用处理程序

以下示例显示一对多关系,其中权限处理程序可以处理三种不同类型的要求:

using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using BlazorWebAppAuthorization.Policies.Requirements;

namespace BlazorWebAppAuthorization.Policies.Handlers;

public class PermissionHandler : IAuthorizationHandler
{
    public Task HandleAsync(AuthorizationHandlerContext context)
    {
        var pendingRequirements = context.PendingRequirements.ToList();

        foreach (var requirement in pendingRequirements)
        {
            if (requirement is ReadPermission)
            {
                if (IsOwner(context.User, context.Resource)
                    || IsSponsor(context.User, context.Resource))
                {
                    context.Succeed(requirement);
                }
            }
            else if (requirement is EditPermission || requirement is DeletePermission)
            {
                if (IsOwner(context.User, context.Resource))
                {
                    context.Succeed(requirement);
                }
            }
        }

        return Task.CompletedTask;
    }

    private static bool IsOwner(ClaimsPrincipal user, object? resource)
    {
        // Code omitted for brevity
        return true;
    }

    private static bool IsSponsor(ClaimsPrincipal user, object? resource)
    {
        // Code omitted for brevity
        return true;
    }
}
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;

public class PermissionHandler : IAuthorizationHandler
{
    public Task HandleAsync(AuthorizationHandlerContext context)
    {
        var pendingRequirements = context.PendingRequirements.ToList();

        foreach (var requirement in pendingRequirements)
        {
            if (requirement is ReadPermission)
            {
                if (IsOwner(context.User, context.Resource) ||
                    IsSponsor(context.User, context.Resource))
                {
                    context.Succeed(requirement);
                }
            }
            else if (requirement is EditPermission ||
                        requirement is DeletePermission)
            {
                if (IsOwner(context.User, context.Resource))
                {
                    context.Succeed(requirement);
                }
            }
        }

        // Use the following if targeting a version of
        // .NET Framework older than 4.6:
        // return Task.FromResult(0);
        return Task.CompletedTask;
    }

    private bool IsOwner(ClaimsPrincipal user, object resource)
    {
        // Code omitted for brevity

        return true;
    }

    private bool IsSponsor(ClaimsPrincipal user, object resource)
    {
        // Code omitted for brevity

        return true;
    }
}

前面的代码遍历 PendingRequirements - 一个包含未标记为成功的要求的属性。 对于 ReadPermission 要求,用户必须是所有者或发起人才能访问请求的资源。 对于 EditPermissionDeletePermission 的要求,其必须是所请求资源的所有者才能进行访问。

处理程序注册

在配置过程中,在服务集合中注册处理程序。 以下示例将最低年龄处理程序(MinimumAgeHandler)注册为单例服务,但处理程序也可以使用任何一种内置的服务生命周期进行注册:

builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();
services.AddSingleton<IAuthorizationHandler, MinimumAgeHandler>();

可以将要求和处理程序捆绑到一个实现 IAuthorizationRequirementIAuthorizationHandler 的类中。 此捆绑将在处理程序和要求之间建立紧密的耦合,建议仅用于简单的要求和处理程序。 创建实现这两个接口的类无需在服务容器中注册处理程序,因为内置 PassThroughAuthorizationHandler 允许要求自行处理。

有关这样一个示例,请参阅 ASP.NET Core AssertionRequirement 类的实现,其中 AssertionRequirement 在一个完全自包含的类中既是要求,也是处理程序。 框架 AssertionRequirement 的 API 允许你使用内联 lambda 表达式来验证访问,而不是编写单独的样板要求和处理程序类。

Note

指向 .NET 引用源的文档链接通常会加载存储库的默认分支,该分支代表正在进行的 .NET 下一版本的开发。 若要为特定版本选择标签,请使用“切换分支或标签”下拉菜单。 有关详细信息,请参阅如何选择 ASP.NET Core 源代码的版本标记 (dotnet/AspNetCore.Docs #26205)

处理程序应会返回哪种结果?

Handle 处理程序示例中的方法不返回任何值。 如何表示成功或失败状态?

  • 处理程序通过调用 context.Succeed并传递成功验证的要求(IAuthorizationRequirement)来指示成功。

  • 通常不需要处理程序来处理失败,因为相同要求的其他处理程序可能会成功。

  • 为了保证失败,即使其他要求处理程序成功,也需调用 context.Fail

如果处理程序调用 context.Succeedcontext.Fail,则仍将调用所有其他处理程序。 这使要求能够产生副作用,例如进行日志记录;即使另一个处理程序已成功满足某项要求,或在某项要求上验证失败,这些副作用仍会发生。 当设置为 false 时,InvokeHandlersAfterFailure 属性将在调用 context.Fail 时缩短处理程序的执行时间。 InvokeHandlersAfterFailure 默认为 true,在这种情况下,将调用所有处理程序。

Note

即使身份验证失败,也需调用授权处理程序。 此外,处理程序可以按任意顺序执行,因此 依赖于调用处理程序的顺序。

为什么需要多个处理程序才能实现一个要求?

如果想要基于 OR 进行评估,请为单个要求实现多个处理程序。 例如,假设 Contoso Corporation 的门只用钥匙卡打开。 如果你把钥匙卡留在家里,接待员会打印一个临时贴纸,为你打开门。 在此方案中,应用具有单个要求,但有多个处理程序,每个处理程序检查单个要求。

在以下示例实现中:

  • BuildingEntryRequirement 是进入建筑物的要求。
  • BadgeEntryHandler (个人有徽章)和 TemporaryStickerHandler (个人有临时贴纸)是单独的处理程序,每个处理程序检查单个要求。

BuildingEntryRequirement.cs

using Microsoft.AspNetCore.Authorization;

namespace BlazorWebAppAuthorization.Policies.Requirements;

public class BuildingEntryRequirement : IAuthorizationRequirement { }

BadgeEntryHandler.cs

using Microsoft.AspNetCore.Authorization;
using BlazorWebAppAuthorization.Policies.Requirements;

namespace BlazorWebAppAuthorization.Policies.Handlers;

public class BadgeEntryHandler : AuthorizationHandler<BuildingEntryRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, BuildingEntryRequirement requirement)
    {
        if (context.User.HasClaim(c => c.Type == "BadgeId"))
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

TemporaryStickerHandler.cs

using Microsoft.AspNetCore.Authorization;
using BlazorWebAppAuthorization.Policies.Requirements;

namespace BlazorWebAppAuthorization.Policies.Handlers;

public class TemporaryStickerHandler : AuthorizationHandler<BuildingEntryRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context, BuildingEntryRequirement requirement)
    {
        if (context.User.HasClaim(c => 
            c.Type == "TemporaryBadgeId" &&
            c.Issuer == "https://contososecurity"))
        {
            // Code to check expiration date omitted for brevity.
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

BuildingEntryRequirement.cs

using Microsoft.AspNetCore.Authorization;

public class BuildingEntryRequirement : IAuthorizationRequirement
{
}

BadgeEntryHandler.cs

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;

public class BadgeEntryHandler : AuthorizationHandler<BuildingEntryRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context,
                                                    BuildingEntryRequirement requirement)
    {
        if (context.User.HasClaim(c => 
            c.Type == "BadgeId" &&
            c.Issuer == "https://contososecurity"))
        {
            context.Succeed(requirement);
        }

        // Use the following if targeting a version of
        // .NET Framework older than 4.6:
        // return Task.FromResult(0);
        return Task.CompletedTask;
    }
}

TemporaryStickerHandler.cs

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;

public class TemporaryStickerHandler : AuthorizationHandler<BuildingEntryRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, 
        BuildingEntryRequirement requirement)
    {
        if (context.User.HasClaim(c => 
            c.Type == "TemporaryBadgeId" &&
            c.Issuer == "https://contososecurity"))
        {
            // We'd also check the expiration date on the sticker.
            context.Succeed(requirement);
        }

        // Use the following if targeting a version of
        // .NET Framework older than 4.6:
        // return Task.FromResult(0);
        return Task.CompletedTask;
    }
}

确保两个处理程序均已注册。 如果任一处理程序在策略评估 BuildingEntryRequirement时成功,则策略评估会成功。

使用Func来满足策略要求

在某些情况下,使用 Func<AuthorizationHandlerContext, bool> 策略生成器配置策略时,可以通过 RequireAssertion 委托在代码中轻松表达如何满足策略要求。 例如,可以按如下所示重写上述 BadgeEntryHandler 代码:

    options.AddPolicy("AtLeast21", policy =>
        policy.Requirements.Add(new MinimumAgeRequirement(21)));
            (c.Type == "BadgeId" || c.Type == "TemporaryBadgeId")
            && c.Issuer == "https://contososecurity")));
});

// <snippet_minimumAgeHandlerRegistration>
services.AddAuthorization(options =>
{
     options.AddPolicy("BadgeEntry", policy =>
        policy.RequireAssertion(context =>
            context.User.HasClaim(c =>
                (c.Type == "BadgeId" ||
                 c.Type == "TemporaryBadgeId") &&
                 c.Issuer == "https://microsoftsecurity")));
});

需要全局用户身份验证

有关如何要求对所有应用用户进行身份验证的信息,请参阅 创建具有受授权保护的用户数据的 ASP.NET Core应用

通过外部服务示例进行授权

通过外部服务示例(dotnet/AspNetCore.Docs.SamplesGitHub存储库)授权演示如何使用外部授权服务实现其他授权要求。 解决方案的项目Contoso.API使用Microsoft Entra ID进行保护。 Contoso.Security.API project 中的其他授权检查返回有效负载,描述 Contoso.API 客户端应用是否可以调用 GetWeather API。

配置示例

以下演示依赖于在命令行界面中使用 NSwag(Swagger/OpenAPI)cURL

Contoso.Security.API 项目中,将 AllowedClients 占位符({CLIENT ID})设置为任何测试 GUID 值(例如, 00001111-aaaa-2222-bbbb-3333cccc4444):

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "AllowedClients": [
    "{CLIENT ID (FOR THE CLIENT CALLING CONTOSO.API)}"
  ]
}

在针对 Contoso.API 项目打开的命令 shell 中,使用 dotnet user-jwts 生成访问令牌,并为在上一步中创建的客户端应用 ID 添加 appid 声明(例如 00001111-aaaa-2222-bbbb-3333cccc4444)。

dotnet user-jwts create --claim appid={GUID}

Example:

dotnet user-jwts create --claim appid=00001111-aaaa-2222-bbbb-3333cccc4444

输出在命令 shell 中的“Token:”后面生成令牌:

New JWT saved with ID '{JWT ID}'.
Name: {USER}
Custom Claims: [appid=00001111-aaaa-2222-bbbb-3333cccc4444]

Token: {TOKEN}

把标记的值(前面的输出中出现的 {TOKEN} 占位符)存起来,以便以后使用。

可以在联机 JWT 解码器中解码令牌,例如 jwt.ms 查看其内容,并显示它包含 appid 具有客户端应用 ID 的声明:

{
  "alg": "HS256",
  "typ": "JWT"
}.{
  "unique_name": "{USER}",
  "sub": "{USER}",
  "jti": "14ed7729",
  "appid": "{CLIENT ID}",
  "aud": [
    "https://localhost:7250",
    "http://localhost:7251"
  ],
  "nbf": 1780660887,
  "exp": 1788609687,
  "iat": 1780660888,
  "iss": "dotnet-user-jwts"
}.[Signature]

再次使用不正确的客户端 ID (appid) 值执行命令:

dotnet user-jwts create --claim appid=aaaabbbb-0000-cccc-1111-dddd2222eeee

将第二个令牌的值放在一边。

在 Visual Studio 中,或者在命令行 shell 中使用 Contoso.API 命令,启动 Contoso.Security.APIdotnet watch 两个项目:

dotnet watch

Contoso.API 项目(https://localhost:7250/swagger/index.html)的 Swagger UI 中,选择 Authorize 按钮。

“可用授权: Bearer ”窗口中,输入访问令牌。 选择“授权”按钮。 关闭 “可用授权” 窗口。

default 下,选择 终结点的 /WeatherForecast 按钮。 选择“试用”按钮。 选择“执行”按钮。

响应>服务器响应>响应正文下的输出显示了 Contoso.API 项目返回的天气预报 JSON。

对使用无效客户端应用 ID 生成的访问令牌执行相同的步骤。 响应为 403 - 禁止。

其他资源