新文章 网摘 文章 随笔 日记

JWT Bearer 认证和授权 for ASP.NET Core 5 (授权的干货)

介绍#

2020 年 11 月 10 日,Microsoft 发布了 .NET 5 和更新 ASP.NET Core 平台,其中包括一长串性能改进。

.NET 5 平台

在本文中,我们将介绍如何为使用 Core 5 构建的 API 配置 JWT 持有者身份验证和授权 ASP.NET。有很多资源涵盖了如何使用对称签名构建自己的“JWT身份验证”,但在本文中,我们将重点介绍利用OpenID连接和OAuth 2流(使用身份验证0/身份服务器/Okta/...),其中API是受保护的资源。让我们首先从高层次上看一下所有部分是如何组合在一起的。您构建的 API 通常由应用程序代表用户或代表用户自己调用。

用户与 SPA/移动应用程序/桌面应用程序/Web 应用程序/CLI/...并将使用OpenID连接(授权码授予)进行身份验证。授权服务器将发出 (由应用程序用于对用户进行身份验证) 和 应用程序用来代表用户调用 API 的 。id_tokenaccess_token

用户认证

当应用程序需要代表自己调用 API 时,它们将使用 OAuth 2.0 客户端凭据授予直接获取:access_token

客户端身份验证
配置 JWT 持有者身份验证#

我们将首先创建一个帮助器方法,该方法将使用包处理所有 JWT 承载配置.Microsoft.AspNetCore.Authentication.JwtBearer

JwtBearerConfiguration.cs
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;

public static class JwtBearerConfiguration
{
  public static AuthenticationBuilder AddJwtBearerConfiguration(this AuthenticationBuilder builder, string issuer, string audience)
  {
    return builder.AddJwtBearer(options =>
    {
      options.Authority = issuer;
      options.Audience = audience;
      options.TokenValidationParameters = new TokenValidationParameters()
      {
        ClockSkew = new System.TimeSpan(0, 0, 30)
      };
      options.Events = new JwtBearerEvents()
      {
        OnChallenge = context =>
        {
          context.HandleResponse();
          context.Response.StatusCode = StatusCodes.Status401Unauthorized;
          context.Response.ContentType = "application/json";

          // Ensure we always have an error and error description.
          if (string.IsNullOrEmpty(context.Error))
            context.Error = "invalid_token";
          if (string.IsNullOrEmpty(context.ErrorDescription))
            context.ErrorDescription = "This request requires a valid JWT access token to be provided";

          // Add some extra context for expired tokens.
          if (context.AuthenticateFailure != null && context.AuthenticateFailure.GetType() == typeof(SecurityTokenExpiredException))
          {
            var authenticationException = context.AuthenticateFailure as SecurityTokenExpiredException;
            context.Response.Headers.Add("x-token-expired", authenticationException.Expires.ToString("o"));
            context.ErrorDescription = $"The token expired on {authenticationException.Expires.ToString("o")}";
          }

          return context.Response.WriteAsync(JsonSerializer.Serialize(new
          {
            error = context.Error,
            error_description = context.ErrorDescription
          }));
        }
      };
    });
  }
}

在上面的代码中,我们使用以下命令配置该方法:AddJwtBearer

  • Authority:颁发者(例如:https://sandrino.auth0.com/)
  • Audience:通常是已在授权服务器上注册的 API 的标识符(例如:http://my-api)
  • ClockSkew:默认情况下,此值设置为 5 分钟,但在大多数情况下,30 秒应该没问题。这是为了处理授权服务器和 API 之间的任何时间差异。

我们还修改了任何 JWT 验证错误的响应,以返回 JSON 对象而不是标准质询。这是可选的,它为客户端提供了更多可用于处理错误的上下文。WWW-Authenticate

现在,可以使用帮助程序在类中注册身份验证服务:Startup

Startup.cs
public class Startup
{
  private readonly IConfiguration _configuration;

  public Startup(IConfiguration configuration)
  {
    _configuration = configuration;
  }

  public void ConfigureServices(IServiceCollection services)
  {
    services.AddControllers();

    // Configure JWT authentication.
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearerConfiguration(
_configuration["Jwt:Issuer"],
_configuration["Jwt:Audience"]
);
  }

  public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
  {
    app.UseRouting();
    app.UseAuthentication();
    app.UseAuthorization();
    app.UseEndpoints(endpoints =>
    {
      endpoints.MapControllers();
    });
  }
}

和 设置将被读取配置文件:Jwt.IssuerJwt.Audienceappsettings.json

appsettings.json
{
  "Jwt": {
    "Issuer": "https://sandrino-dev.auth0.com/",
    "Audience": "urn:my-api"
  },
  ...
}

就是这样,我们现在可以开始创建必要的API并保护它们。

创建受保护的接口#

让我们首先创建一个简单的 API,该 API 返回当前标识的声明。在操作中,我们将使用要求对 HTTP 请求进行身份验证的属性。Get[Authorize]

UserController.cs
public class UserInfo
{
  [JsonPropertyName("id")]
  public string Id { get; set; }

  [JsonPropertyName("claims")]
  public Dictionary<string, string> Claims { get; set; }
}

[ApiController]
[Route("/api/claims")]
public class UserController : ControllerBase
{
  [HttpGet]
[Authorize]
  public UserInfo Get()
  {
    return new UserInfo()
    {
      Id = this.User.GetId(),
      Claims = this.User.Claims.ToDictionary(claim => claim.Type, claim => claim.Value)
    };
  }
}

为了使用获取用户的标识符,我们可以编写一个小的帮助器类:GetId()

UserHelpers.cs
using System.Security.Claims;

public static class UserHelpers
{
  public static string GetId(this ClaimsPrincipal principal)
  {
    var userIdClaim = principal.FindFirst(c => c.Type == ClaimTypes.NameIdentifier) ?? principal.FindFirst(c => c.Type == "sub");
    if (userIdClaim != null && !string.IsNullOrEmpty(userIdClaim.Value))
    {
      return userIdClaim.Value;
    }

    return null;
  }
}

让我们继续并启动我们的 API。如果我们调用此终结点而不在标头中提供有效值,这将导致以下错误:access_tokenAuthorization

{
  "error": "invalid_token",
  "error_description": "This request requires a valid JWT access token to be provided"
}

现在,我们可以在标头中使用有效令牌尝试相同的请求:Authorization

curl \
  --request GET 'http://localhost:5001/api/claims' \
  --header 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InA0UVUtODVUY09GeG03c05JMWlaYyJ9.eyJpc3MiOiJodHRwczovL3NhbmRyaW5vLWRldi5hdXRoMC5jb20vIiwic3ViIjoiYXV0aDB8NTk3YTA2NTExM2Y0MGIwODQ4NWVlN2JkIiwiYXVkIjpbInVybjpteS1hcGkiLCJodHRwczovL3NhbmRyaW5vLWRldi5hdXRoMC5jb20vdXNlcmluZm8iXSwiaWF0IjoxNjA4Mjg2OTMxLCJleHAiOjE2MDgyOTY5MzEsImF6cCI6IllRd0Q0YTBBMTFreURJQzJPcVBLNnVDR3FHNEQ3cnVJIiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSBvZmZsaW5lX2FjY2VzcyIsImd0eSI6InBhc3N3b3JkIn0.l9dOVOXvnFhmMbUAelGiQJTwlCpgXqE6nbrdbTJhg1shxhMiGSuMg3YN3eFLD3-TfU8T5nHNttjgHdlIus-oQuJspYg4Mqu6NTIE0PxGnQQDYqADnXzpLV4OdFc2k1YuZwCpE8dJDJ0lzvXTsio3DKvWq_Vq3gL7qAWtF5EefKbsfTOaLhVPZ8YIcY8C0VSReJnC2M8da0KAdP0SqYJB_BIZYeQiPg668MrGFWsKuQv1h4C9DU3o9Ol0S1nHZ6r8KiiMSQRJyFV7v82VQ3dZWjrj5YWGGR4Uk1Wuf3iochLxRz64MQp-iV_fuE1DECLjKTt6Bj-nLR2PZFDTHAheCA'

然后,这将按预期返回用户的 ID 和声明:

{
  "id": "auth0|597a065113f40b08485ee7bd",
  "claims": [
    {
      "name": "iss",
      "value": "https://sandrino-dev.auth0.com/"
    },
    {
      "name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
      "value": "auth0|597a065113f40b08485ee7bd"
    },
    {
      "name": "aud",
      "value": "urn:my-api"
    },
    {
      "name": "aud",
      "value": "https://sandrino-dev.auth0.com/userinfo"
    },
    {
      "name": "iat",
      "value": "1608286931"
    },
    {
      "name": "exp",
      "value": "1608296931"
    },
    {
      "name": "azp",
      "value": "YQwD4a0A11kyDIC2OqPK6uCGqG4D7ruI"
    },
    {
      "name": "scope",
      "value": "openid profile offline_access"
    },
    {
      "name": "gty",
      "value": "password"
    }
  ]
}

请注意,您可以使用Auth0和失眠轻松测试上述内容。

刚刚发生了什么?#

你可能想知道刚刚发生了什么?!API 如何能够在不配置机密或公钥的情况下验证 JWT 持有者令牌?这是因为身份验证服务将使用 OIDC 元数据终端节点来获取所有必要的信息。

  1. 首先读取 OpenID 配置:https://sandrino-dev.auth0.com/.well-known/openid-configuration
  2. 从那里,它将找到该网址,然后加载该网址:https://sandrino-dev.auth0.com/.well-known/jwks.jsonjwks_uri
  3. 公钥从该文档加载,并用于验证传入的 JWT 持有者令牌
创建授权策略#

以上是创建安全 API 的一个好步骤,但它可能不够精细。并非每个人都有权访问 API 中公开的所有操作。这是您需要创建授权策略的位置,您可以在其中限制对某些操作的访问。

在我们的示例中,我们将创建一个终结点来查询计费设置,该设置仅适用于具有作用域的用户。在授权服务器中,您通常会配置只有属于特定组成员的用户,只有具有特定角色或权限的用户...可以接收此范围。但是,一旦应用程序可以代表用户请求该作用域,它将在 中可用,并且对此终结点的调用将成功。read:billing_settingsaccess_token

授权处理程序实现我们的业务需求。它将从当前主体中提取声明,然后验证配置的声明(例如:)是否可用。如果是,则允许请求继续。scoperead:billing_settings

RequireScopeHandler.cs
public class ScopeRequirement : IAuthorizationRequirement
{
  public string Issuer { get; }

  public string Scope { get; }

  public ScopeRequirement(string issuer, string scope)
  {
    Issuer = issuer ?? throw new ArgumentNullException(nameof(issuer));
    Scope = scope ?? throw new ArgumentNullException(nameof(scope));
  }
}

public class RequireScopeHandler : AuthorizationHandler<ScopeRequirement>
{
  protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ScopeRequirement requirement)
  {
    // The scope must have originated from our issuer.
    var scopeClaim = context.User.FindFirst(c => c.Type == "scope" && c.Issuer == requirement.Issuer);
    if (scopeClaim == null || String.IsNullOrEmpty(scopeClaim.Value))
      return Task.CompletedTask;

    // A token can contain multiple scopes and we need at least one exact match.
    if (scopeClaim.Value.Split(' ').Any(s => s == requirement.Scope))
      context.Succeed(requirement);
    return Task.CompletedTask;
  }
}

然后,我们应该为 API 支持的每个范围注册此策略,并注册处理程序:

Startup.cs
// Create an authorization policy for each scope supported by my API.
services.AddAuthorization(options =>
{
  var scopes = new[] {
    "read:billing_settings",
    "update:billing_settings",
    "read:customers",
    "read:files"
  };

  Array.ForEach(scopes, scope =>
options.AddPolicy(scope,
policy => policy.Requirements.Add(
new ScopeRequirement(_configuration["Jwt:Issuer"], scope)
)
)
  );
});

// Register our authorization handler.
services.AddSingleton<IAuthorizationHandler, RequireScopeHandler>();

对于每个作用域,我们使用该作用域的名称注册一个策略,以便稍后在代码中使用。[Authorize("read:billing_settings")]

作为最后一步,我们现在可以创建一个,在其中向用户公开必要的功能,以管理其计费设置。终结点需要存在作用域:BillingController/api/billing/settingsread:billing_settings

BillingController.cs
[ApiController]
[Route("/api/billing")]
public class BillingController : ControllerBase
{
  [HttpGet]
  [Route("settings")]
[Authorize("read:billing_settings")]
  public BillingSettings Get()
  {
    return new BillingSettings()
    {
      Country = "United States",
      State = "Washington",
      Street = "Microsoft Road 1",
      VATNumber = "987654321"
    };
  }
}

与我的授权服务器交互时,我需要确保请求此作用域:

https://sandrino-dev.auth0.com/authorize
  ?client_id=5CMfGLsLworduMTOfVD0Kap2IQm4xpLH
&scope=openid profile read:billing_settings
  &redirect_uri=https://jwt.io
  &response_type=code
  &audience=urn:my-api
  &...

然后,现在包含作用域的结果可用于调用终结点:access_tokenread:billing_settings

GET /api/billing/settings
Host: localhost:5001
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InA0UVUtODVUY09GeG03c05JMWlaYyJ9.eyJpc3MiOiJodHRwczovL3NhbmRyaW5vLWRldi5hdXRoMC5jb20vIiwic3ViIjoiYXV0aDB8NTk3YTA2NTExM2Y0MGIwODQ4NWVlN2JkIiwiYXVkIjpbInVybjpteS1hcGkiLCJodHRwczovL3NhbmRyaW5vLWRldi5hdXRoMC5jb20vdXNlcmluZm8iXSwiaWF0IjoxNjA4Mjg3MzY4LCJleHAiOjE2MDgyOTczNjgsImF6cCI6IllRd0Q0YTBBMTFreURJQzJPcVBLNnVDR3FHNEQ3cnVJIiwic2NvcGUiOiJvcGVuaWQgcHJvZmlsZSByZWFkOmJpbGxpbmdfc2V0dGluZ3Mgb2ZmbGluZV9hY2Nlc3MiLCJndHkiOiJwYXNzd29yZCJ9.CMvAa4gVO5dFnXBqWK8UIq5mB--3JacVv9MwocnWFTSR5p938zhw5hMREqUGCesIWy5UUZeb7ka7Dhp4Mf3tK-8h3psfsPMMpI3OP4q8IglKplt1KaXe5rn8Fmm2daNDnxmMccXusLI7T_Ea3hfVrjrfURprNfXW9vCS17Xj6mRHF9RBHNkeg8CKyotatPQojY_uex2L3qBhJhGXBd8CHvnnbEVZMYVlc_D02tqMu4bvs9QCml8y3qQkyvBHOAEJcE7b84trIJK2vIh7B339l-ukeSyK1AEkf5hHAlUjGRuB1dhtfodWLexEd5rH-Tn55xwdvL2CyQI-J2JVIQS0Kw

{
  "country": "United States",
  "state": "Washington",
  "street": "Microsoft Road 1",
  "vat_number": "987654321"
}

在没有所需作用域的情况下调用此终结点将导致“403 禁止访问”。read:billing_settings

身份验证0 基于角色的访问控制#

如果使用 Auth0 作为授权服务器,则可以为 API 配置“RBAC 授权策略”:

RBAC

这会将对 API 上定义的作用域的访问权限限制为分配了所需角色或权限的用户。

Scopes

现在,我们可以创建一个角色计费管理员,我们将在其中添加权限:read:billing_settings

Scopes

作为最后一步,我们可以将角色分配给我们的用户,允许应用程序请求他们的范围。read:billing_settings

✅ 成功!#

完成上述所有操作后,您应该都可以在自己的 API 中配置 JWT 持有者身份验证和授权。

完整的演示应用程序可在 GitHub 上使用https://github.com/sandrinodimattia/aspnet-core-5-jwt-bearer-demo

JWT Bearer Authentication and Authorization for ASP.NET Core 5 – sandrino.dev

posted @ 2022-10-22 15:41  岭南春  阅读(1092)  评论(0)    收藏  举报