教程:保护外部租户中注册的 ASP.NET Core Web API

本教程系列演示如何保护在外部租户中注册的 Web API。 在本教程中,你将生成一个 ASP.NET Core Web API,用于发布委托的权限(作用域)和应用程序权限(应用角色)。

在本教程中;

  • 配置 Web API 以使用它的应用注册详细信息
  • 将 Web API 配置为使用在应用注册中注册的委托权限和应用程序权限
  • 保护 Web API 终结点

先决条件

创建 ASP.NET Core Web API

  1. 打开终端,然后导航到希望存放项目的文件夹。

  2. 运行以下命令:

    dotnet new webapi -o ToDoListAPI
    cd ToDoListAPI
    
  3. 当对话框询问是否要将所需资产添加到项目时,选择“是”。

安装包

安装以下包:

  • Microsoft.EntityFrameworkCore.InMemory 允许将 Entity Framework Core 和内存数据库一起使用。 它不适合用于生产环境。
  • Microsoft.Identity.Web 可简化向与 Microsoft 标识平台集成的 Web 应用和 Web API 添加身份验证和授权支持的过程。
dotnet add package Microsoft.EntityFrameworkCore.InMemory
dotnet add package Microsoft.Identity.Web

配置应用注册详细信息

打开应用文件夹中的 appsettings.json 文件,并在注册 Web API 后添加到所记录的应用注册详细信息中。

{
    "AzureAd": {
        "Instance": "https://Enter_the_Tenant_Subdomain_Here.ciamlogin.com/",
        "TenantId": "Enter_the_Tenant_Id_Here",
        "ClientId": "Enter_the_Application_Id_Here",
    },
    "Logging": {...},
  "AllowedHosts": "*"
}

如下所示,替换以下占位符:

  • Enter_the_Application_Id_Here 替换为应用程序(客户端)ID。
  • Enter_the_Tenant_Id_Here 替换为目录(租户)ID。
  • Enter_the_Tenant_Subdomain_Here 替换为目录(租户)子域。

添加应用角色和范围

所有 API 必须至少发布一个范围(也称为委托的权限),以便客户端应用成功获取用户的访问令牌。 API 还应为应用程序至少发布一个应用角色(也称为应用程序权限),以便客户端应用以自己的身份获取访问令牌(即在它们未登录用户时)。

我们会在 appsettings.json 文件中指定这些权限。 在本教程中,我们注册了四个权限。 ToDoList.ReadWrite、ToDoList.Read、ToDoList.ReadWrite.All 和 ToDoList.Read.All,前两个为委托的权限,后两个为应用程序权限。

{
  "AzureAd": {
    "Instance": "https://Enter_the_Tenant_Subdomain_Here.ciamlogin.com/",
    "TenantId": "Enter_the_Tenant_Id_Here",
    "ClientId": "Enter_the_Application_Id_Here",
    "Scopes": {
      "Read": ["ToDoList.Read", "ToDoList.ReadWrite"],
      "Write": ["ToDoList.ReadWrite"]
    },
    "AppPermissions": {
      "Read": ["ToDoList.Read.All", "ToDoList.ReadWrite.All"],
      "Write": ["ToDoList.ReadWrite.All"]
    }
  },
  "Logging": {...},
  "AllowedHosts": "*"
}

添加身份验证方案

身份验证方案是在身份验证期间配置身份验证服务时命名的。 在本文中,我们使用 JWT 持有者身份验证方案。 在 Programs.cs 文件中添加以下代码以添加身份验证方案。

// Add the following to your imports
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;

// Add authentication scheme
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApi(builder.Configuration);

创建模型

在项目的根文件夹中创建名为“Models”的文件夹。 导航到该文件夹并创建名为 ToDo.cs 的文件,然后添加以下代码。 此代码创建一个名为 ToDo 的模型。

using System;

namespace ToDoListAPI.Models;

public class ToDo
{
    public int Id { get; set; }
    public Guid Owner { get; set; }
    public string Description { get; set; } = string.Empty;
}

添加数据库上下文

数据库上下文是为数据模型协调 Entity Framework 功能的主类。 此类可通过从 Microsoft.EntityFrameworkCore.DbContext 类派生进行创建。 在本教程中,我们将使用内存中数据库进行测试。

  1. 在项目的根文件夹中,创建一个名为“DbContext”的文件夹。

  2. 导航到该文件夹中,并创建名为 ToDoContext.cs 的文件,然后将以下内容添加到该文件:

    using Microsoft.EntityFrameworkCore;
    using ToDoListAPI.Models;
    
    namespace ToDoListAPI.Context;
    
    public class ToDoContext : DbContext
    {
        public ToDoContext(DbContextOptions<ToDoContext> options) : base(options)
        {
        }
    
        public DbSet<ToDo> ToDos { get; set; }
    }
    
  3. 打开应用根文件夹中的 Program.cs 文件,然后在文件中添加以下代码。 此代码会将名为 ToDoContextDbContext 子类注册为 ASP.NET Core 应用程序服务提供程序(也称为依赖项注入容器)中已限定范围的服务。 配置上下文,以使用内存中数据库。

    // Add the following to your imports
    using ToDoListAPI.Context;
    using Microsoft.EntityFrameworkCore;
    
    builder.Services.AddDbContext<ToDoContext>(opt =>
        opt.UseInMemoryDatabase("ToDos"));
    

添加控制器

在大多数情况下,控制器可以执行多个操作。 通常包括创建读取更新删除 (CRUD) 操作。 在本教程中,我们仅创建两个操作项。 它们分别是读取所有操作项和创建操作项,用于演示如何保护终结点。

  1. 导航到项目根文件夹中的 Controllers 文件夹。

  2. 在此文件夹中创建名为 ToDoListController.cs 的文件。 打开该文件,然后添加以下模板代码:

    using Microsoft.AspNetCore.Authorization;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.EntityFrameworkCore;
    using Microsoft.Identity.Web;
    using Microsoft.Identity.Web.Resource;
    using ToDoListAPI.Models;
    using ToDoListAPI.Context;
    
    namespace ToDoListAPI.Controllers;
    
    [Authorize]
    [Route("api/[controller]")]
    [ApiController]
    public class ToDoListController : ControllerBase
    {
        private readonly ToDoContext _toDoContext;
    
        public ToDoListController(ToDoContext toDoContext)
        {
            _toDoContext = toDoContext;
        }
    
        [HttpGet()]
        [RequiredScopeOrAppPermission()]
        public async Task<IActionResult> GetAsync(){...}
    
        [HttpPost]
        [RequiredScopeOrAppPermission()]
        public async Task<IActionResult> PostAsync([FromBody] ToDo toDo){...}
    
        private bool RequestCanAccessToDo(Guid userId){...}
    
        private Guid GetUserId(){...}
    
        private bool IsAppMakingRequest(){...}
    }
    

将代码添加到控制器

在本部分中,我们将代码添加到所创建的占位符。 此处的重点不是生成 API,而是保护 API。

  1. 导入必需包。 Microsoft.Identity.Web 包是一种 MSAL 包装器,可帮助我们轻松处理身份验证逻辑,例如,通过处理令牌验证。 为了确保终结点需要授权,我们使用内置的 Microsoft.AspNetCore.Authorization 包。

  2. 由于我们授予的权限,允许此 API 使用代表用户的委托的权限,或使用客户端自身调用的应用程序权限(而不是代表用户的应用程序权限)进行调用,因此重要的是需要确认调用是否由应用本身触发。 执行此操作的最简便方法对发现访问令牌是否包含 idtyp 可选声明进行声明。 此 idtyp 声明是 API 确定令牌是应用令牌还是应用 + 用户令牌的最简便方法。 我们建议启用 idtyp 可选声明。

    如果未启用声明 idtyp,则可以使用 rolesscp 声明来确定访问令牌是应用令牌还是应用 + 用户令牌。 由 Microsoft Entra 外部 ID 颁发的访问令牌至少有两个声明中的一个。 颁发给用户的访问令牌具有 scp 声明。 颁发给应用程序的访问令牌具有 roles 声明。 同时包含两个声明的访问令牌仅颁发给用户,其中 scp 声明用于指定委托的权限,而 roles 声明用于指定用户的角色。 两个声明均未包含的访问令牌将不会得到遵循。

    private bool IsAppMakingRequest()
    {
        if (HttpContext.User.Claims.Any(c => c.Type == "idtyp"))
        {
            return HttpContext.User.Claims.Any(c => c.Type == "idtyp" && c.Value == "app");
        }
        else
        {
            return HttpContext.User.Claims.Any(c => c.Type == "roles") && !HttpContext.User.Claims.Any(c => c.Type == "scp");
        }
    }
    
  3. 添加一个帮助程序函数,用于确定所发出的请求是否包含执行预期操作所需的足够权限。 请检查应用是代表自己发出请求,还是代表拥有给定资源的用户通过验证用户 ID 来执行调用。

    private bool RequestCanAccessToDo(Guid userId)
        {
            return IsAppMakingRequest() || (userId == GetUserId());
        }
    
    private Guid GetUserId()
        {
            Guid userId;
            if (!Guid.TryParse(HttpContext.User.GetObjectId(), out userId))
            {
                throw new Exception("User ID is not valid.");
            }
            return userId;
        }
    
  4. 插入权限定义以保护路由。 通过将 [Authorize] 属性添加到控制器类来保护 API。 此行为可确保仅当使用已授权的标识调用 API 时,才能调用控制器操作。 权限定义定义执行这些操作所需的权限类型。

    [Authorize]
    [Route("api/[controller]")]
    [ApiController]
    public class ToDoListController: ControllerBase{...}
    

    添加可以 GET 所有终结点和 POST 终结点的权限。 为此,请使用属于 Microsoft.Identity.Web.Resource 命名空间的 RequiredScopeOrAppPermission 方法。 然后,通过 RequiredScopesConfigurationKeyRequiredAppPermissionsConfigurationKey 属性将范围和权限传递给此方法。

    [HttpGet]
    [RequiredScopeOrAppPermission(
        RequiredScopesConfigurationKey = "AzureAD:Scopes:Read",
        RequiredAppPermissionsConfigurationKey = "AzureAD:AppPermissions:Read"
    )]
    public async Task<IActionResult> GetAsync()
    {
        var toDos = await _toDoContext.ToDos!
            .Where(td => RequestCanAccessToDo(td.Owner))
            .ToListAsync();
    
        return Ok(toDos);
    }
    
    [HttpPost]
    [RequiredScopeOrAppPermission(
        RequiredScopesConfigurationKey = "AzureAD:Scopes:Write",
        RequiredAppPermissionsConfigurationKey = "AzureAD:AppPermissions:Write"
    )]
    public async Task<IActionResult> PostAsync([FromBody] ToDo toDo)
    {
        // Only let applications with global to-do access set the user ID or to-do's
        var ownerIdOfTodo = IsAppMakingRequest() ? toDo.Owner : GetUserId();
    
        var newToDo = new ToDo()
        {
            Owner = ownerIdOfTodo,
            Description = toDo.Description
        };
    
        await _toDoContext.ToDos!.AddAsync(newToDo);
        await _toDoContext.SaveChangesAsync();
    
        return Created($"/todo/{newToDo!.Id}", newToDo);
    }
    

运行 API

使用 dotnet run 命令运行 API,确保其运行良好,不会出现任何错误。 如果打算在测试期间使用 HTTPS 协议,则需要信任 .NET 的开发证书

有关此 API 代码的完整示例,请参阅示例文件

下一步