十二、验证 API 客户端

在这一章中,我将解释如何通过一个简单的 JavaScript 应用使用 ASP.NET Core Identity 认证 API 客户端。一些项目可以依赖标准的 ASP.NET Core 用户认证来为他们的客户服务,我将演示这是如何工作的。对于其他项目,我解释了如何使用 cookie 和使用不记名令牌来提供认证,这对不支持 cookie 的客户端很有用。表 12-1 将 API 认证放在上下文中。

表 12-1。

将 API 认证放在上下文中

|

问题

|

回答

| | --- | --- | | 这是什么? | API 认证限制对提供直接数据访问的控制器的访问,而不是通过 HTML 内容。这些控制器通常支持 RESTful web 服务。 | | 为什么有用? | 许多 web 服务提供不应公开访问的数据或支持操作。通过要求对 API 请求进行认证来限制访问允许定义有效的授权策略。 | | 如何使用? | 使用Authorize属性限制对 API 控制器的访问。对请求进行认证的方式因应用支持的客户端而异。 | | 有什么陷阱或限制吗? | 没有明确定义的标准来描述 web 服务应该如何设计或授权,当您需要支持一系列第三方客户端时,这可能是一个问题。 | | 还有其他选择吗? | 不是所有的应用都支持 API,但是当它们被支持时,就需要某种认证。 |

12-2 总结了本章内容。

表 12-2。

章节总结

|

问题

|

解决办法

|

列表

| | --- | --- | --- | | 对 ASP.NET Core 服务器交付的 JavaScript API 客户端进行认证 | 依靠用户登录应用时创建的标准认证 cookie。 | 18 | | 向 API 客户端返回状态代码响应 | 定义一个处理程序,覆盖质询和禁止响应的默认行为。 | 913 | | 使用 cookie 验证 API 客户端 | 创建一个认证控制器,并使用登录管理器的方法将用户登录到应用中,并返回一个 JSON 响应。确保 JavaScript 客户端在后续请求中包含 cookie。 | 1419 | | 在没有 cookie 的情况下验证 API 客户端 | 使用承载令牌,客户端可以在后续请求中提供该令牌。 | 2025 |

为本章做准备

本章使用第 11 章中的 IdentityApp 项目。为了演示 API 客户机认证,我需要设置一个 API 控制器。将名为ValuesController.cs的类文件添加到Controllers文件夹中,代码如清单 12-1 所示。

Tip

你可以从 https://github.com/Apress/pro-asp.net-core-identity 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第 1 章获取帮助。

using IdentityApp.Models;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;

namespace IdentityApp.Controllers {

    [ApiController]
    [Route("/api/data")]
    public class ValuesController : ControllerBase {
        private ProductDbContext DbContext;

        public ValuesController(ProductDbContext dbContext) {
            DbContext = dbContext;
        }

        [HttpGet]
        public IAsyncEnumerable<Product> GetProducts() => DbContext.Products;

        [HttpPost]
        public async Task<IActionResult> CreateProduct([FromBody]
                ProductBindingTarget target) {
            if (ModelState.IsValid) {
                Product product = new Product {
                    Name = target.Name, Price = target.Price,
                        Category = target.Category
                };
                await DbContext.AddAsync(product);
                await DbContext.SaveChangesAsync();
                return Ok(product);
            }
            return BadRequest(ModelState);
        }

        [HttpDelete("{id}")]
        public Task DeleteProduct(long id) {
            DbContext.Products.Remove(new Product { Id = id });
            return DbContext.SaveChangesAsync();
        }
    }

    public class ProductBindingTarget {
        [Required]
        public string Name { get; set; }

        [Required]
        public decimal Price { get; set; }

        [Required]
        public string Category { get; set; }
    }
}

Listing 12-1.The ValuesController.cs File in the Controllers Folder

这是一个基本的 API 控制器,提供对产品数据库中的数据的访问,到目前为止只用于在 MVC 视图和 Razor 页面中生成内容。本章我不需要全套的 API 函数,因为重点是认证。有检索数据库中所有产品、从数据库中删除产品和向数据库中添加产品的操作。ProductBindingTarget类用于确保模型绑定过程只使用由Product类定义的选定属性。

创建 JavaScript API 客户端

对于这一章,我使用了一个纯 JavaScript 客户端,这将使我能够专注于认证过程,而不会受到大型框架的干扰。创建wwwroot/js文件夹,并在其中添加一个名为network.js的 JavaScript 文件,内容如清单 12-2 所示。

Note

我在我的书中描述了如何在 Angular 和 React 中处理无记名令牌,这些框架分别是 Pro AngularPro React

const baseUrl = "https://localhost:44350/api/data";

export const loadData = async function (callback, errorHandler) {
    const response = await fetch(baseUrl, {
        redirect: "manual"
    });
    processResponse(response, async () => callback(await response.json()),
        errorHandler);
}

export const createProduct = async function (product, callback, errorHandler) {
    const response = await fetch(baseUrl, {
        method: "POST",
        body: JSON.stringify(product),
        headers: {
            "Content-Type": "application/json"
        }
    });
    processResponse(response, callback, errorHandler);
}

export const deleteProduct = async function (id, callback, errorHandler) {
    const response = await fetch(`${baseUrl}/${id}`, {
        method: "DELETE"
    });
    processResponse(response, callback, errorHandler);
}

function processResponse(response, callback, errorHandler) {
    if (response.ok) {
        callback();
    } else {
        errorHandler(response.status);
    }
}

Listing 12-2.The Contents of the network.js File in the wwwroot/js Folder

这个文件中定义的函数负责向 API 控制器发送 HTTP 请求。我使用了 Fetch API,它受到现代浏览器的支持,为传统的XmlHttpRequest对象提供了一个更有用的替代方案。

用清单 12-3 所示的代码向wwwroot/js文件夹添加一个名为client.js的 JavaScript 文件。

import * as network from "./network.js";

const columns = ["ID", "Name", "Category", "Price"];
let tableBody;
let errorElem;

HTMLElement.prototype.make = function (...types) {
    return types.reduce((lastElem, elemType) =>
        lastElem.appendChild(document.createElement(elemType)), this);
}

function showError(err) {
    errorElem.innerText = `Error: ${err}`;
    errorElem.classList.add("m-2", "p-2");
}

function clearError(err) {
    errorElem.innerText = "";
    errorElem.classList.remove("m-2", "p-2");
}

function createStructure() {
    const targetElement = document.getElementById("target");
    targetElement.innerHTML = "";
    errorElem = targetElement.make("div");
    errorElem.classList.add("h6", "bg-danger", "text-center", "text-white");
    return targetElement;
}

function createContent() {
    const targetElement = createStructure();
    const table = targetElement.make("table");
    table.classList.add("table", "table-sm", "table-striped", "table-bordered");
    const headerRow = table.make("thead", "tr");
    columns.concat([""]).forEach(col => {
        const th = headerRow.make("th");
        th.innerText = col;
    });
    tableBody = table.make("tbody");
    const footerRow = table.make("tfoot", "tr");
    footerRow.make("td");
    columns.filter(col => col != "ID").forEach(col => {
        const input = footerRow.make("td", "input");
        input.name = input.id = col;
        input.placeholder = `Enter ${col.toLowerCase()}`;
    });
    const button = footerRow.make("td", "button");
    button.classList = "btn btn-sm btn-success";
    button.innerText = "Add";
    button.addEventListener("click", async () => {
        const product = {};
        columns.forEach(col => product[col] = document.getElementById(col)?.value);
        await network.createProduct(product, populateTable, showError);
    });
}

function createTableContents(products) {
    tableBody.innerHTML = "";
    products.forEach(p => {
        const row = tableBody.appendChild(document.createElement("tr"));
        columns.forEach(col => {
            const cell = row.appendChild(document.createElement("td"));
            cell.innerText = p[col.toLowerCase()];
        });
        const button = row.appendChild(document.createElement("td")
            .appendChild(document.createElement("button")));
        button.classList.add("btn", "btn-sm", "btn-danger");
        button.textContent = "Delete";
        button.addEventListener("click", async () =>
            await network.deleteProduct(p.id, populateTable, showError));
    });
}

async function populateTable(products) {
    clearError();
    await network.loadData(createTableContents, showError);
}

document.addEventListener("DOMContentLoaded", () => {
    createContent();
    populateTable();
})

Listing 12-3.The Contents of the client.js File in the wwwroot/js Folder

通常,我编写的代码很容易理解,但这段 JavaScript 代码并非如此,它使用浏览器的域对象模型(DOM) API 来创建一个简单的表,表中填充了来自 API 控制器的数据。这不是一本关于 JavaScript 的书,我也不描述它是如何工作的,因为本章重要的部分都包含在network.js文件中。

为了将 JavaScript 代码交付给浏览器,向Pages文件夹添加一个名为JSClient.cshtml的 Razor 页面,其内容如清单 12-4 所示。

@page

<div id="target" class="m-3">
    Loading JavaScript client...
</div>
<script type="module" src="/js/client.js"></script>

Listing 12-4.The Contents of the JSClient.cshtml File in the Pages Folder

div元素将用于显示 JavaScript 代码生成的内容,这些内容由script元素指定。

打开一个新的 PowerShell 命令提示符,并运行清单 12-5 中所示的命令来重置应用和 Identity 数据库。

dotnet ef database drop --force --context ProductDbContext
dotnet ef database drop --force --context IdentityDbContext
dotnet ef database update --context ProductDbContext
dotnet ef database update --context IdentityDbContext

Listing 12-5.Resetting the Databases

使用 PowerShell 提示符运行清单 12-6 中的命令来启动应用。

dotnet run

Listing 12-6.Running the Example Application

打开网络浏览器,请求https://localhost:44350/Identity/Admin,并使用mysecret作为密码以admin@example.com的 Identity 登录。登录后,您将被重定向到管理仪表板。点击种子数据库,这将更新仪表板以指示商店中有四个用户,如图 12-1 所示。

img/508695_1_En_12_Fig1_HTML.jpg

图 12-1。

运行示例应用

请求https://localhost:44350/jsclient,你会看到 JavaScript 客户端的输出,如图 12-2 所示。

img/508695_1_En_12_Fig2_HTML.jpg

图 12-2。

JavaScript API 客户端

对 JavaScript 客户端使用简单认证

限制对 API 控制器的访问的最简单的方法是依靠前面章节中使用的标准 ASP.NET Core 和 Identity 特性。这种方法有一些缺点,我很快会解释,但它的优点是允许 JavaScript 客户机受益于认证和授权,而不需要直接处理任何细节。

为了演示,清单 12-7Authorize属性修饰了 API 控制器,这将只允许登录用户访问 web 服务控制器定义的动作。

using IdentityApp.Models;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;

namespace IdentityApp.Controllers {

    [Authorize]
    [ApiController]
    [Route("/api/data")]
    public class ValuesController : ControllerBase {
        private ProductDbContext DbContext;

        // ...methods omitted for brevity...
    }

    public class ProductBindingTarget {
        [Required]
        public string Name { get; set; }

        [Required]
        public decimal Price { get; set; }

        [Required]
        public string Category { get; set; }
    }
}

Listing 12-7.Restricting Access in the ValuesController.cs File in the Controllers Folder

这种技术要求对包含 JavaScript 代码的script元素的 Razor 页面或动作方法应用相同的限制,如清单 12-8 所示。

@page
@model IdentityApp.Pages.JSClientModel
@attribute [Microsoft.AspNetCore.Authorization.Authorize]

<div id="target" class="m-3">
    Loading JavaScript client...
</div>

<script type="module" src="/js/client.js"></script>

Listing 12-8.Restricting Access in the JSClient.cshtml File in the Pages Folder

重启 ASP.NET Core 并请求https://localhost:44350/jsclient。使用密码mysecretalice@example.comIdentity 登录,你将被重定向到包含 JavaScript 代码的页面,该页面将从 API 控制器请求数据,如图 12-3 所示。

img/508695_1_En_12_Fig3_HTML.jpg

图 12-3。

使用标准认证 cookie 进行 API 授权

当用户登录时,ASP.NET Core 将一个 cookie 添加到包含在未来请求中的响应中,包括由 JavaScript 代码发出的请求。ASP.NET Core 不区分用户直接发起的请求和 JavaScript 代码发起的请求,并以相同的方式对它们进行认证和授权。

这种方法几乎不费吹灰之力就能实现,但它只能用于使用 ASP.NET Core 交付的 JavaScript 应用,并期望为它们处理认证。这种方法不适用于希望直接处理认证的 JavaScript 客户机,因为登录应用的唯一方式是提交一个由 Razor 页面呈现的表单。您可以编写一个提交表单数据的 JavaScript 客户机,但是创建一个认证 API 更容易,我将在本章的后面演示。

对 API 客户机使用标准认证和授权并不像看起来那么可怕。它适用于应用的 JavaScript 和 ASP.NET Core 部分并行开发的项目,并且您知道 JavaScript 客户端将始终能够依赖于 ASP.NET Core cookie。尽管有它的局限性,这个场景覆盖了很多项目,你不应该轻易放弃它。任何不需要任何额外努力就可以交付项目需求的情况都被视为成功。

为 API 客户端返回状态代码响应

API 客户端通常希望 web 服务遵循表述性状态转移(REST)的广泛原则,并使用 HTTP 状态代码来指示操作的结果。这不是 ASP.NET Core 的默认做法,因为它希望向用户呈现有意义的 HTML 内容,甚至——或者特别是——当出现问题时。

要查看这导致的问题,请打开 PowerShell 命令提示符,运行清单 12-9 中所示的命令,向 web 服务发送 HTTP 请求。

Invoke-WebRequest -Uri https://localhost:44350/api/data

Listing 12-9.Sending an HTTP request

这个命令使用与 JavaScript 客户机相同的 URL 向 API 控制器发送 HTTP GET 请求。该命令发送的请求不包含认证 cookie,因此 ASP.NET Core 会生成一个质询响应。但是,因为 ASP.NET Core 希望向用户呈现内容,所以质询响应被拦截并替换为重定向,指示浏览器请求登录页面,这可以在命令的输出中看到。

...
StatusCode        : 200
StatusDescription : OK
Content           :
                    <!DOCTYPE html>
                    <html>
                    <head>
                        <meta name="viewport" content="width=device-width" />
                        <title>Identity App</title>
                        <link href="/lib/twitter-bootstrap/css/bootstrap.min.css"
...

结果不一致,取决于发出请求的客户端。一些客户端遵循重定向并以 200 OK 响应结束,该响应包含登录速度的 HTML 这就是清单 12-9 中的命令所发生的情况。期待 HTTP 状态代码响应的客户端会认为请求成功了,因为它们收到了 200 状态代码。这种类型的客户机经常试图将 HTML 响应解析为 JSON 数据,并遇到错误。

其他客户端不会遵循重定向,但同样会被卡住,因为它们没有办法获得所需的数据,并且重定向响应没有提供关于如何解决问题的任何信息。

更糟糕的是,一些请求会收到状态码响应。使用 PowerShell 命令提示符运行清单 12-10 中所示的命令。确保整个命令在一行中。

Invoke-WebRequest -Uri https://localhost:44350/api/data -Headers @{"X-Requested-With"="XMLHttpRequest"}

Listing 12-10.Sending an HTTP Request with a Header

这个命令向同一个 URL 发送 GET 请求,但是添加了一个名为X-Requested-With的头,头的值为XMLHttpRequest。来自 ASP.NET Core 的响应是 401 状态代码响应,如下所示:

...
Invoke-WebRequest : The remote server returned an error: (401) Unauthorized.
...

XMLHttpRequest头值指的是同名的 JavaScript 对象,这是客户端应用中发出 HTTP 请求的传统方式。一些浏览器和 JavaScript 框架将X-Requested-With头设置为XMLHttpRequest,以帮助服务器识别用 JavaScript 代码发出的 HTTP 请求。不幸的是,你不能依赖于这个头的设置,因为 Fetch API 已经被引入作为XmlHttpRequest对象的现代替代物(Fetch 是我在示例 JavaScript 客户端中使用的 API ),并且因为不是所有的浏览器和框架都设置头,即使在使用XmlHttpRequest的时候。

为了改变示例应用处理 API 客户端的方式,将清单 12-11 中所示的语句添加到Startup类中。(此语句调用的方法尚未定义,因此您将在代码编辑器中看到一个错误。)

...
services.ConfigureApplicationCookie(opts => {
    opts.LoginPath = "/Identity/SignIn";
    opts.LogoutPath = "/Identity/SignOut";
    opts.AccessDeniedPath = "/Identity/Forbidden";
    opts.Events.DisableRedirectionForApiClients();
});
...

Listing 12-11.Handling API Clients in the Startup.cs File in the IdentityApp Folder

CookieAuthenticationOptions类定义了一个Events属性,该属性返回一个CookieAuthenticationEvents对象,该对象允许在认证和授权期间为关键事件定义处理程序。处理 API 客户端有四个属性,如表 12-3 所述。

表 12-3。

API 客户端的 CookieAuthenticationEvents 事件处理程序属性

|

名字

|

描述

| | --- | --- | | OnRedirectToLogin | 此属性被分配了一个处理程序,当用户应该被重定向到登录 URL 时,将调用该处理程序。默认处理程序为带有X-Requested-With头的请求发送一个状态代码响应,并为所有其他请求执行重定向。 | | OnRedirectToAccessDenied | 此属性被分配了一个处理程序,当用户应该被重定向到拒绝访问的 URL 时,将调用该处理程序。默认处理程序为带有X-Requested-With头的请求发送一个状态代码响应,并为所有其他请求执行重定向。 | | OnRedirectToLogout | 此属性被分配了一个处理程序,当用户应该被重定向到注销 URL 时,将调用该处理程序。默认的处理程序发送一个 200 OK 响应,并为注销 URL 设置一个Location头,并为所有其他请求执行重定向。 | | OnRedirectToReturnUrl | 此属性被分配一个处理程序,当用户应该被重定向到触发质询响应的 URL 时,将调用该处理程序。默认的处理程序发送一个 200 OK 响应,并为注销 URL 设置一个Location头,并为所有其他请求执行重定向。 |

分配给表 12-3 中描述的属性的处理函数接收一个RedirectContext<CookieAuthenticationOptions>类的实例,其最有用的属性在表 12-4 中描述。

表 12-4。

有用的 RedirectContext 属性

|

名字

|

描述

| | --- | --- | | HttpContext | 该属性返回当前请求的HttpContext对象。 | | Request | 该属性返回描述请求的HttpRequest对象。 | | Response | 该属性返回描述响应的HttpRequest对象。 | | RedirectUri | 此属性返回用户应被定向到的 URL。 |

为了定义我在清单 12-11 中调用的扩展方法,将名为CookieAuthEventsExtensions.cs的类文件添加到IdentityApp文件夹中,并添加清单 12-12 中所示的代码。

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using System.Threading.Tasks;

namespace IdentityApp {

    public static class CookieAuthEventsExtensions {

        public static void DisableRedirectionForApiClients(this
                CookieAuthenticationEvents events) {
            events.OnRedirectToLogin = ctx =>
                SelectiveRedirect(ctx, StatusCodes.Status401Unauthorized);
            events.OnRedirectToAccessDenied = ctx =>
                SelectiveRedirect(ctx, StatusCodes.Status403Forbidden);
            events.OnRedirectToLogout = ctx =>
                SelectiveRedirect(ctx, StatusCodes.Status200OK);
            events.OnRedirectToReturnUrl= ctx =>
                SelectiveRedirect(ctx, StatusCodes.Status200OK);
        }

        private static Task SelectiveRedirect(
                RedirectContext<CookieAuthenticationOptions> context, int code) {
            if (IsApiRequest(context.Request)) {
                context.Response.StatusCode = code;
                context.Response.Headers["Location"] = context.RedirectUri;
            } else {
                context.Response.Redirect(context.RedirectUri);
            }
            return Task.CompletedTask;
        }

        private static bool IsApiRequest(HttpRequest request) {
            return request.Path.StartsWithSegments("/api");
        }
    }
}

Listing 12-12.The Contents of the CookieAuthEventsExtensions.cs File in the IdentityApp Folder

DisableRedirectionForApiClients方法将新的处理函数分配给表 12-3 中定义的四个属性。每个处理程序的结果都是相同的,这样 API 客户机被发送一个带有Location头的状态代码响应,而其他客户机接收重定向。Location头为 API 客户机提供了重定向 URL,而不强迫它们请求它的内容。

区别在于 API 客户端的识别方式,这是通过IsApiRequest方法完成的。不是寻找X-Requested-With头,而是检查请求路径,如果它以/api开始,那么客户端将得到状态码响应。这是一种方法上的转变,从试图确定请求是如何发起的到确定请求是为了什么:如果请求是针对一个/api URL 的,那么客户机被认为想要状态代码响应。

重启 ASP.NET Core 并使用 PowerShell 提示符运行清单 12-13 中所示的命令。

Invoke-WebRequest -Uri https://localhost:44350/api/data

Listing 12-13.Sending a Request

该请求不会被授权,因为它不包含 ASP.NET Core cookie,但是清单 12-12 中的代码检测到对以/api开头的 URL 的请求,并发送 401 响应。

...
Invoke-WebRequest : The remote server returned an error: (401) Unauthorized.
...

直接验证 API 客户端

并不是所有的 API 客户端都可以通过认证的 Razor 页面或视图交付,并且必须直接负责处理认证。

为直接 API 客户端认证准备 ASP.NET Core

将名为ApiAuthController.cs的类文件添加到Controllers文件夹中,并使用它来创建清单 12-14 中所示的控制器。

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using SignInResult = Microsoft.AspNetCore.Identity.SignInResult;

namespace IdentityApp.Controllers {

    [ApiController]
    [Route("/api/auth")]
    public class ApiAuthController: ControllerBase {
        private SignInManager<IdentityUser> SignInManager;

        public ApiAuthController(SignInManager<IdentityUser> signMgr) {
            SignInManager = signMgr;
        }

        [HttpPost("signin")]
        public async Task<object> ApiSignIn(
                [FromBody] SignInCredentials creds) {
            SignInResult result = await SignInManager.PasswordSignInAsync(
                creds.Email, creds.Password, true, true);
            return new { success = result.Succeeded  };
        }

        [HttpPost("signout")]
        public async Task<IActionResult> ApiSignOut() {
            await SignInManager.SignOutAsync();
            return Ok();
        }
    }

    public class SignInCredentials {
        [Required]
        public string Email { get; set; }
        [Required]
        public string Password { get; set; }
    }
}

Listing 12-14.The Contents of the ApiAuthController.cs File in the Controllers Folder

有两个动作方法,ApiSignInApiSignOut,它们都处理 POST 请求。ApiSignIn方法接收一个具有EmailPassword属性的对象,用于通过登录管理器的PasswordSignInAsync方法将用户登录到应用中。ApiSignOut方法使用密码管理器的SignOutAsync方法将用户从应用中注销。

这些是我在创建基于 HTML 的定制工作流时使用的相同的登录和退出方法,不同之处在于响应。登录时,API 客户端接收到一个带有success属性的对象,该属性指示登录是否成功,如下所示:

...
{ "success": true }
...

没有处理 API 客户端认证的标准方法,但是这种方法的优点是登录尝试的成功或失败取决于在一个可以包含附加数据的对象中的定义,我将在本章稍后介绍无记名令牌认证时使用它。

扶持 CORS

跨源资源共享(CORS)是一种安全机制,用于限制 JavaScript 代码向包含它的 HTML 页面以外的域发出请求。在前面的例子中,这不是问题,因为 JavaScript 客户机是由 ASP.NET Core 服务器交付的,所以对 API 控制器的请求是针对同一个域的。对于这个例子,我将使用一个运行在不同端口上的 web 服务器,这个不同的端口足以让 CORS 阻塞请求。添加清单 12-15 中所示的语句来配置 CORS,以便允许本节中的请求。

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using IdentityApp.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.UI.Services;
using IdentityApp.Services;

namespace IdentityApp {

    public class Startup {

        public Startup(IConfiguration config) => Configuration = config;

        private IConfiguration Configuration { get; set; }

        public void ConfigureServices(IServiceCollection services) {

            // ...statements omitted for brevity...

            services.ConfigureApplicationCookie(opts => {
                opts.LoginPath = "/Identity/SignIn";
                opts.LogoutPath = "/Identity/SignOut";
                opts.AccessDeniedPath = "/Identity/Forbidden";
                opts.Events.DisableRedirectionForApiClients();
            });

            services.AddCors(opts => {
                opts.AddDefaultPolicy(builder => {
                    builder.WithOrigins("http://localhost:5100")
                        .AllowAnyHeader()
                        .AllowAnyMethod()
                        .AllowCredentials();
                });
            });
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {
            if (env.IsDevelopment()) {
                app.UseDeveloperExceptionPage();
            }

            app.UseHttpsRedirection();
            app.UseStaticFiles();
            app.UseRouting();
            app.UseCors();
            app.UseAuthentication();
            app.UseAuthorization();

            app.UseEndpoints(endpoints => {
                endpoints.MapDefaultControllerRoute();
                endpoints.MapRazorPages();
            });

            app.SeedUserStoreForDashboard();
        }
    }
}

Listing 12-15.Adding a CORS Policy in the Startup.cs File in the IdentityApp Folder

AddCors方法定义了应用的 CORS 策略。该策略是使用 options 模式定义的,我使用AddDefaultPolicy方法来更改默认策略,以允许来自 JavaScript 代码的请求,这些代码是从本地主机上的端口 5100 加载的,带有任何请求头、方法和凭证。UseCors方法将中间件添加到请求管道中,以应用 CORS 策略。

向 JavaScript 客户端添加认证

为了登录和退出应用,JavaScript 客户机必须向 API 控制器定义的动作方法发送 POST 请求。将清单 12-16 中所示的代码添加到network.js文件中,以定义发送所需请求的函数。

const baseUrl = "https://localhost:44350/api/data";
const authUrl = "https://localhost:44350/api/auth";

const baseRequestConfig = {
    credentials: "include"
}

export const signIn = async function (email, password, callback, errorHandler) {
    const response = await fetch(`${authUrl}/signin`, {
        ...baseRequestConfig,
        method: "POST",
        body: JSON.stringify({ email, password }),
        headers: { "Content-Type": "application/json" }
    });
    processResponse(response, async () =>
        callback(await response.json()), errorHandler);
}

export const signOut = async function (callback) {
    const response = await fetch(`${authUrl}/signout`, {
        ...baseRequestConfig,
        method: "POST"
    });
    processResponse(response, callback, callback);
}

export const loadData = async function (callback, errorHandler) {
    const response = await fetch(baseUrl, {
        ...baseRequestConfig,
        redirect: "manual"
    });
    processResponse(response, async () =>
        callback(await response.json()), errorHandler);
}

export const createProduct = async function (product, callback, errorHandler) {
    const response = await fetch(baseUrl, {
        ...baseRequestConfig,
        method: "POST",
        body: JSON.stringify(product),
        headers: {
            "Content-Type": "application/json"
        }
    });
    processResponse(response, callback, errorHandler);
}

export const deleteProduct = async function (id, callback, errorHandler) {
    const response = await fetch(`${baseUrl}/${id}`, {
        ...baseRequestConfig,
        method: "DELETE"
    });
    processResponse(response, callback, errorHandler);
}

function processResponse(response, callback, errorHandler) {
    if (response.ok) {
        callback();
    } else {
        errorHandler(response.status);
    }
}

Listing 12-16.Adding Functions in the network.js File in the wwwroot/js Folder

我用来生成 JavaScript HTTP 请求的 Fetch API 不会处理 cookies,除非该请求配置了设置为includecredentials属性。这个属性必须在所有请求上设置,所以我定义了一个具有所需设置的对象,并将其赋给一个名为baseRequestConfig的常量,我已经使用 JavaScript 析构特性将它合并到其他请求中。我还定义了两个新函数:signIn被调用来登录应用,正如您所料,signOut函数退出应用。

在清单 12-17 中,我在 JavaScript 客户端生成的 HTML 中添加了一些新内容,用于让用户登录和退出应用。用户的凭证被硬编码到应用中,对于实际的项目来说不应该这样做,但是对于这个例子来说已经足够了。

import * as network from "./network.js";

const columns = ["ID", "Name", "Category", "Price"];
let tableBody;
let errorElem;

// ...functions omitted for brevity...

function createContent() {
    const targetElement = createStructure();
    createAuthPrompt(targetElement);
    const table = targetElement.make("table");
    table.classList.add("table", "table-sm", "table-striped", "table-bordered");
    const headerRow = table.make("thead", "tr");
    columns.concat([""]).forEach(col => {
        const th = headerRow.make("th");
        th.innerText = col;
    });
    tableBody = table.make("tbody");
    const footerRow = table.make("tfoot", "tr");
    footerRow.make("td");
    columns.filter(col => col != "ID").forEach(col => {
        const input = footerRow.make("td", "input");
        input.name = input.id = col;
        input.placeholder = `Enter ${col.toLowerCase()}`;
    });
    const button = footerRow.make("td", "button");
    button.classList = "btn btn-sm btn-success";
    button.innerText = "Add";
    button.addEventListener("click", async () => {
        const product = {};
        columns.forEach(col => product[col] = document.getElementById(col)?.value);
        await network.createProduct(product, populateTable, showError);
    });
}

function createAuthPrompt(targetElement) {
    let signedIn = false;
    const container = targetElement.make("div");
    container.classList.add("m-2", "p-2", "text-center");
    const status = container.make("span");
    status.innerText = "Not signed in";
    const button = container.make("button");
    button.classList.add("btn", "btn-sm", "btn-secondary", "m-2");
    button.innerText = "Sign In";
    button.addEventListener("click", async () => {
        if (!signedIn) {
            await network.signIn("alice@example.com", "mysecret",
                response => {
                    if (response.success == true) {
                        signedIn = true;
                        status.innerText = "Signed in";
                        button.innerText = "Sign Out";
                        populateTable();
                    }
                }, showError);
        } else {
            await network.signOut(() => {
                signedIn = false;
                status.innerText = "Signed out";
                button.innerText = "Sign In";
                createTableContents([]);
                populateTable();
            });
        }

    });
}

// ...functions omitted for brevity...

document.addEventListener("DOMContentLoaded", () => {
    createContent();
    populateTable();
})

Listing 12-17.Adding Authentication Support in the client.js File in the wwwroot/js Folder

新功能显示一个指示登录状态的指示器和一个按钮,单击该按钮可以调用signInsignOut功能。和这个文件中的现有代码一样,重点是简洁,这个例子的重要部分是 JavaScript 客户机发出的网络请求。

这个例子依赖于将 JavaScript 代码交付给浏览器,而不使用 Razor 页面或视图。为此,添加一个 HTML 文件名index.htmlwwwroot文件夹,内容如清单 12-18 所示。

<!DOCTYPE html>
<html>
<head>
    <title>Identity App</title>
    <link href="/lib/twitter-bootstrap/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body>
    <div id="target" class="m-3">Loading JavaScript client...</div>
    <script type="module" src="/js/client.js"></script>
</body>
</html>

Listing 12-18.The Contents of the index.html File in the wwwroot Folder

测试认证 API

重启 ASP.NET Core。打开一个新的命令提示符,运行wwwroot文件夹中清单 12-19 所示的命令来启动新的 web 服务器。这个命令依赖于 Node.js,您在第 2 章中安装了它。如果您还没有安装 Node.js,那么现在就安装。

npx http-server -p 5100 -c-1

Listing 12-19.Starting a Web Server in the wwwroot Folder

这个命令下载并执行 JavaScript http-server包,这是一个轻量级 HTTP 服务器。这些参数告诉服务器监听端口 5100 上的请求并禁用缓存,这样可以确保您对 JavaScript 客户端所做的任何更改都将在下次重新加载浏览器时生效。

使用来宾功能打开一个新的浏览器选项卡,这样就不会使用任何现有的 cookies。请求http://localhost:5100,它将向新服务器发送一个请求,请求index.html文件的内容。JavaScript 客户端将不能用数据填充该表,因为 ASP.NET Core API 控制器将返回 401 响应,表明该请求没有被授权。单击登录按钮,客户端将登录并在响应中收到一个 ASP.NET Core cookie。再次发送对产品数据的请求,这一次是用新的 cookie,并且表被填充,如图 12-4 所示。再次单击按钮退出,您将看到另一个 401 错误。

img/508695_1_En_12_Fig4_HTML.jpg

图 12-4。

使用 API 登录和注销

使用无记名令牌

如果您的 API 支持非浏览器的客户端,您就不能依赖 cookies。承载令牌是在登录过程中提供给客户端的字符串,然后作为报头包含在后续请求中。这与 cookies 的机制基本相同,但是客户端负责接收令牌并将其包含在未来的请求中,而不是依赖浏览器来完成这项工作。

当客户端使用 API 进行认证时,他们收到的响应将包括现有的success属性,加上一个token,该属性包括将包含在未来请求中的值,如下所示:

...
{
  "success": true,
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
}
...

使用以下格式的Authorization HTTP 头时包含了token值:

...
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
...

我缩短了令牌以使格式更明显,而真正的令牌要长得多。认证失败时不提供令牌,在这种情况下,响应将只包含 success 属性,如下所示:

...
{
  "success": false
}
...

为 JWT 无记名令牌配置 ASP.NET Core

微软支持处理特定类型的不记名令牌:JSON Web 令牌(JWT)。这种类型的令牌由 RFC 7519 指定,用于安全地描述一组声明。JWT 的细节对于本章并不重要,令牌只是在登录时提供给客户端,并包含在后续的请求中。使用 PowerShell 命令运行IdentityApp文件夹中清单 12-20 所示的命令,安装支持 JWT 不记名令牌的微软包。

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 5.0.0

Listing 12-20.Installing a Package

将清单 12-21 中所示的配置设置添加到appsettings.json文件中。这些是将用于生成和验证不记名令牌的设置。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "AppDataConnection": "Server=(localdb)\\MSSQLLocalDB;Database=IdentityAppData;MultipleActiveResultSets=true",
    "IdentityConnection": "Server=(localdb)\\MSSQLLocalDB;Database=IdentityAppUserData;MultipleActiveResultSets=true"
  },
  "BearerTokens": {
    "ExpiryMins": "60",
    "Key": "mySuperSecretKey"
  }
}

Listing 12-21.Adding Configuration Settings in the appsettings.json File

ExpiryMins属性定义了令牌过期之前的时间段。Key属性定义了用于签名和验证令牌的密钥。

清单 12-22 展示了对Startup类的更改,这些更改将应用配置为支持使用无记名令牌的认证。

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using IdentityApp.Models;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity.UI.Services;
using IdentityApp.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

namespace IdentityApp {

    public class Startup {

        public Startup(IConfiguration config) => Configuration = config;

        private IConfiguration Configuration { get; set; }

        public void ConfigureServices(IServiceCollection services) {

            // ...statements omitted for brevity...

            services.AddAuthentication()
                .AddFacebook(opts => {
                    opts.AppId = Configuration["Facebook:AppId"];
                    opts.AppSecret = Configuration["Facebook:AppSecret"];
                })
                .AddGoogle(opts => {
                    opts.ClientId = Configuration["Google:ClientId"];
                    opts.ClientSecret = Configuration["Google:ClientSecret"];
                })
                .AddTwitter(opts => {
                    opts.ConsumerKey = Configuration["Twitter:ApiKey"];
                    opts.ConsumerSecret = Configuration["Twitter:ApiSecret"];
                    opts.RetrieveUserDetails = true;
                }).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, opts => {
                    opts.TokenValidationParameters.ValidateAudience = false;
                    opts.TokenValidationParameters.ValidateIssuer = false;
                    opts.TokenValidationParameters.IssuerSigningKey
                        = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
                            Configuration["BearerTokens:Key"]));
                });

            // ...statements omitted for brevity...
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {

            // ...statements omitted for brevity...
        }
    }
}

Listing 12-22.Enabling Token Authentication in the Startup.cs File in the IdentityApp Folder

AddJwtBearer扩展方法添加了一个使用 JWT 令牌的认证处理程序,并用它们来认证请求。JWT 令牌旨在安全地描述双方之间的声明,在令牌包含的数据以及如何验证数据方面,有许多功能可以支持这一目标。当使用 ASP.NET Core API 控制器的 JWT 令牌对客户端进行认证时,令牌的作用要简单得多,因为 JavaScript 客户端不处理令牌的内容,而只是将其视为 HTTP 请求中包含的不透明数据块,以向 ASP.NET Core 标识客户端。

这意味着我只需要最基本的令牌特性。我使用 options 模式将ValidateAudienceValidateIssuer属性设置为false,这减少了以后必须放入令牌的数据量。重要的是能够验证令牌包含的加密签名,所以我从配置服务中读取密钥,并使用 options 模式应用它。

...
opts.TokenValidationParameters.IssuerSigningKey = new SymmetricSecurityKey(
    Encoding.UTF8.GetBytes(Configuration["BearerTokens:Key"]));
...

更新 API 认证控制器

AddJwtBearer方法建立的认证处理器将验证令牌,但是应用负责生成它们。清单 12-23 修改了将 API 客户端签署到应用中的控制器,以便使用令牌而不是 cookies。

using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using SignInResult = Microsoft.AspNetCore.Identity.SignInResult;
using Microsoft.Extensions.Configuration;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;
using System.Linq;
using System;
using System.Text;

namespace IdentityApp.Controllers {

    [ApiController]
    [Route("/api/auth")]
    public class ApiAuthController: ControllerBase {
        private SignInManager<IdentityUser> SignInManager;
        private UserManager<IdentityUser> UserManager;
        private IConfiguration Configuration;

        public ApiAuthController(SignInManager<IdentityUser> signMgr,
                UserManager<IdentityUser> usrMgr,
                IConfiguration config) {
            SignInManager = signMgr;
            UserManager = usrMgr;
            Configuration = config;
        }

        [HttpPost("signin")]
        public async Task<object> ApiSignIn(
                [FromBody] SignInCredentials creds) {
            IdentityUser user = await UserManager.FindByEmailAsync(creds.Email);
            SignInResult result = await SignInManager.CheckPasswordSignInAsync(user,
                creds.Password, true);
            if (result.Succeeded) {
                SecurityTokenDescriptor descriptor = new SecurityTokenDescriptor {
                    Subject = (await SignInManager.CreateUserPrincipalAsync(user))
                        .Identities.First(),
                    Expires = DateTime.Now.AddMinutes(int.Parse(
                        Configuration["BearerTokens:ExpiryMins"])),
                    SigningCredentials = new SigningCredentials(
                        new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
                            Configuration["BearerTokens:Key"])),
                            SecurityAlgorithms.HmacSha256Signature)
                };
                JwtSecurityTokenHandler handler = new JwtSecurityTokenHandler();
                SecurityToken secToken = new JwtSecurityTokenHandler()
                    .CreateToken(descriptor);
                return new { success = true, token = handler.WriteToken(secToken)};
            }
            return new { success = false  };
        }

        //[HttpPost("signout")]
        //public async Task<IActionResult> ApiSignOut() {
        //    await SignInManager.SignOutAsync();
        //    return Ok();
        //}
    }

    public class SignInCredentials {
        [Required]
        public string Email { get; set; }
        [Required]
        public string Password { get; set; }
    }
}

Listing 12-23.Using Tokens in the ApiAuthController.cs File in the Controllers Folder

登录过程有两个关键步骤。第一步是验证用户提供的密码。要做到这一点而不需要用户登录到应用,可以使用登录管理器的CheckPasswordSignInAsync方法。该方法对从用户管理器的存储中获得的IdentityUser对象进行操作。

...
IdentityUser user = await UserManager.FindByEmailAsync(creds.Email);
SignInResult result = await SignInManager.CheckPasswordSignInAsync(user,
    creds.Password, true);
...

如果提供了正确的密码,则创建一个令牌并在响应中发回。使用表 12-5 中描述的属性创建一个SecurityTokenDescriptor对象。

表 12-5。

用于生成令牌的 SecurityTokenDescriptor 属性

|

名字

|

描述

| | --- | --- | | Subject | 这个属性被分配给ClaimsIdentity对象,该对象的声明将包含在令牌中。 | | Expires | 该属性用于指定确定令牌何时过期的DateTime。如果客户端在这一点之后提交令牌,则令牌不会被验证。 | | SigningCredentials | 此属性用于指定将用于对令牌进行签名的算法和密钥。 |

SecurityTokenDescriptor对象用于使用由JwtSecurityTokenHandler类定义的CreateToken方法创建一个令牌,该令牌使用WriteToken方法写成一个字符串,并在响应中发送。注意,我已经注释掉了ApiSignOut方法。使用令牌时没有注销过程,如果不再需要令牌,客户端只需将其丢弃。

为了快速参考,表 12-6 描述了用于支持无记名令牌认证的登录管理器方法。

表 12-6。

SignInManager 方法支持无记名令牌

|

名字

|

描述

| | --- | --- | | CheckPasswordSignInAsync(user, password, lockout) | 该方法检查一个IdentityUser对象的密码,无需用户登录应用。lockout参数指定不正确的密码是否会被锁定。锁定在第 9 章中描述。 | | CreateUserPrincipalAsync(user) | 这个方法从一个IdentityUser对象创建一个ClaimsPrincipal对象,它被用作承载令牌的数据。 |

在 API 控制器中指定令牌认证

最后一个服务器端的变化是指定对 API 控制器的请求应该使用令牌处理程序进行认证,如清单 12-24 所示。

using IdentityApp.Models;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication.JwtBearer;

namespace IdentityApp.Controllers {

    [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
    [ApiController]
    [Route("/api/data")]
    public class ValuesController : ControllerBase {

        // ...statements omitted for brevity...
    }

    public class ProductBindingTarget {
        [Required]
        public string Name { get; set; }

        [Required]
        public decimal Price { get; set; }

        [Required]
        public string Category { get; set; }
    }

}

Listing 12-24.Specifying Token Authentication in the ValuesController.cs File in the Controllers Folder

属性的AuthenticationSchemes参数用于指定与设置Startup类中的处理程序相同的名称。

更新 JavaScript 客户端

现在服务器端部分已经就绪,我可以更新 JavaScript 客户机来接收和使用不记名令牌,如清单 12-25 所示。

const baseUrl = "https://localhost:44350/api/data";
const authUrl = "https://localhost:44350/api/auth";

const baseRequestConfig = {
    credentials: "include"
}

export const signIn = async function (email, password, callback, errorHandler) {
    const response = await fetch(`${authUrl}/signin`, {
        ...baseRequestConfig,
        method: "POST",
        body: JSON.stringify({ email, password }),
        headers: {
            "Content-Type": "application/json"
        }
    });

    if (response.ok) {
        let responseData = await response.json();
        if (responseData.success) {
            baseRequestConfig.headers = {
                "Authorization": `Bearer ${responseData.token}`
            }
        }
        processResponse(response, async () =>
            callback(responseData, errorHandler));
        return;
    }
    processResponse({ ok: false, status: "Auth Failed" }, async () =>
        callback(responseData), errorHandler);
}

export const signOut = async function (callback) {
    //const response = await fetch(`${authUrl}/signout`, {
    //    ...baseRequestConfig,
    //    method: "POST"
    //});
    baseRequestConfig.headers = {};
    processResponse({ ok: true }, callback, callback);
}

export const loadData = async function (callback, errorHandler) {
    const response = await fetch(baseUrl, {
        ...baseRequestConfig,
        redirect: "manual"
    });
    processResponse(response, async () =>
        callback(await response.json()), errorHandler);
}

export const createProduct = async function (product, callback, errorHandler) {
    const response = await fetch(baseUrl, {
        ...baseRequestConfig,
        method: "POST",
        body: JSON.stringify(product),
        headers: {
            ...baseRequestConfig.headers,
            "Content-Type": "application/json"
        }
    });
    processResponse(response, callback, errorHandler);
}

export const deleteProduct = async function (id, callback, errorHandler) {
    const response = await fetch(`${baseUrl}/${id}`, {
        ...baseRequestConfig,
        method: "DELETE"
    });
    processResponse(response, callback, errorHandler);
}

function processResponse(response, callback, errorHandler) {
    if (response.ok) {
        callback();
    } else {
        errorHandler(response.status);
    }
}

Listing 12-25.Using Tokens in the network.js File in the wwwroot/js Folder

signIn函数处理成功的响应以获得令牌,该令牌作为头部添加到用于配置请求的对象中。signOut函数已经更新,删除了配置对象的头,当用户想要注销时,它会丢弃令牌。需要对createProduct函数进行调整,以便将设置请求内容类型所需的报头添加到由基本配置对象定义的报头中。

测试令牌认证

使用清单 12-19 中所示的命令重启 ASP.NET Core 并重启 HTTP 服务器。一旦两个服务器都启动,使用浏览器的来宾模式请求http://localhost:5100,这将确保在认证过程中不使用之前示例中的 ASP.NET Core 认证 cookies。即使用于验证 JavaScript 客户端的机制已经改变,用户体验仍然保持不变。初始请求失败,因为它不包含令牌。单击 Sign In 按钮发送一个获取令牌的请求,该令牌用于再次请求数据。点击退出按钮丢弃令牌,导致数据请求失败,如图 12-5 所示。

img/508695_1_En_12_Fig5_HTML.jpg

图 12-5。

使用不记名令牌进行认证

摘要

在本章中,我解释了如何认证 API 客户端。我解释了如何利用标准的 ASP.NET Core 认证 cookie 进行快速简单的 API 认证。并不是所有的项目都可以使用这种方法,所以我还解释了如何直接获取 cookie 以及如何使用不记名令牌,这对不支持 cookie 的客户端很有用。在本书的下一部分中,我将重温 Identity API 提供的特性,并详细解释它们是如何工作的。