三十九、应用 ASP.NET Core Identity

在本章中,我将解释如何使用 ASP.NET Core Identity 来认证用户和授权对应用功能的访问。我创建了用户建立身份所需的特性,解释了如何控制对端点的访问,并演示了 Blazor 提供的安全特性。我还展示了认证 web 服务客户机的两种不同方法。表 39-1 总结了本章内容。

表 39-1。

章节总结

|

问题

|

解决办法

|

列表

| | --- | --- | --- | | 认证用户 | 使用SignInManager<T>类来验证用户提供的凭证,并使用内置的中间件来触发认证 | 3–18 | | 限制对端点的访问 | 使用Authorize属性和内置中间件来控制访问 | 9–13 | | 限制对 Blazor 组件的访问 | 使用Authorize属性和内置的 Razor 组件来控制访问 | 14–17 | | 限制对 web 服务的访问 | 使用 cookie 认证或不记名令牌 | 18–30 |

为本章做准备

本章使用了第 38 章中的项目。为了准备本章,我将重置应用数据和 ASP.NET Core Identity 数据库,并创建新的用户和角色。打开一个新的命令提示符,运行清单 39-1 中的命令,清单Advanced项目文件夹中包含了Advanced.csproj文件。这些命令删除现有的数据库并重新创建它们。

Tip

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

dotnet ef database drop --force --context DataContext
dotnet ef database drop --force --context IdentityContext
dotnet ef database update --context DataContext
dotnet ef database update --context IdentityContext

Listing 39-1.Re-creating the Project Databases

既然应用包含多个数据库上下文类,那么实体框架核心命令需要使用--context参数来选择命令所应用的上下文。

从 Debug 菜单中选择 Start Without Debugging 或 Run Without Debugging,或者使用 PowerShell 命令提示符运行清单 39-2 中所示的命令。

dotnet run

Listing 39-2.Running the Example Application

使用浏览器请求http://localhost:5000/home/index,将产生如图 39-1 所示的响应。

img/338050_8_En_39_Fig1_HTML.jpg

图 39-1。

运行示例应用

当应用启动时,主应用数据库会自动重新设定种子。ASP.NET Core Identity 数据库没有种子数据。请求http://localhost:5000/users/listhttp://localhost:5000/roles/list,你会看到图 39-2 中的响应,显示数据库为空。

img/338050_8_En_39_Fig2_HTML.jpg

图 39-2。

空的 ASP.NET Core Identity 数据库

认证用户

在接下来的小节中,我将向您展示如何向示例项目添加认证特性,以便用户可以向应用提供他们的凭证并建立他们的身份。

Authentication VS. Authorization

在使用 ASP.NET Core Identity 时,了解认证和授权之间的区别非常重要。认证,通常被称为认证,是建立用户身份的过程,用户通过向应用提交他们的凭证来完成这个过程。在示例应用中,这些凭证是用户名和密码。用户名是公共信息,但密码只有用户知道,当提供正确的密码时,应用能够对用户进行认证。

授权,通常被称为 AuthZ ,是基于用户的身份授权访问应用特性的过程。只有当用户通过认证时,才能执行授权,因为应用在决定用户是否有权使用特定功能之前,必须知道用户的身份。

创建登录功能

为了实施安全策略,应用必须允许用户对自己进行认证,这是使用 ASP.NET Core Identity API 完成的。创建Pages/Account文件夹,添加一个名为_Layout.cshtml的 Razor 页面,内容如清单 39-3 所示。该布局将为认证功能提供通用内容。

<!DOCTYPE html>
<html>
<head>
    <title>Identity</title>
    <link href="/lib/twitter-bootstrap/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body>
    <div class="m-2">
        @RenderBody()
    </div>
</body>
</html>

Listing 39-3.The Contents of the _Layout.cshtml File in the Pages/Account Folder in the Advanced Project

在高级项目的Pages/Account文件夹中添加一个名为Login.cshtml的 Razor 页面,内容如清单 39-4 所示。

@page
@model LoginModel

<div class="bg-primary text-center text-white p-2"><h4>Log In</h4></div>

<div class="m-1 text-danger" asp-validation-summary="All"></div>

<form method="post">
    <input type="hidden" name="returnUrl" value="@Model.ReturnUrl" />
    <div class="form-group">
        <label>UserName</label>
        <input class="form-control" asp-for="UserName" />
    </div>
    <div class="form-group">
        <label>Password</label>
        <input asp-for="Password" type="password" class="form-control" />
    </div>
    <button class="btn btn-primary" type="submit">Log In</button>
</form>

@functions {

    public class LoginModel : PageModel {
        private SignInManager<IdentityUser> signInManager;

        public LoginModel(SignInManager<IdentityUser> signinMgr) {
            signInManager = signinMgr;
        }

        [BindProperty] [Required]
        public string UserName { get; set; }

        [BindProperty] [Required]
        public string Password { get; set; }

        [BindProperty(SupportsGet = true)]
        public string ReturnUrl { get; set; }

        public async Task<IActionResult> OnPostAsync() {
            if (ModelState.IsValid) {
                Microsoft.AspNetCore.Identity.SignInResult result =
                    await signInManager.PasswordSignInAsync(UserName, Password,
                        false, false);
                if (result.Succeeded) {
                    return Redirect(ReturnUrl ?? "/");
                }
                ModelState.AddModelError("", "Invalid username or password");
            }
            return Page();
        }
    }
}

Listing 39-4.The Contents of the Login.cshtml File in the Pages/Account Folder of the Advanced Project

ASP.NET Core Identity 提供了用于管理登录的SigninManager<T>类,其中通用类型参数T是代表应用中用户的类,对于示例应用来说是IdentityUser。表 39-2 描述了我在本章中使用的SigninManager<T>成员。

表 39-2。

有用的 SigninManager 成员

|

名字

|

描述

| | --- | --- | | PasswordSignInAsync(name, password, persist, lockout) | 此方法尝试使用指定的用户名和密码进行认证。persist参数决定了成功的认证是否会产生一个在浏览器关闭后仍然存在的 cookie。lockout参数决定如果认证失败是否应该锁定帐户。 | | SignOutAsync() | 此方法注销用户。 |

Razor 页面为用户提供了一个收集用户名和密码的表单,用于使用PasswordSignInAsync方法执行认证,如下所示:

...
Microsoft.AspNetCore.Identity.SignInResult result =
    await signInManager.PasswordSignInAsync(UserName, Password, false, false);
...

来自PasswordSignInAsync方法的结果是一个SignInResult对象,它定义了一个Suceeded属性,如果认证成功,这个属性就是true。(在Microsoft.AspNetCore.Mvc名称空间中还定义了一个SignInResult类,这就是我在清单中使用完全限定类名的原因。)

当用户试图访问需要授权的端点时,通常会触发 ASP.NET Core 应用中的认证,如果认证成功,则按照惯例会将用户返回到该端点,这就是为什么如果用户提供了有效的凭证,则Login页面会定义一个在重定向中使用的ReturnUrl属性。

...
if (result.Succeeded) {
    return Redirect(ReturnUrl ?? "/");
}
...

如果用户没有提供有效的凭证,则会显示一条验证消息,并重新显示页面。

Protecting the Authentication Cookie

认证 cookie 包含用户的身份,ASP.NET Core 信任包含 cookie 的请求来自经过认证的用户。这意味着您应该对使用 ASP.NET Core Identity 的生产应用使用 HTTPS,以防止 cookie 被中介拦截。有关在 ASP.NET Core 中启用 HTTPS 的详细信息,请参见第 2 部分。

检查 ASP.NET Core 标识 Cookie

当用户通过认证时,响应中会添加一个 cookie,以便后续请求可以被识别为已经通过认证。在高级项目的Pages/Account文件夹中添加一个名为Details.cshtml的 Razor 页面,内容如清单 39-5 所示,当 cookie 存在时,它会显示 cookie。

@page
@model DetailsModel

<table class="table table-sm table-bordered">
    <tbody>
        @if (Model.Cookie == null) {
            <tr><th class="text-center">No Identity Cookie</th></tr>
        } else {
            <tr>
                <th>Cookie</th>
                <td class="text-break">@Model.Cookie</td>
            </tr>
        }
    </tbody>
</table>

@functions {

    public class DetailsModel : PageModel {

        public string Cookie { get; set; }

        public void OnGet() {
            Cookie = Request.Cookies[".AspNetCore.Identity.Application"];
        }
    }
}

Listing 39-5.The Contents of the Details.cshtml File in the Pages/Account Folder of the Advanced Folder

用于 ASP.NET Core Identity cookie 的名称是.AspNetCore.Identity.Application,该页面从请求中检索 cookie 并显示其值,如果没有 cookie,则显示一条占位符消息。

创建注销页面

让用户能够退出很重要,这样他们就可以显式地删除 cookie,尤其是在公共机器可能被用来访问应用的情况下。在Advanced文件夹的Pages/Account文件夹中添加一个名为Logout.cshtml的 Razor 页面,内容如清单 39-6 所示。

@page
@model LogoutModel

<div class="bg-primary text-center text-white p-2"><h4>Log Out</h4></div>
<div class="m-2">
    <h6>You are logged out</h6>
    <a asp-page="Login" class="btn btn-secondary">OK</a>
</div>

@functions {

    public class LogoutModel : PageModel {
        private SignInManager<IdentityUser> signInManager;

        public LogoutModel(SignInManager<IdentityUser> signInMgr) {
            signInManager = signInMgr;
        }

        public async Task OnGetAsync() {
            await signInManager.SignOutAsync();
        }
    }
}

Listing 39-6.The Contents of the Logout.cshtml File in the Pages/Account Folder in the Advanced Project

该页面调用表 39-2 中描述的SignOutAsync方法,将应用退出应用。ASP.NET Core Identity cookie 将被删除,这样浏览器就不会将其包含在将来的请求中(并使 cookie 失效,这样即使再次使用 cookie,请求也不会被视为已通过认证)。

测试认证功能

重启 ASP.NET Core 并请求http://localhost:5000/users/list。点击创建按钮,使用表 39-3 中显示的数据填写表格。单击提交按钮提交表单并创建用户帐户。

表 39-3。

创建用户的数据值

|

|

描述

| | --- | --- | | UserName | bob | | Email | bob@example.com | | Password | secret |

导航至http://localhost:5000/account/login,使用表 39-3 中的用户名和密码进行验证。没有指定返回 URL,一旦通过认证,您将被重定向到根 URL。请求http://localhost:5000/account/details,您将看到 ASP.NET Core Identity cookie。请求http://localhost:5000/account/logout退出应用并返回http://localhost:5000/account/details确认 cookie 已被删除,如图 39-3 所示。

img/338050_8_En_39_Fig3_HTML.jpg

图 39-3。

认证用户

启用身份认证中间件

ASP.NET Core Identity 提供了一个中间件组件,该组件检测由SignInManager<T>类创建的 cookie,并用经过认证的用户的详细信息填充HttpContex对象。这为端点提供了有关用户的详细信息,而不需要知道认证过程,也不需要直接处理认证过程创建的 cookie。清单 39-7 将认证中间件添加到示例应用的请求管道中。

...
public void Configure(IApplicationBuilder app, DataContext context) {

    app.UseDeveloperExceptionPage();
    app.UseStaticFiles();
    app.UseRouting();

    app.UseAuthentication();

    app.UseEndpoints(endpoints => {
        endpoints.MapControllerRoute("controllers",
            "controllers/{controller=Home}/{action=Index}/{id?}");
        endpoints.MapDefaultControllerRoute();
        endpoints.MapRazorPages();
        endpoints.MapBlazorHub();

        endpoints.MapFallbackToClientSideBlazor<BlazorWebAssembly.Startup>
            ("/webassembly/{*path:nonfile}", "index.html");

        endpoints.MapFallbackToPage("/_Host");
    });

    app.Map("/webassembly", opts =>
        opts.UseClientSideBlazorFiles<BlazorWebAssembly.Startup>());

    SeedData.SeedDatabase(context);
}
...

Listing 39-7.Enabling Middleware in the Startup.cs File in the Advanced Folder

中间件将HttpContext.User属性的值设置为一个ClaimsPrincipal对象。声明是关于用户的信息片段和信息来源的细节,提供了一种通用的方法来描述关于用户的已知信息。

ClaimsPrincipal类是。NET Core,并且在大多数 ASP.NET Core 应用中并不直接有用,但是有两个嵌套属性在大多数应用中是有用的,如表 39-4 中所述。

表 39-4。

有用的嵌套 ClaimsPrincipal 属性

|

名字

|

描述

| | --- | --- | | ClaimsPrincipal.Identity.Name | 该属性返回用户名,如果没有与请求相关联的用户,该用户名将是null。 | | ClaimsPrincipal.Identity.IsAuthenticated | 如果与请求相关联的用户已经过认证,则该属性返回true。 |

通过ClaimsPrincipal对象提供的用户名可以用来获得 ASP.NET Core Identity 用户对象,如清单 39-8 所示。

@page
@model DetailsModel

<table class="table table-sm table-bordered">
    <tbody>
        @if (Model.IdentityUser == null) {
            <tr><th class="text-center">No User</th></tr>
        } else {
            <tr><th>Name</th><td>@Model.IdentityUser.UserName</td></tr>
            <tr><th>Email</th><td>@Model.IdentityUser.Email</td></tr>
        }
    </tbody>
</table>

@functions {

    public class DetailsModel : PageModel {
        private UserManager<IdentityUser> userManager;

        public DetailsModel(UserManager<IdentityUser> manager) {
            userManager = manager;
        }

        public IdentityUser IdentityUser { get; set; }

        public async Task OnGetAsync() {
            if (User.Identity.IsAuthenticated) {
                IdentityUser = await userManager.FindByNameAsync(User.Identity.Name);
            }
        }
    }
}

Listing 39-8.User Details in the Details.cshtml File in the Pages/Account Folder of the Advanced Project

可以通过由PageModelControllerBase类定义的User便利属性来访问HttpContext.User属性。这个 Razor 页面确认有一个经过认证的用户与请求相关联,并获得描述用户的IdentityUser对象。

重启 ASP.NET Core,请求http://localhost:5000/account/login,并使用表 39-3 中的细节进行认证。请求http://localhost:5000/account/details,您将看到清单 39-7 中启用的 ASP.NET Core Identity 中间件是如何处理 cookie 来将用户详细信息与请求相关联的,如图 39-4 所示。

Considering Two-Factor Authentication

在本章中,我已经执行了单因素认证,在这种情况下,用户能够使用他们预先知道的一条信息进行认证:密码。

ASP.NET Core Identity 认证还支持双因素身份认证,用户需要额外的东西,通常是在用户想要进行身份认证时提供给用户的东西。最常见的例子是来自硬件令牌或智能手机应用的值,或者作为电子邮件或文本消息发送的认证代码。(严格来说,这两个因素可以是任何东西,包括指纹、虹膜扫描和语音识别,尽管这些是大多数 web 应用很少需要的选项。)

安全性增加了,因为攻击者需要知道用户的密码可以访问提供第二个因素的任何东西,如电子邮件帐户或手机。

出于两个原因,我没有在书中展示双因素认证。首先,它需要大量的准备工作,比如建立分发第二因素电子邮件和文本的基础设施,以及实现验证逻辑,所有这些都超出了本书的范围。

第二个原因是双因素认证迫使用户记住通过一个额外的环来进行认证,例如记住他们的电话或在附近保留一个安全令牌,这对于 web 应用来说并不总是合适的。十多年来,我在各种工作中携带着这样或那样的硬件令牌,我记不清有多少次因为把令牌忘在家里而无法登录雇主的系统。如果您正在考虑双因素认证,那么我建议使用众多托管提供商中的一个,它将负责为您分发和管理第二个因素。

img/338050_8_En_39_Fig4_HTML.jpg

图 39-4。

获取经过认证的用户的详细信息

授权对端点的访问

一旦应用具有认证功能,用户身份就可以用来限制对端点的访问。在接下来的小节中,我将解释启用授权的过程,并演示如何定义授权策略。

应用授权属性

Authorize属性用于限制对端点的访问,可以应用于单个动作或页面处理程序方法,也可以应用于控制器或页面模型类,在这种情况下,策略适用于该类定义的所有方法。我想限制对第 38 章中创建的用户和角色管理工具的访问。当有多个 Razor 页面或控制器需要相同的授权策略时,定义一个可以应用Authorize属性的公共基类是一个好主意,因为它可以确保您不会意外地忽略该属性并允许未经授权的访问。正是因为这个原因,我定义了AdminPageModel类,并把它作为第 38 章中所有管理工具页面模型的基础。清单 39-9Authorize属性应用到AdminPageModel类来创建授权策略。

using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Authorization;

namespace Advanced.Pages {

    [Authorize(Roles="Admins")]
    public class AdminPageModel : PageModel {

    }
}

Listing 39-9.Applying an Attribute in the AdminPageModel.cs File in the Pages Folder in the Advanced Project

可以在没有参数的情况下应用Authorize属性,这限制了对任何经过认证的用户的访问。Roles参数用于进一步限制作为特定角色成员的用户的访问,这些角色用逗号分隔的列表表示。该清单中的属性限制了分配了Admins角色的用户的访问权限。授权限制是继承的,这意味着将属性应用到基类将限制对所有 Razor 页面的访问,这些页面是为了管理第 38 章中的用户和角色而创建的。

Note

如果您想限制对控制器中大多数(而不是全部)动作方法的访问,那么您可以将Authorize属性应用于控制器类,将AllowAnonymous属性应用于需要认证访问的动作方法。

启用授权中间件

授权策略由一个中间件组件强制执行,该组件必须添加到应用的请求管道中,如清单 39-10 所示。

...
public void Configure(IApplicationBuilder app, DataContext context) {

    app.UseDeveloperExceptionPage();
    app.UseStaticFiles();
    app.UseRouting();

    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints => {
        endpoints.MapControllerRoute("controllers",
            "controllers/{controller=Home}/{action=Index}/{id?}");
        endpoints.MapDefaultControllerRoute();
        endpoints.MapRazorPages();
        endpoints.MapBlazorHub();

        endpoints.MapFallbackToClientSideBlazor<BlazorWebAssembly.Startup>
            ("/webassembly/{*path:nonfile}", "index.html");

        endpoints.MapFallbackToPage("/_Host");
    });

    app.Map("/webassembly", opts =>
        opts.UseClientSideBlazorFiles<BlazorWebAssembly.Startup>());

    SeedData.SeedDatabase(context);
}
...

Listing 39-10.Adding Middleware in the Startup.cs File in the Advanced Project

在调用了UseAuthentication方法之后,必须在UseRoutingUseEndpoints方法之间调用UseAuthorization方法。这确保了授权组件可以在选择端点之后、处理请求之前访问用户数据并检查授权策略。

创建拒绝访问端点

应用必须处理两种不同类型的授权失败。如果在请求受限端点时没有用户通过认证,那么授权中间件将返回一个质询响应,这将触发到登录页面的重定向,以便用户可以提供他们的凭证并证明他们应该能够访问端点。

但是,如果一个经过认证的用户请求一个受限制的端点,并且没有通过授权检查,那么就会生成一个拒绝访问的响应,这样应用就可以向用户显示一个适当的警告。在Advanced文件夹的Pages/Account文件夹中添加一个名为AccessDenied.cshtml的 Razor 页面,内容如清单 39-11 所示。

@page

<h4 class="bg-danger text-white text-center p-2">Access Denied</h4>

<div class="m-2">
    <h6>You are not authorized for this URL</h6>
    <a class="btn btn-outline-danger" href="/">OK</a>
    <a class="btn btn-outline-secondary" asp-page="Logout">Logout</a>
</div>

Listing 39-11.The AccessDenied.cshtml File in the Pages/Account Folder of the Advanced Project

该页面向用户显示一条警告消息,并带有一个导航到根 URL 的按钮。如果没有管理人员的干预,用户通常很难解决授权失败的问题,我倾向于让拒绝访问响应尽可能简单。

创建种子数据

在清单 39-9 中,我限制了对用户和角色管理工具的访问,因此它们只能由Admin角色的用户访问。数据库中没有这样的角色,这就产生了一个问题:我被锁定在管理工具之外,因为没有允许我创建该角色的授权帐户。

我可以在应用Authorize属性之前创建一个管理用户和角色,但是这会使应用的部署变得复杂,这时应该避免进行代码更改。相反,我将为 ASP.NET Core Identity 创建种子数据,以确保始终至少有一个帐户可用于访问用户和角色管理工具。在高级项目的Models文件夹中添加一个名为IdentitySeedData.cs的类文件,并使用它来定义清单 39-12 中所示的类。

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Advanced.Models {
    public class IdentitySeedData {

        public static void CreateAdminAccount(IServiceProvider serviceProvider,
                IConfiguration configuration) {
            CreateAdminAccountAsync(serviceProvider, configuration).Wait();
        }

        public static async Task CreateAdminAccountAsync(IServiceProvider
                serviceProvider, IConfiguration configuration) {

            serviceProvider = serviceProvider.CreateScope().ServiceProvider;

            UserManager<IdentityUser> userManager =
                serviceProvider.GetRequiredService<UserManager<IdentityUser>>();
            RoleManager<IdentityRole> roleManager =
                serviceProvider.GetRequiredService<RoleManager<IdentityRole>>();

            string username = configuration["Data:AdminUser:Name"] ?? "admin";
            string email
                = configuration["Data:AdminUser:Email"] ?? "admin@example.com";
            string password = configuration["Data:AdminUser:Password"] ?? "secret";
            string role = configuration["Data:AdminUser:Role"] ?? "Admins";

            if (await userManager.FindByNameAsync(username) == null) {
                if (await roleManager.FindByNameAsync(role) == null) {
                    await roleManager.CreateAsync(new IdentityRole(role));
                }

                IdentityUser user = new IdentityUser {
                    UserName = username,
                    Email = email
                };

                IdentityResult result = await userManager
                    .CreateAsync(user, password);
                if (result.Succeeded) {
                    await userManager.AddToRoleAsync(user, role);
                }
            }
        }
    }
}

Listing 39-12.The Contents of the IdentitySeedData.cs File in the Models Folder of the Advanced Project

UserManager<T>RoleManager<T>服务是有范围的,这意味着我需要在请求服务之前创建一个新的范围,因为播种将在应用启动时完成。种子代码创建一个分配给角色的用户帐户。种子数据的值通过回退值从应用的配置中读取,这样就可以轻松配置种子帐户,而无需更改代码。清单 39-13Startup类添加了一条语句,这样当应用启动时数据库就被植入了。

Caution

将密码放在代码文件或纯文本配置文件中意味着在部署应用和首次初始化新数据库时,必须将更改默认帐户的密码作为部署过程的一部分。您还可以使用用户机密功能将敏感数据保存在项目之外。

...
public void Configure(IApplicationBuilder app, DataContext context) {

    app.UseDeveloperExceptionPage();
    app.UseStaticFiles();
    app.UseRouting();

    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints => {
        endpoints.MapControllerRoute("controllers",
            "controllers/{controller=Home}/{action=Index}/{id?}");
        endpoints.MapDefaultControllerRoute();
        endpoints.MapRazorPages();
        endpoints.MapBlazorHub();

        endpoints.MapFallbackToClientSideBlazor<BlazorWebAssembly.Startup>
            ("/webassembly/{*path:nonfile}", "index.html");

        endpoints.MapFallbackToPage("/_Host");
    });

    app.Map("/webassembly", opts =>
        opts.UseClientSideBlazorFiles<BlazorWebAssembly.Startup>());

    SeedData.SeedDatabase(context);
    IdentitySeedData.CreateAdminAccount(app.ApplicationServices, Configuration);
}
...

Listing 39-13.Seeding Identity in the Startup.cs File in the Advanced Project

测试认证序列

重启 ASP.NET Core 并请求http://localhost:5000/account/logout确保没有用户登录到应用。没有登录,请求http://localhost:5000/users/list。将被选择来处理请求的端点需要认证,并且将显示登录提示,因为没有与请求相关联的经过认证的用户。使用用户名bob和密码secret进行认证。该用户没有访问受限端点的权限,将显示拒绝访问响应,如图 39-5 所示。

img/338050_8_En_39_Fig5_HTML.jpg

图 39-5。

未经授权的用户

点击注销按钮,再次请求http://localhost:5000/users/list,将显示登录提示。使用用户名admin和密码secret进行认证。这是由种子数据创建的用户帐户,并且是由Authorize属性指定的角色的成员。用户通过授权检查,显示请求的 Razor 页面,如图 39-6 所示。

img/338050_8_En_39_Fig6_HTML.jpg

图 39-6。

具有授权的用户

Changing the Authorization Urls

/Account/Login/Account/AccessDeniedURL 是 ASP.NET Core 授权文件使用的默认 URL。这些可以在Startup类中使用选项模式进行更改,如下所示:

...
services.Configure<CookieAuthenticationOptions>(
       IdentityConstants.ApplicationScheme,
    opts => {
        opts.LoginPath = "/Authenticate";
        opts.AccessDeniedPath = "/NotAllowed";
    });
...

使用在Microsoft.AspNetCore.Authentication.Cookies名称空间中定义的CookieAuthenticationOptions类来执行配置。LoginPath属性用于指定当未经验证的用户试图访问受限端点时,浏览器将被重定向到的路径。AccessDeniedPath属性用于指定经过认证的用户在没有授权的情况下试图访问受限端点时的路径。

授权访问 Blazor 应用

保护 Blazor 应用最简单的方法是限制对作为入口点的 action 方法或 Razor 页面的访问。在清单 39-14 中,我为_Host页面的页面模型类添加了Authorize属性,这是示例项目中 Blazor 应用的入口点。

Understanding Oauth and Identityserver

如果你阅读微软的文档,你会有这样的印象:你需要使用一个名为IdentityServer ( http://identityserver.io )的第三方服务器来认证 web 服务。

IdentityServer是一个高质量的开源软件包,提供认证和授权服务,并提供付费的附加组件和支持选项。IdentityServer提供对OAuth的支持,后者是管理认证和授权的标准,并为一系列客户端框架提供包。

微软文档所说的——尽管有些尴尬——是微软已经在包含 web 服务认证的项目模板中使用了IdentityServer。如果你使用微软提供的 ASP.NET Core 模板创建一个 Angular 或者 React 项目,你会发现认证已经使用IdentityServer实现了。

认证是复杂的,并且IdentityServer可能难以正确设置。我喜欢IdentityServer,但它不是必不可少的,也不是大多数项目所要求的。IdentityServer如果你的项目需要支持复杂的认证场景,这可能是有用的,但是我的建议是不要急于使用第三方认证服务器,除非它们是必不可少的。

@page "/"
@{ Layout = null; }
@model HostModel
@using Microsoft.AspNetCore.Authorization

<!DOCTYPE html>
<html>
<head>
    <title>@ViewBag.Title</title>
    <link href="/lib/twitter-bootstrap/css/bootstrap.min.css" rel="stylesheet" />
    <base href="~/" />
</head>
<body>
    <div class="m-2">
        <component type="typeof(Advanced.Blazor.Routed)" render-mode="Server" />
    </div>
    <script src="_framework/blazor.server.js"></script>
    <script src="~/interop.js"></script>
</body>
</html>

@functions {

    [Authorize]
    public class HostModel : PageModel {}
}

Listing 39-14.Applying an Attribute in the _Host.cshtml File in the Pages Folder of the Advanced Project

这可以防止未经验证的用户访问 Blazor 应用。请求http://localhost:5000/account/logout确保浏览器没有认证 cookie,然后请求http://localhost:5000。这个请求将由_Host页面处理,但是授权中间件将触发重定向到登录提示。使用用户名bob和密码secret进行认证,你将被授权访问 Blazor 应用,如图 39-7 所示。

img/338050_8_En_39_Fig7_HTML.jpg

图 39-7。

限制对 Blazor 端点的访问

在 Blazor 组件中执行授权

限制对端点的访问是一种有效的技术,但是它对所有 Blazor 功能应用相同级别的授权。对于需要更细粒度限制的应用,Blazor 提供了AuthorizeRouteView组件,当使用 URL 路由管理组件时,它允许为授权和未授权显示不同的内容。清单 39-15AuthorizeRouteView添加到示例应用的路由组件中。

@using Microsoft.AspNetCore.Components.Authorization

<Router AppAssembly="typeof(Startup).Assembly">
    <Found>
        <AuthorizeRouteView RouteData="@context" DefaultLayout="typeof(NavLayout)">
           <NotAuthorized Context="authContext">
                <h4 class="bg-danger text-white text-center p-2">Not Authorized </h4>
                <div class="text-center">
                    You may need to log in as a different user
                </div>
            </NotAuthorized>
        </AuthorizeRouteView>
    </Found>
    <NotFound>
        <h4 class="bg-danger text-white text-center p-2">
            Not Matching Route Found
        </h4>
    </NotFound>
</Router>

Listing 39-15.Adding a Component in the Routed.razor File in the Blazor Folder of the Advanced Project

NotAuthorized部分用于定义当用户试图访问受限资源时将呈现给他们的内容。为了演示这个特性,我将限制被分配了Admins角色的用户对DepartmentList组件的访问,如清单 39-16 所示。

@page "/departments"
@page "/depts"
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize(Roles = "Admins")]

<CascadingValue Name="BgTheme" Value="Theme" IsFixed="false" >
    <TableTemplate RowType="Department" RowData="Departments"
        Highlight="@(d => d.Name)"
        SortDirection="@(d => d.Name)">
        <Header>
            <tr><th>ID</th><th>Name</th><th>People</th><th>Locations</th></tr>
        </Header>
        <RowTemplate Context="d">
            <td>@d.Departmentid</td>
            <td>@d.Name</td>
            <td>@(String.Join(", ", d.People.Select(p => p.Surname)))</td>
            <td>
                @(String.Join(", ",
                    d.People.Select(p => p.Location.City).Distinct()))
            </td>
        </RowTemplate>
    </TableTemplate>
</CascadingValue>

<SelectFilter Title="@("Theme")" Values="Themes" @bind-SelectedValue="Theme" />

<button class="btn btn-primary" @onclick="HandleClick">People</button>

@code {

    [Inject]
    public DataContext Context { get; set; }

    public IEnumerable<Department> Departments => Context.Departments
            .Include(d => d.People).ThenInclude(p => p.Location);

    public string Theme { get; set; } = "info";
    public string[] Themes = new string[] { "primary", "info", "success" };

    [Inject]
    public NavigationManager NavManager { get; set; }

    public void HandleClick() => NavManager.NavigateTo("/people");
}

Listing 39-16.Restricting Access in the DepartmentList.cshtml File in the Blazor Folder in the Advanced Project

我已经使用了@attribute指令将Authorize属性应用于组件。重启 ASP.NET Core 并请求http://localhost:5000/account/logout移除认证 cookie,然后请求http://localhost:5000。出现提示时,使用用户名bobpassword密码进行验证。您将看到 Blazor 应用,但是当您单击 Departments 按钮时,您将看到清单 39-15 中定义的授权内容,如图 39-8 所示。再次注销,用密码secret登录为admin,就可以使用受限组件了。

img/338050_8_En_39_Fig8_HTML.jpg

图 39-8。

在 Blazor 应用中使用授权

向授权用户显示内容

AuthorizeView组件用于限制对组件呈现的内容部分的访问。在清单 39-17 中,我已经更改了对DepartmentList组件的授权,这样任何经过认证的用户都可以访问页面并使用AuthorizeView组件,这样表格中Locations列的内容只显示给分配到Admins组的用户。

@page "/departments"
@page "/depts"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]

<CascadingValue Name="BgTheme" Value="Theme" IsFixed="false" >
    <TableTemplate RowType="Department" RowData="Departments"
        Highlight="@(d => d.Name)"
        SortDirection="@(d => d.Name)">
        <Header>
            <tr><th>ID</th><th>Name</th><th>People</th><th>Locations</th></tr>
        </Header>
        <RowTemplate Context="d">
            <td>@d.Departmentid</td>
            <td>@d.Name</td>
            <td>@(String.Join(", ", d.People.Select(p => p.Surname)))</td>
            <td>
                <AuthorizeView Roles="Admins">
                    <Authorized>
                        @(String.Join(", ",
                            d.People.Select(p => p.Location.City).Distinct()))
                    </Authorized>
                    <NotAuthorized>
                        (Not authorized)
                    </NotAuthorized>
                </AuthorizeView>
            </td>
        </RowTemplate>
    </TableTemplate>
</CascadingValue>

<SelectFilter Title="@("Theme")" Values="Themes" @bind-SelectedValue="Theme" />

<button class="btn btn-primary" @onclick="HandleClick">People</button>

@code {

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

Listing 39-17.Selective Content in the DepartmentList.razor File in the Blazor Folder in the Advanced Project

AuthorizeView组件配置有Roles属性,该属性接受逗号分隔的授权角色列表。Authorized部分包含将向授权用户显示的内容。NotAuthorized部分包含将向未授权用户显示的内容。

Tip

如果不需要向未授权用户显示内容,可以省略NotAuthorized部分。

在请求http://localhost:5000/depts之前,重启 ASP.NET Core 并使用密码secret认证为bob。该用户无权查看Locations栏的内容,如图 39-9 所示。认证为admin,密码secret,再次请求http://localhost:5000/depts。这次用户是Admins角色的成员并通过了授权检查,也如图 39-9 所示。

img/338050_8_En_39_Fig9_HTML.jpg

图 39-9。

基于授权选择性地显示内容

认证和授权 Web 服务

上一节中的授权过程依赖于能够将客户端重定向到允许用户输入凭据的 URL。在向 web 服务添加认证和授权时,需要一种不同的方法,因为没有向用户提供 HTML 表单来收集他们的凭据的选项。添加对 web 服务认证的支持的第一步是禁用重定向,以便客户端在尝试请求需要认证的端点时会收到 HTTP 错误响应。将名为CookieAuthenticationExtensions.cs的类文件添加到Advanced文件夹中,并使用它来定义清单 39-18 中所示的扩展方法。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;

namespace Microsoft.AspNetCore.Authentication.Cookies {
    public static class CookieAuthenticationExtensions {

        public static void DisableRedirectForPath(
            this CookieAuthenticationEvents events,
            Expression<Func<CookieAuthenticationEvents,
                Func<RedirectContext<CookieAuthenticationOptions>, Task>>> expr,
            string path, int statuscode) {

            string propertyName = ((MemberExpression)expr.Body).Member.Name;
            var oldHandler = expr.Compile().Invoke(events);

            Func<RedirectContext<CookieAuthenticationOptions>, Task> newHandler
                    = context => {
                if (context.Request.Path.StartsWithSegments(path)) {
                    context.Response.StatusCode = statuscode;
                } else {
                    oldHandler(context);
                }
                return Task.CompletedTask;
            };

            typeof(CookieAuthenticationEvents).GetProperty(propertyName)
                .SetValue(events, newHandler);
        }
    }
}

Listing 39-18.The Contents of the CookieAuthenticationExtensions.cs File in the Advanced Folder

这个代码很难理解。ASP.NET Core 提供了CookieAuthenticationOptions类,用于配置基于 cookie 的认证。CookieAuthenticationOptions.Events属性返回一个CookieAuthenticationEvents对象,用于设置由认证系统触发的事件的处理程序,包括当用户请求未授权内容时发生的重定向。清单 39-18 中的扩展方法将事件的默认处理程序替换为只有当请求不是以指定的路径字符串开始时才执行重定向的处理程序。清单 39-19 使用扩展方法来替换OnRedirectToLoginOnRedirectToAccessDenied处理程序,这样当请求路径以/api开始时就不会执行重定向。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
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 Advanced.Models;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Authentication.Cookies;

namespace Advanced {
    public class Startup {

        public Startup(IConfiguration config) {
            Configuration = config;
        }

        public IConfiguration Configuration { get; set; }

        public void ConfigureServices(IServiceCollection services) {
            services.AddDbContext<DataContext>(opts => {
                opts.UseSqlServer(Configuration[
                    "ConnectionStrings:PeopleConnection"]);
                opts.EnableSensitiveDataLogging(true);
            });
            services.AddControllersWithViews().AddRazorRuntimeCompilation();
            services.AddRazorPages().AddRazorRuntimeCompilation();
            services.AddServerSideBlazor();
            services.AddSingleton<Services.ToggleService>();

            services.AddResponseCompression(opts => {
                opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
                    new[] { "application/octet-stream" });
            });

            services.AddDbContext<IdentityContext>(opts =>
                opts.UseSqlServer(Configuration[
                    "ConnectionStrings:IdentityConnection"]));
            services.AddIdentity<IdentityUser, IdentityRole>()
                .AddEntityFrameworkStores<IdentityContext>();

            services.Configure<IdentityOptions>(opts => {
                opts.Password.RequiredLength = 6;
                opts.Password.RequireNonAlphanumeric = false;
                opts.Password.RequireLowercase = false;
                opts.Password.RequireUppercase = false;
                opts.Password.RequireDigit = false;
                opts.User.RequireUniqueEmail = true;
                opts.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyz";
            });

            services.AddAuthentication(opts => {
                opts.DefaultScheme =
                    CookieAuthenticationDefaults.AuthenticationScheme;
                opts.DefaultChallengeScheme =
                     CookieAuthenticationDefaults.AuthenticationScheme;
            }).AddCookie(opts => {
                opts.Events.DisableRedirectForPath(e => e.OnRedirectToLogin,
                     "/api", StatusCodes.Status401Unauthorized);
                opts.Events.DisableRedirectForPath(e => e.OnRedirectToAccessDenied,
                    "/api", StatusCodes.Status403Forbidden);
            });
        }

        public void Configure(IApplicationBuilder app, DataContext context) {

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

Listing 39-19.Preventing Redirection in the Startup.cs File in the Advanced Folder

AddAuthentication方法用于选择基于 cookie 的认证,并与AddCookie方法链接在一起,以替换否则会触发重定向的事件处理程序。

构建一个简单的 JavaScript 客户端

为了演示如何使用 web 服务执行认证,我将创建一个简单的 JavaScript 客户端,它将使用示例项目中的Data控制器中的数据。

Tip

你不必熟悉 JavaScript 就能理解本章这一部分的例子。重要的是服务器端代码,以及它支持客户端认证的方式,以便它可以访问 web 服务。

Advanced项目的wwwroot文件夹中添加一个名为webclient.html的 HTML 页面,其元素如清单 39-20 所示。

<!DOCTYPE html>
<html>
<head>
    <title>Web Service Authentication</title>
    <link href="/lib/twitter-bootstrap/css/bootstrap.min.css" rel="stylesheet" />
    <script type="text/javascript" src="webclient.js"></script>
</head>
<body>
    <div id="controls" class="m-2"></div>
    <div id="data" class="m-2 p-2">
        No data
    </div>
</body>
</html>

Listing 39-20.The Contents of the webclient.html File in the wwwroot Folder of the Advanced Project

在高级项目的wwwroot中添加一个名为webclient.js的 JavaScript 文件,内容如清单 39-21 所示。

const username = "bob";
const password = "secret";

window.addEventListener("DOMContentLoaded", () => {
    const controlDiv = document.getElementById("controls");
    createButton(controlDiv, "Get Data", getData);
    createButton(controlDiv, "Log In", login);
    createButton(controlDiv, "Log Out", logout);
});

function login() {
    // do nothing
}

function logout() {
    // do nothing
}

async function getData() {
    let response = await fetch("/api/people");
    if (response.ok) {
        let jsonData = await response.json();
        displayData(...jsonData.map(item => `${item.surname}, ${item.firstname}`));
    } else {
        displayData(`Error: ${response.status}: ${response.statusText}`);
    }
}

function displayData(...items) {
    const dataDiv = document.getElementById("data");
    dataDiv.innerHTML = "";
    items.forEach(item => {
        const itemDiv = document.createElement("div");
        itemDiv.innerText = item;
        itemDiv.style.wordWrap = "break-word";
        dataDiv.appendChild(itemDiv);
    })
}

function createButton(parent, label, handler) {
    const button = document.createElement("button");
    button.classList.add("btn", "btn-primary",  "m-2");
    button.innerText = label;
    button.onclick = handler;
    parent.appendChild(button);
}

Listing 39-21.The Contents of the webclient.js File in the wwwroot Folder of the Advanced Project

这段代码为用户提供了获取数据、登录和注销按钮。单击 Get Data 按钮使用 Fetch API 发送一个 HTTP 请求,处理 JSON 结果,并显示一个名称列表。其他按钮什么也不做,但是我将在后面的示例中使用它们,通过 JavaScript 代码中的硬连线凭证向 ASP.NET Core 应用进行认证。

Caution

这只是一个简单的客户端来演示服务器端的认证功能。如果你需要写一个 JavaScript 客户端,那么考虑 Angular 或者 React 之类的框架。无论您如何构建客户端,都不要在 JavaScript 文件中包含硬连接的凭证。

请求http://localhost:5000/webclient.html,点击获取数据按钮。JavaScript 客户端将向Data控制器发送 HTTP 请求并显示结果,如图 39-10 所示。

img/338050_8_En_39_Fig10_HTML.jpg

图 39-10。

一个简单的 web 客户端

限制对 Web 服务的访问

标准授权特性用于限制对 web 服务端点的访问,在清单 39-22 中,我将Authorize属性应用于DataController类。

using Advanced.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;

namespace Advanced.Controllers {

    [ApiController]
    [Route("/api/people")]
    [Authorize]
    public class DataController : ControllerBase {
        private DataContext context;

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

Listing 39-22.Applying an Attribute in the DataController.cs File in the Controllers Folder of the Advanced Project

重启 ASP.NET Core 并请求http://localhost:5000/account/logout确保 JavaScript 客户机不使用前面例子中的认证 cookie。请求http://localhost:5000/webclient.html加载 JavaScript 客户端,点击 Get Data 按钮发送 HTTP 请求。服务器将响应一个401 Unauthorized响应,如图 39-11 所示。

img/338050_8_En_39_Fig11_HTML.jpg

图 39-11。

未经授权的请求

使用 Cookie 认证

实现认证的最简单方法是依靠前面几节中演示的标准 ASP.NET Core cookies。将名为ApiAccountController.cs的类文件添加到Advanced项目的Controllers文件夹中,并使用它来定义清单 39-23 中所示的控制器。

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

namespace Advanced.Controllers {

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

        public ApiAccountController(SignInManager<IdentityUser> mgr) {
            signinManager = mgr;
        }

        [HttpPost("login")]
        public async Task<IActionResult> Login([FromBody]Credentials creds) {
            Microsoft.AspNetCore.Identity.SignInResult result
                = await signinManager.PasswordSignInAsync(creds.Username,
                     creds.Password, false, false);
            if (result.Succeeded) {
                return Ok();
            }
            return Unauthorized();
        }

        [HttpPost("logout")]
        public async Task<IActionResult> Logout() {
            await signinManager.SignOutAsync();
            return Ok();
        }

        public class Credentials {
            [Required]
            public string Username { get; set; }
            [Required]
            public string Password { get; set; }
        }
    }
}

Listing 39-23.The Contents of the ApiAccountController.cs File in the Controllers Folder of the Advanced Project

这个 web 服务控制器定义了允许客户端登录和注销的操作。对成功的认证请求的响应将包含一个 cookie,浏览器会自动将该 cookie 包含在 JavaScript 客户端发出的请求中。

清单 39-24 增加了对简单 JavaScript 客户端的支持,使用清单 39-23 中定义的动作方法进行认证。

const username = "bob";
const password = "secret";

window.addEventListener("DOMContentLoaded", () => {
    const controlDiv = document.getElementById("controls");
    createButton(controlDiv, "Get Data", getData);
    createButton(controlDiv, "Log In", login);
    createButton(controlDiv, "Log Out", logout);
});

async function login() {
    let response = await fetch("/api/account/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username: username, password: password })
    });
    if (response.ok) {
        displayData("Logged in");
    } else {
        displayData(`Error: ${response.status}: ${response.statusText}`);
    }
}

async function logout() {
    let response = await fetch("/api/account/logout", {
        method: "POST"
    });
    if (response.ok) {
        displayData("Logged out");
    } else {
        displayData(`Error: ${response.status}: ${response.statusText}`);
    }
}

async function getData() {
    let response = await fetch("/api/people");
    if (response.ok) {
        let jsonData = await response.json();
        displayData(...jsonData.map(item => `${item.surname}, ${item.firstname}`));
    } else {
        displayData(`Error: ${response.status}: ${response.statusText}`);
    }
}

function displayData(...items) {
    const dataDiv = document.getElementById("data");
    dataDiv.innerHTML = "";
    items.forEach(item => {
        const itemDiv = document.createElement("div");
        itemDiv.innerText = item;
        itemDiv.style.wordWrap = "break-word";
        dataDiv.appendChild(itemDiv);
    })
}

function createButton(parent, label, handler) {
    const button = document.createElement("button");
    button.classList.add("btn", "btn-primary",  "m-2");
    button.innerText = label;
    button.onclick = handler;
    parent.appendChild(button);
}

Listing 39-24.Adding Authentication in the webclient.js File in the wwwroot Folder of the Advanced Project

重启 ASP.NET Core,请求http://localhost:5000/webclient.html,点击登录按钮。等待确认认证的消息,然后单击“获取数据”按钮。浏览器包含认证 cookie,请求通过授权检查。点按“注销”按钮,然后再次点按“获取数据”。没有使用 cookie,请求失败。图 39-12 显示了这两种请求。

img/338050_8_En_39_Fig12_HTML.jpg

图 39-12。

使用 cookie 认证

使用不记名令牌认证

不是所有的网络服务都可以依赖 cookiess,因为不是所有的客户端都可以使用 cookie。另一种方法是使用一个承载令牌,它是一个字符串,由客户端提供,包含在客户端发送给 web 服务的请求中。客户端不理解令牌的含义——据说令牌是不透明的——只是使用服务器提供的令牌。

我将使用一个 JSON Web 令牌(JWT)来演示认证,它为客户端提供一个加密的令牌,其中包含经过认证的用户名。客户端无法解密或修改令牌,但是当令牌包含在请求中时,ASP.NET Core 服务器会解密令牌,并使用令牌中包含的名称作为用户的身份。JWT 格式在 https://tools.ietf.org/html/rfc7519 有详细描述。

Caution

ASP.NET Core 将相信任何包含令牌的请求都来自经过认证的用户。就像使用 cookies 一样,生产应用应该使用 HTTPS 来防止令牌被拦截和重用。

准备应用

打开一个新的 PowerShell 命令提示符,导航到Advanced项目文件夹,运行清单 39-25 中所示的命令,将 JWT 的包添加到项目中。

dotnet add package System.IdentityModel.Tokens.Jwt --version 5.6.0
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 3.1.1

Listing 39-25.Installing the NuGet Package

JWT 需要一个用于加密和解密令牌的密钥。将清单 39-26 中所示的配置设置添加到appsettings.json文件中。如果您在实际应用中使用 JWT,请确保您更改了密钥。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information",
      "Microsoft.EntityFrameworkCore": "Information",
      "Microsoft.AspNetCore.Authentication": "Debug"

    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "PeopleConnection": "Server=(localdb)\\MSSQLLocalDB;Database=People;MultipleActiveResultSets=True",
    "IdentityConnection": "Server=(localdb)\\MSSQLLocalDB;Database=Identity;MultipleActiveResultSets=True"
  },
  "jwtSecret": "apress_jwt_secret"
}

Listing 39-26.Adding a Setting in the appsettings.json File in the Advanced Project

创建令牌

客户端将发送一个包含用户凭证的 HTTP 请求,并将收到一个 JWT 作为响应。清单 39-27ApiAccount控制器添加了一个动作方法,用于接收凭证、验证凭证并生成令牌。

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
using System.Security.Claims;
using System;
using Microsoft.Extensions.Configuration;

namespace Advanced.Controllers {

    [ApiController]
    [Route("/api/account")]
    public class ApiAccountController : ControllerBase {
        private SignInManager<IdentityUser> signinManager;
        private UserManager<IdentityUser> userManager;
        private IConfiguration configuration;

        public ApiAccountController(SignInManager<IdentityUser> mgr,
                 UserManager<IdentityUser> usermgr, IConfiguration config) {
            signinManager = mgr;
            userManager = usermgr;
            configuration = config;
        }

        [HttpPost("login")]
        public async Task<IActionResult> Login([FromBody]Credentials creds) {
            Microsoft.AspNetCore.Identity.SignInResult result
                = await signinManager.PasswordSignInAsync(creds.Username,
                    creds.Password, false, false);
            if (result.Succeeded) {
                return Ok();
            }
            return Unauthorized();
        }

        [HttpPost("logout")]
        public async Task<IActionResult> Logout() {
            await signinManager.SignOutAsync();
            return Ok();
        }

        [HttpPost("token")]
        public async Task<IActionResult> Token([FromBody]Credentials creds) {
            if (await CheckPassword(creds)) {
                JwtSecurityTokenHandler handler = new JwtSecurityTokenHandler();
                byte[] secret = Encoding.ASCII.GetBytes(configuration["jwtSecret"]);
                SecurityTokenDescriptor descriptor = new SecurityTokenDescriptor {
                    Subject = new ClaimsIdentity(new Claim[] {
                        new Claim(ClaimTypes.Name, creds.Username)
                    }),
                    Expires = DateTime.UtcNow.AddHours(24),
                    SigningCredentials = new SigningCredentials(
                        new SymmetricSecurityKey(secret),
                            SecurityAlgorithms.HmacSha256Signature)
                };
                SecurityToken token = handler.CreateToken(descriptor);
                return Ok(new {
                    success = true,
                    token = handler.WriteToken(token)
                });
            }
            return Unauthorized();
        }

        private async Task<bool> CheckPassword(Credentials creds) {
            IdentityUser user = await userManager.FindByNameAsync(creds.Username);
            if (user != null) {
                foreach (IPasswordValidator<IdentityUser> v in
                         userManager.PasswordValidators) {
                    if ((await v.ValidateAsync(userManager, user,
                            creds.Password)).Succeeded) {
                        return true;
                    }
                }
            }
            return false;
        }

        public class Credentials {
            [Required]
            public string Username { get; set; }
            [Required]
            public string Password { get; set; }
        }
    }
}

Listing 39-27.Generating Tokens in the ApiAccountController.cs File in the Controllers Folder of the Advanced Project

UserManager<T>类定义了一个PasswordValidators属性,该属性返回实现IPasswordValidator<T>接口的一系列对象。当调用Token动作方法时,它将凭证传递给CheckPassword方法,后者枚举IPasswordValidator<T>对象以调用每个对象的ValidateAsync方法。如果密码通过了任何验证器的验证,那么 Token 方法将创建一个令牌。

JWT 规范定义了一个通用的令牌,它的使用范围比在 HTTP 请求中标识用户更广,而且许多可用的选项在这个例子中并不是必需的。在清单 39-27 中创建的令牌包含这样一个有效负载:

...
{
  "unique_name": "bob",
  "nbf": 1579765454,
  "exp": 1579851854,
  "iat": 1579765454
}
...

unique_name属性包含用户名,用于验证包含令牌的请求。其他有效负载属性是时间戳,我不使用它。

使用清单 39-27 中定义的密钥对有效负载进行加密,并作为 JSON 编码的响应返回给客户端,如下所示:

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

我只展示了令牌的第一部分,因为它们是长字符串,响应的结构很重要。客户端接收令牌,并使用Authorization头将其包含在未来的请求中,如下所示:

...
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
...

服务器接收令牌,使用密钥对其进行解密,并使用令牌有效负载中的unique_name属性值对请求进行认证。不执行进一步的验证,具有有效令牌的请求将使用有效负载中包含的任何用户名进行认证。

用令牌认证

下一步是配置应用来接收和验证令牌,如清单 39-28 所示。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
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 Advanced.Models;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;

namespace Advanced {
    public class Startup {

        public Startup(IConfiguration config) {
            Configuration = config;
        }

        public IConfiguration Configuration { get; set; }

        public void ConfigureServices(IServiceCollection services) {
            services.AddDbContext<DataContext>(opts => {
                opts.UseSqlServer(Configuration[
                    "ConnectionStrings:PeopleConnection"]);
                opts.EnableSensitiveDataLogging(true);
            });
            services.AddControllersWithViews().AddRazorRuntimeCompilation();
            services.AddRazorPages().AddRazorRuntimeCompilation();
            services.AddServerSideBlazor();
            services.AddSingleton<Services.ToggleService>();

            services.AddResponseCompression(opts => {
                opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
                    new[] { "application/octet-stream" });
            });

            services.AddDbContext<IdentityContext>(opts =>
                opts.UseSqlServer(Configuration[
                    "ConnectionStrings:IdentityConnection"]));

            services.AddIdentity<IdentityUser, IdentityRole>()
                .AddEntityFrameworkStores<IdentityContext>();

            services.Configure<IdentityOptions>(opts => {
                opts.Password.RequiredLength = 6;
                opts.Password.RequireNonAlphanumeric = false;
                opts.Password.RequireLowercase = false;
                opts.Password.RequireUppercase = false;
                opts.Password.RequireDigit = false;
                opts.User.RequireUniqueEmail = true;
                opts.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyz";
            });

            services.AddAuthentication(opts => {
                opts.DefaultScheme =
                    CookieAuthenticationDefaults.AuthenticationScheme;
                opts.DefaultChallengeScheme =
                    CookieAuthenticationDefaults.AuthenticationScheme;
            }).AddCookie(opts => {
                opts.Events.DisableRedirectForPath(e => e.OnRedirectToLogin,
                    "/api", StatusCodes.Status401Unauthorized);
                opts.Events.DisableRedirectForPath(e => e.OnRedirectToAccessDenied,
                    "/api", StatusCodes.Status403Forbidden);
            }).AddJwtBearer(opts => {
                opts.RequireHttpsMetadata = false;
                opts.SaveToken = true;
                opts.TokenValidationParameters = new TokenValidationParameters {
                    ValidateIssuerSigningKey = true,
                    IssuerSigningKey = new SymmetricSecurityKey(
                        Encoding.ASCII.GetBytes(Configuration["jwtSecret"])),
                    ValidateAudience = false,
                    ValidateIssuer = false
                };
                opts.Events = new JwtBearerEvents {
                    OnTokenValidated = async ctx => {
                        var usrmgr = ctx.HttpContext.RequestServices
                            .GetRequiredService<UserManager<IdentityUser>>();
                        var signinmgr = ctx.HttpContext.RequestServices
                            .GetRequiredService<SignInManager<IdentityUser>>();
                        string username =
                            ctx.Principal.FindFirst(ClaimTypes.Name)?.Value;
                        IdentityUser idUser = await usrmgr.FindByNameAsync(username);
                        ctx.Principal =
                            await signinmgr.CreateUserPrincipalAsync(idUser);
                    }
                };
            });
        }

        public void Configure(IApplicationBuilder app, DataContext context) {

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

Listing 39-28.Authenticating Tokens in the Startup.cs File in the Advanced Project

AddJwtBearer为认证系统增加了对 JWT 的支持,并提供了解密令牌所需的设置。我已经为OnTokenValidated事件添加了一个处理程序,当一个令牌被验证时就会被触发,这样我就可以查询用户数据库并将IdentityUser对象与请求相关联。这充当了 JWT 令牌和 ASP.NET Core Identity 数据之间的桥梁,确保基于角色的授权等功能无缝工作。

使用令牌限制访问

为了允许使用令牌访问受限制的端点,我修改了应用于控制器的属性,如清单 39-29 所示。

using Advanced.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;

namespace Advanced.Controllers {

    [ApiController]
    [Route("/api/people")]
    [Authorize(AuthenticationSchemes = "Identity.Application, Bearer")]
    public class DataController : ControllerBase {
        private DataContext context;

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

Listing 39-29.Enabling Tokens in the DataController.cs File in the Controllers Folder of the Advanced Project

AuthenticationSchemes参数用于指定可用于授权访问控制器的认证类型。在这种情况下,我已经指定可以使用默认的 cookie 认证和新的承载令牌。

使用令牌请求数据

最后一步是更新 JavaScript 客户机,以便它获得一个令牌并将其包含在数据请求中,如清单 39-30 所示。

const username = "bob";
const password = "secret";
let token;

window.addEventListener("DOMContentLoaded", () => {
    const controlDiv = document.getElementById("controls");
    createButton(controlDiv, "Get Data", getData);
    createButton(controlDiv, "Log In", login);
    createButton(controlDiv, "Log Out", logout);
});

async function login() {
    let response = await fetch("/api/account/token", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ username: username, password: password })
    });
    if (response.ok) {
        token = (await response.json()).token;
        displayData("Logged in", token);
    } else {
        displayData(`Error: ${response.status}: ${response.statusText}`);
    }
}

async function logout() {
    token = "";
    displayData("Logged out");
}

async function getData() {
    let response = await fetch("/api/people", {
        headers: { "Authorization": `Bearer ${token}` }
    });
    if (response.ok) {
        let jsonData = await response.json();
        displayData(...jsonData.map(item => `${item.surname}, ${item.firstname}`));
    } else {
        displayData(`Error: ${response.status}: ${response.statusText}`);
    }
}

function displayData(...items) {
    const dataDiv = document.getElementById("data");
    dataDiv.innerHTML = "";
    items.forEach(item => {
        const itemDiv = document.createElement("div");
        itemDiv.innerText = item;
        itemDiv.style.wordWrap = "break-word";
        dataDiv.appendChild(itemDiv);
    })
}

function createButton(parent, label, handler) {
    const button = document.createElement("button");
    button.classList.add("btn", "btn-primary",  "m-2");
    button.innerText = label;
    button.onclick = handler;
    parent.appendChild(button);
}

Listing 39-30.Using Tokens in the webclient.js File in the wwwroot Folder of the Advanced Project

客户端接收认证响应并分配令牌,这样它就可以被设置Authorization头的GetData方法使用。请注意,不需要注销请求,当用户单击“注销”按钮时,用于存储令牌的变量被简单地重置。

Caution

当试图测试令牌时,很容易以使用 cookie 进行认证而告终。请确保在测试此功能之前清除浏览器 cookie,以确保以前测试的 cookie 不会被使用。

重启 ASP.NET Core 并请求http://localhost:5000/webclient.html。单击登录按钮,将会生成并显示一个令牌。点击获取数据按钮,令牌将被发送到服务器并用于认证用户,产生如图 39-13 所示的结果。

img/338050_8_En_39_Fig13_HTML.jpg

图 39-13。

使用令牌进行认证

摘要

在本章中,我向您展示了如何在 ASP.NET Core 应用中应用认证和授权。我解释了认证用户和限制访问端点的过程。我解释了如何在 Blazor 应用中对用户进行授权,并演示了如何使用 cookies 和不记名令牌对 web 服务客户端进行认证。

关于 ASP.NET Core 我只能教你这么多。我只希望你能像我喜欢写这本书一样喜欢读这本书,我祝愿你在 ASP.NET 的核心项目中一切顺利。