十六、创建用户存储

在本章中,我将 Identity 添加到示例项目中,并创建一个自定义用户存储。在第 1 部分中,我使用了微软提供的默认用户存储,它使用实体框架核心将用户数据存储在关系数据库中,这是您应该在实际项目中使用的存储。

我在本章中创建的用户存储将其数据存储在内存中,这使得解释用户存储如何工作变得容易,而不会陷入如何序列化和持久化数据的困境。表 16-1 将用户商店放在上下文中。

表 16-1。

将用户存储放在上下文中

|

问题

|

回答

| | --- | --- | | 这是什么? | 用户存储是 Identity 数据的数据存储库。 | | 为什么有用? | 用户存储维护 Identity 管理的数据。如果没有用户存储,Identity 提供的所有功能都不可能实现。 | | 如何使用? | 用户存储被注册为服务,并通过 ASP.NET Core 依赖注入功能由 Identity 使用。 | | 有什么陷阱或限制吗? | 自定义用户存储的主要问题是确保每个可选接口的实现不会影响其他任何接口。正如您将在后面的示例中看到的,有些特性是密切相关的,必须注意确保结果的一致性。 | | 还有其他选择吗? | 使用 Identity 需要用户存储,但您不必创建自定义实现。 |

16-2 总结了本章内容。

表 16-2。

章节总结

|

问题

|

解决办法

|

列表

| | --- | --- | --- | | 定义自定义用户类 | 创建一个类,该类具有唯一的键属性,并且可以存储名称的常规和规范化版本。定义附加属性以支持用户存储实现的可选功能。 | 782431 | | 定义自定义用户存储 | 创建一个IUserStore<T>接口的实现。通过实现可选接口,可以支持其他功能。将商店注册为服务。 | 1114222527 | | 定义自定义规格化器 | 创建一个ILookupNormalizer接口的实现。将规范化器注册为服务。 | 1214 | | 访问用户存储 | 使用UserManager<T>类的成员。 | 1521232830 | | 在将用户数据添加到存储之前对其进行验证 | 创建一个IUserValidator<T>方法的实现,并将其注册为服务。 | 3526 |

为本章做准备

本章使用了第 15 章中的 ExampleApp 项目。为了准备这一章,用清单 16-1 中所示的代码替换Startup类,该代码删除了前一章中使用的自定义授权中间件,并替换为内置授权。我还启用了自定义的RoleMemberships中间件组件作为用户声明的来源,并移除了 Razor 页面和 MVC 框架的应用模型约定。MapFallbackToPage方法用于为与另一个端点不匹配的请求选择Secret Razor 页面。

Tip

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

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using ExampleApp.Custom;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;

namespace ExampleApp {
    public class Startup {

        public void ConfigureServices(IServiceCollection services) {

            services.AddTransient<IAuthorizationHandler,
                CustomRequirementHandler>();

            services.AddAuthentication(opts => {
                opts.DefaultScheme
                    = CookieAuthenticationDefaults.AuthenticationScheme;
            }).AddCookie(opts => {
                opts.LoginPath = "/signin";
                opts.AccessDeniedPath = "/signin/403";
            });
            services.AddAuthorization(opts => {
                AuthorizationPolicies.AddPolicies(opts);
            });
            services.AddRazorPages();
            services.AddControllersWithViews();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {

            app.UseStaticFiles();
            app.UseAuthentication();
            app.UseRouting();
            //app.UseMiddleware<AuthorizationReporter>();
            app.UseMiddleware<RoleMemberships>();
            app.UseAuthorization();

            app.UseEndpoints(endpoints => {
                //endpoints.MapGet("/", async context => {
                //    await context.Response.WriteAsync("Hello World!");
                //});
                endpoints.MapRazorPages();
                endpoints.MapDefaultControllerRoute();
                endpoints.MapFallbackToPage("/Secret");
            });
        }
    }
}

Listing 16-1.The Contents of the Startup.cs File in the ExampleApp Folder

禁用自定义回退授权策略,并从UsersExceptBob策略中删除对OtherScheme的引用,如清单 16-2 所示。

using Microsoft.AspNetCore.Authorization;
using System.Linq;
using Microsoft.AspNetCore.Authorization.Infrastructure;

namespace ExampleApp.Custom {

    public static class AuthorizationPolicies {

        public static void AddPolicies(AuthorizationOptions opts) {
            //opts.FallbackPolicy = new AuthorizationPolicy(
            //   new IAuthorizationRequirement[] {
            //       new RolesAuthorizationRequirement(
            //           new [] { "User", "Administrator" }),
            //       new AssertionRequirement(context =>
            //           !string.Equals(context.User.Identity.Name, "Bob"))
            //   }, new string[] { "TestScheme" });

            opts.AddPolicy("UsersExceptBob", builder =>
                    builder.RequireRole("User")
                .AddRequirements(new AssertionRequirement(context =>
                    !string.Equals(context.User.Identity.Name, "Bob"))));
                //.AddAuthenticationSchemes("OtherScheme"));

            opts.AddPolicy("NotAdmins", builder =>
                builder.AddRequirements(new AssertionRequirement(context =>
                    !context.User.IsInRole("Administrator"))));
        }
    }
}

Listing 16-2.Altering Authorization Policies in the AuthorizationPolicies.cs File in the Custom Folder

Home控制器的Protected动作中移除Authorize属性,如清单 16-3 所示。

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

namespace ExampleApp.Controllers {

    [Authorize]
    public class HomeController : Controller {

        public IActionResult Test() => View();

        //[Authorize(Roles = "User", AuthenticationSchemes = "OtherScheme")]
        public IActionResult Protected() => View("Test", "Protected Action");

        [AllowAnonymous]
        public IActionResult Public() => View("Test", "Unauthenticated Action");
    }
}

Listing 16-3.Removing an Attribute in the HomeController.cs File in the Controllers Folder

最后,禁用显示第 15 章中使用的授权结果的局部视图,如清单 16-4 所示。

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>ExampleApp</title>
    <link href="/lib/twitter-bootstrap/css/bootstrap.min.css" rel="stylesheet" />
</head>
<body>
    <div>
        @RenderBody()
    </div>
    @*<partial name="_AuthorizationReport" />*@
</body>
</html>

Listing 16-4.Removing the Partial View in the _Layout.cshtml File in the Pages/Shared Folder

这些更改的效果是恢复内置的授权特性,这样用户就可以使用Cookie认证方案在/signin URL 登录。定制的RoleMemberships中间件组件提供了授权策略使用的用户声明的来源。运行ExampleApp文件夹中清单 16-5 所示的命令启动 ASP.NET Core。

dotnet run

Listing 16-5.Starting ASP.NET Core

请求http://localhost:5000/signin,从列表中选择爱丽丝,点击签到按钮。请求http://localhost:5000/secret,该请求将使用 cookie 进行认证,并被授予访问Secret Razor 页面的权限,如图 16-1 所示。

img/508695_1_En_16_Fig1_HTML.jpg

图 16-1。

运行示例应用

安装 ASP.NET Core Identity

使用命令提示符运行清单 16-6 中的命令,将核心身份包添加到项目中。

dotnet add package Microsoft.Extensions.Identity.Core --version 5.0.0

Listing 16-6.Adding the Core Identity Package to the Project

创建 Identity 用户存储

在前面的章节中,我将用户列表硬编码到应用中,这可以让您入门,但很快就变得难以管理,并且每次添加或删除用户时都需要一个新的版本。用户存储提供了管理用户数据的一致方式。

创建用户类

创建用户存储的第一步是定义用户类,它的实例将用于表示应用中的用户。不需要特定的基类,但是该类的实例必须彼此不同,并且必须能够存储用户的基本信息。为了定义本章的用户类,创建ExampleApp/Identity文件夹并添加一个名为AppUser.cs的类文件,代码如清单 16-7 所示。

using System;

namespace ExampleApp.Identity {
    public class AppUser {

        public string Id { get; set; } = Guid.NewGuid().ToString();

        public string UserName { get; set; }

        public string NormalizedUserName { get; set; }
    }
}

Listing 16-7.The Contents of the AppUser.cs File in the Identity Folder

属性将被分配一个代表用户的唯一标识符,默认为 GUID 值。属性将在应用中存储用户的帐户名称。NormalizedUserName包含了UserName值的规范化表示,我将在下一节中解释。这是一个最小的用户类,随着用户存储的发展,我将向它添加属性。

我需要能够轻松地将值从一个AppUser对象复制到另一个对象。这将使以 Identity 期望的方式实现存储变得更加容易,这样,如果没有将对 user 类实例的更改显式保存到用户存储中,这些更改将被丢弃。将名为StoreClassExtentions.cs的类文件添加到Identity文件夹中,并使用它来定义清单 16-8 中所示的扩展方法。

using System;
using System.Collections;
using System.Collections.Generic;

namespace ExampleApp.Identity {

    public static class StoreClassExtentions {

        public static T UpdateFrom<T>(this T target, T source) {
            UpdateFrom(target, source, out bool discardValue);
            return target;
        }

        public static T UpdateFrom<T>(this T target, T source, out bool changes) {
            object value;
            int changeCount = 0;
            Type classType = typeof(T);
            foreach (var prop in classType.GetProperties()) {
                if (prop.PropertyType.IsGenericType &&
                    prop.PropertyType.GetGenericTypeDefinition()
                        .Equals(typeof(IList<>))) {
                    Type listType = typeof(List<>).MakeGenericType(prop.PropertyType
                        .GetGenericArguments()[0]);
                    IList sourceList = prop.GetValue(source) as IList;
                    if (sourceList != null) {
                        prop.SetValue(target, Activator.CreateInstance(listType,
                            sourceList));
                    }
                } else {
                    if ((value = prop.GetValue(source)) != null
                            && !value.Equals(prop.GetValue(target))) {
                        classType.GetProperty(prop.Name).SetValue(target, value);
                        changeCount++;
                    }
                }
            }
            changes = changeCount > 0;
            return target;
        }

        public static T Clone<T>(this T original) =>
             Activator.CreateInstance<T>().UpdateFrom(original);
    }
}

Listing 16-8.The Contents of the StoreClassExtentions.cs File in the Identity Folder

我用泛型类型参数定义了方法,这样我就可以使用相同的代码来处理 Identity 使用的不同类。UpdateFrom方法将任何非空属性的值从一个对象复制到另一个对象,而Clone方法将创建一个对象的副本。清单 16-8 中的代码是为了支持本书这一部分中的示例而编写的,并且在使用实体框架核心存储 Identity 数据的标准方法时不是必需的。

创建用户存储

要实现的关键接口是IUserStore<T>,其中T是用户类。该接口使用表 16-3 中描述的方法定义了用户商店的核心特性。该接口定义的所有方法都接收一个CancellationToken参数,该参数用于接收异步操作应该被取消的通知,在表中显示为token参数。

表 16-3。

IUserStore 方法

|

名字

|

描述

| | --- | --- | | CreateAsync(user, token) | 此方法在存储区中创建指定的用户。 | | DeleteAsync(user, token) | 此方法移除存储区中的指定用户。 | | UpdateAsync(user, token) | 此方法更新存储区中的指定用户。 | | FindByIdAsync(id, token) | 此方法从存储中检索具有指定 ID 的用户。 | | FindByNameAsync(name, token) | 此方法检索具有指定规范化用户名的用户。 | | GetUserIdAsync(user, name) | 此方法从指定的用户对象返回 ID。 | | GetUserNameAsync(name, token) | 此方法从指定的用户对象返回用户名。 | | SetUserNameAsync(user, name, token) | 此方法设置指定用户的用户名。 | | GetNormalizedUserNameAsync(user, token) | 此方法获取指定用户的规范化用户名。 | | SetNormalizedUserNameAsync(user, name, token) | 此方法为指定用户设置规范化用户名。 | | Dispose() | 该方法继承自IDisposable接口,在存储对象被销毁之前被调用来释放非托管资源。 |

IUserStore<T>接口定义的方法分为三组:核心存储(创建/删除/更新用户)、查询(通过名称和 ID 定位用户)和处理名称(获取和设置自然和规范化的用户名)。在接下来的几节中,我通过依次关注每组方法并使用 C# 分部类特性在多个类文件中构建功能来创建用户存储。

实现数据存储方法

为了简单起见,我将创建一个基于内存的用户存储。当然,这种方法的缺点是,当 ASP.NET Core 重新启动时,对用户存储的更改将会丢失,但这已经足够开始使用了。

创建ExampleApp/Identity/Store文件夹并添加一个名为UserStoreCore.cs的类文件,代码如清单 16-9 所示。

Note

您的代码编辑器可能会警告您,AppUserStore类没有实现IUserStore<AppUser>接口所需的所有方法。清单 16-9 中的代码定义了一个分部类,这意味着类成员被定义在多个类文件中。我将在接下来的小节中实现缺少的方法。

using Microsoft.AspNetCore.Identity;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

namespace ExampleApp.Identity.Store {

    public partial class UserStore : IUserStore<AppUser> {
        private ConcurrentDictionary<string, AppUser> users
            = new ConcurrentDictionary<string, AppUser>();

        public Task<IdentityResult> CreateAsync(AppUser user,
                CancellationToken token) {
            if (!users.ContainsKey(user.Id) && users.TryAdd(user.Id, user)) {
                return Task.FromResult(IdentityResult.Success);
            }
            return Task.FromResult(Error);
        }

        public Task<IdentityResult> DeleteAsync(AppUser user,
                CancellationToken token) {
            if (users.ContainsKey(user.Id)
                    && users.TryRemove(user.Id, out user)) {
                return Task.FromResult(IdentityResult.Success);
            }
            return Task.FromResult(Error);
        }

        public Task<IdentityResult> UpdateAsync(AppUser user,
                CancellationToken token) {
            if (users.ContainsKey(user.Id)) {
                users[user.Id].UpdateFrom(user);
                return Task.FromResult(IdentityResult.Success);
            }
            return Task.FromResult(Error);
        }

        public void Dispose() {
            // do nothing
        }

        private IdentityResult Error => IdentityResult.Failed(new IdentityError {
            Code = "StorageFailure",
            Description = "User Store Error"
        });
    }
}

Listing 16-9.The Contents of the UserStoreCore.cs File in the Identity/Store Folder

用户数据的数据结构是一个并发字典,每个AppUser对象使用它的Id值作为键来存储。实现CreateAsyncDeleteAsyncUpdateAsync方法意味着管理字典中的数据并生成IdentityResult对象来报告结果。使用IdentityResult.Success属性报告成功的操作。

...
return Task.FromResult(IdentityResult.Success);
...

使用IdentityResult.Failed方法报告失败的操作,该方法接受一个或多个描述问题的IdentityError对象。

...
private IdentityResult Error => IdentityResult.Failed(new IdentityError {
    Code = "StorageFailure", Description = "User Store Error"});
...

IdentityError类定义了用于描述错误条件的CodeDescription属性。一个真实的用户存储会描述它遇到的问题,但是对于我的简单实现,我会产生一个一般性的错误来指出问题。

实现搜索方法

下一组方法允许搜索用户存储。将名为UserStoreQuery.cs的类文件添加到ExampleApp/Identity/Store文件夹中,并使用它来定义清单 16-10 中所示的分部类。

using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace ExampleApp.Identity.Store {

    public partial class UserStore {

        public Task<AppUser> FindByIdAsync(string userId,
                CancellationToken token) =>
            Task.FromResult(users.ContainsKey(userId)
                 ? users[userId].Clone() : null);

        public Task<AppUser> FindByNameAsync(string normalizedUserName,
                CancellationToken token) =>
            Task.FromResult(users.Values.FirstOrDefault(user =>
            user.NormalizedUserName == normalizedUserName)?.Clone());
    }
}

Listing 16-10.The Contents of the UserStoreQuery.cs File in the Identity/Store Folder

这些方法从字典中检索AppUser对象。在使用FindByIdAsync方法的情况下,使用Id值作为键来存储AppUser对象,这使得查询变得简单。FindByNameAsync需要更多的工作,因为查询是使用NormalizedUserName属性执行的,它不是一个键。对于这个方法,我使用 LINQ FirstOrDefault方法来定位一个匹配的对象。

在这两种情况下,清单 16-8 中定义的Clone扩展方法用于创建从存储中检索到的AppUser对象的副本。这意味着在调用UpdateAsync方法之前,对AppUser对象的任何更改都不会添加到存储中。

实现 ID 和 Name 方法

下一组方法用于获取用户 id 以及获取和设置用户名。将名为UserStoreNames.cs的类文件添加到ExampleApp/Identity/Store文件夹中,并使用它来定义清单 16-11 中所示的分部类。

using System.Threading;
using System.Threading.Tasks;

namespace ExampleApp.Identity.Store {

    public partial class UserStore {

        public Task<string> GetNormalizedUserNameAsync(AppUser user,
            CancellationToken token)
                 => Task.FromResult(user.NormalizedUserName);

        public Task<string> GetUserIdAsync(AppUser user,
            CancellationToken token)
                => Task.FromResult(user.Id);

        public Task<string> GetUserNameAsync(AppUser user,
            CancellationToken token)
                => Task.FromResult(user.UserName);

        public Task SetNormalizedUserNameAsync(AppUser user,
            string normalizedName, CancellationToken token)
                => Task.FromResult(user.NormalizedUserName = normalizedName);

        public Task SetUserNameAsync(AppUser user, string userName,
            CancellationToken token)
                => Task.FromResult(user.UserName = userName);
    }
}

Listing 16-11.The Contents of the UserStoreNames.cs File in the Identity/Store Folder

这些方法很容易实现,并且直接映射到AppUser类的属性上。

创建规范化器并播种用户存储

名称的规范化是对其进行转换的过程,这样查询将与名称可以表达的所有形式相匹配。如果不进行规范化,像Alice这样的名字将会受到与aliceALICEAliCE不同的对待。这对于数据存储来说可能是个问题,例如,对alice的查询与存储的值Alice不匹配。

规范化不是编写复杂的查询匹配器,而是转换名称,因此不管名称的表达方式如何变化,都会产生相同的值。对于用户名,常规的规范化意味着将所有的字母转换成大写或小写,以便所有形式的Alice都表示为alice。ASP.NET Core Identity 规范化通过ILookupNormalizer接口完成,该接口定义了表 16-4 中描述的方法。

表 16-4。

ILookupNormalizer 方法定义的方法

|

名字

|

描述

| | --- | --- | | NormalizeName(name) | 这个方法负责规范化用户名。 | | NormalizeEmail(email) | 这个方法负责规范化电子邮件地址。 |

创建自定义用户存储并不需要创建自定义规格化器,但是我想展示关键的 ASP.NET Core 标识构建块。将名为Normalizer.cs的类添加到ExampleApp/Identity/Store文件夹中,并使用它来定义清单 16-12 中所示的类。

using Microsoft.AspNetCore.Identity;

namespace ExampleApp.Identity.Store {

    public class Normalizer : ILookupNormalizer {

        public string NormalizeName(string name)
            => name.Normalize().ToLowerInvariant();

        public string NormalizeEmail(string email)
            => email.Normalize().ToLowerInvariant();
    }
}

Listing 16-12.The Contents of the Normalizer.cs File in the Identity/Store Folder

如果过程是一致的,那么值如何被规范化并不重要,并且名称的所有表达方式都被处理了。利用也是一个好主意。NET Unicode 规范化功能,该功能确保以一致的方式处理复杂字符。在清单 16-12 中,我调用 string Normalize方法并将结果转换成小写。

为了完成用户存储,将名为UserStore.cs的类文件添加到ExampleApp/Identity/Store文件夹中,并使用它来定义清单 16-13 中所示的分部类。

using Microsoft.AspNetCore.Identity;

namespace ExampleApp.Identity.Store {

    public partial class UserStore {

        public ILookupNormalizer Normalizer { get; set; }

        public UserStore(ILookupNormalizer normalizer) {
            Normalizer = normalizer;
            SeedStore();
        }

        private void SeedStore() {

            int idCounter = 0;

            foreach (string name in UsersAndClaims.Users) {
                AppUser user = new AppUser {
                    Id = (++idCounter).ToString(),
                    UserName = name,
                    NormalizedUserName = Normalizer.NormalizeName(name)
                };
                users.TryAdd(user.Id, user);
            }
        }
    }
}

Listing 16-13.The Contents of the UserStore.cs File in the Identity/Store Folder

这段代码添加了一个接受ILookupNormalizer参数的构造函数。ASP.NET Core Identity 使用 ASP.NET Core 服务找到所需的功能。用户存储将被设置为服务,当 ASP.NET Core 依赖注入特性实例化UserStore类时,它将实例化ILookupNormalizer服务以提供构造函数参数。

构造函数将规格化器赋给一个属性并调用SeedStore方法,该方法根据在UsersAndClaims类中定义的用户名用AppUser对象填充用户存储。

配置 Identity 和自定义服务

应用必须配置为设置自定义用户存储和 ASP.NET Core Identity,如清单 16-14 所示。

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using ExampleApp.Custom;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using ExampleApp.Identity;
using ExampleApp.Identity.Store;

namespace ExampleApp {
    public class Startup {

        public void ConfigureServices(IServiceCollection services) {

            //services.AddTransient<IAuthorizationHandler,
            //    CustomRequirementHandler>();

            services.AddSingleton<ILookupNormalizer, Normalizer>();
            services.AddSingleton<IUserStore<AppUser>, UserStore>();

            services.AddIdentityCore<AppUser>();

            services.AddAuthentication(opts => {
                opts.DefaultScheme
                    = CookieAuthenticationDefaults.AuthenticationScheme;
            }).AddCookie(opts => {
                opts.LoginPath = "/signin";
                opts.AccessDeniedPath = "/signin/403";
            });
            services.AddAuthorization(opts => {
                AuthorizationPolicies.AddPolicies(opts);
            });
            services.AddRazorPages();
            services.AddControllersWithViews();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env) {

            app.UseStaticFiles();
            app.UseAuthentication();
            app.UseRouting();
            app.UseMiddleware<RoleMemberships>();
            app.UseAuthorization();

            app.UseEndpoints(endpoints => {
                endpoints.MapRazorPages();
                endpoints.MapDefaultControllerRoute();
                endpoints.MapFallbackToPage("/Secret");
            });
        }
    }
}

Listing 16-14.Configuring the Application in the Startup.cs File in the ExampleApp Folder

使用AddSingleton方法将 Identity 接口的定制实现注册为服务,使用AddIdentityCore<T>方法将 Identity 添加到应用中,其中T是用户类。对于任何尚未注册的服务,Identity 将使用其默认实现,这意味着您可以有选择地进行定制。

Note

当您希望更好地控制启用的 Identity 服务和特性时,AddIdentityCore<T>方法很有用,但是第 1 部分中使用的方法更适合大多数项目。

访问用户存储

应用不直接与用户存储接口。相反,Identity 提供了UserManager<T>类,其中T是用户类。UserManager<T>类定义了很多成员,表 16-5 描述了那些与用户商店相关的成员。当我开始使用其他 Identity 特征时,我将描述其他成员。

表 16-5。

选定的用户管理器成员

|

名字

|

描述

| | --- | --- | | FindByIdAsync(id) | 该方法通过调用存储的FindByIdAsync方法,根据 ID 定位一个AppUser对象用户。 | | FindByNameAsync(username) | 这个方法通过用户名定位一个AppUser对象用户。username 参数被规范化并传递给商店的FindByNameAsync方法。 | | CreateAsync(user) | 该方法将指定的AppUser对象添加到存储中。设置安全标记,对用户进行验证,更新规范化的名称和电子邮件属性,然后将用户对象传递给存储的CreateAsync方法。(存储电子邮件地址在“添加可选存储功能”一节中介绍,验证用户在“验证用户数据”一节中介绍。) | | UpdateAsync(user) | 该方法应用用户存储的更新序列,在下面的侧栏中描述,提交已经进行的任何更改。 | | DeleteAsync(user) | 该方法通过将用户对象传递给存储的DeleteAsync方法,从存储中删除一个AppUser对象。 | | GetUserIdAsync(user) | 该方法通过调用存储的GetUserIdAsync方法来获取用户对象的 ID。 | | GetUserNameAsync(user) | 该方法通过调用存储的GetUserNameAsync方法来获取用户对象的名称。 | | SetUserNameAsync(user, name) | 该方法通过调用存储的SetUserNameAsync方法来设置用户对象的名称,之后更新安全标记并执行用户管理器的更新序列。更新序列在下面的侧栏中描述。 |

最重要的UserManager<T>方法是CreateAsyncUpdateAsync。这些方法验证用户对象(如“验证用户数据”一节所述),确保规范化属性得到一致更新,并创建新的安全戳(我在第 17 章中对此进行了描述)。

更新单个属性的UserManager<T>方法是可选的,您可以选择使用它们或者直接使用 user 类定义的属性。UserManager<T>方法的优点是它们经常执行有用的额外工作,我在介绍每组方法时都会描述这些工作。

直接设置属性与 ASP.NET Core 模型绑定特性配合得很好,使得处理 HTML 表单变得很容易,尽管这意味着您必须小心地显式执行UserManager<T>自动执行的额外工作。您还必须记住调用UpdateAsync方法来将更改应用到用户存储中。

在大多数情况下,在本书的这一部分中,我直接使用用户类属性,因为它与模型绑定非常接近,这符合示例的风格。你不必在你自己的项目中遵循这种方法,我包括了我描述的每一个UserManager<T>方法所执行的工作的细节。

Understanding the User Manager Update Sequence

当我介绍由UserManager<T>提供的特性时,许多方法的描述将涉及用户管理器更新序列。UserManager<T>类定义了一个受保护的方法,该方法被许多其他方法调用来更新存储中的数据。该方法通过一系列步骤来准备用户对象。这个顺序取决于我在后面章节中介绍的特性,但是现在,对这个过程有一个粗略的概念就足够了。

在将对象传递给用户存储的UpdateAsync方法之前,这个更新序列执行验证(在“验证用户数据”一节中描述)并更新规范化的名称和电子邮件属性(我在“添加对存储电子邮件地址的支持”一节中描述了存储电子邮件地址)。

使用用户存储数据

为了准备使用UserManager<T>类,将清单 16-15 中所示的表达式添加到Pages文件夹中的_ViewImports.cshtml文件中。

@namespace ExampleApp.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@using Microsoft.AspNetCore.Mvc.RazorPages
@using Microsoft.AspNetCore.Identity
@using System.Security.Claims
@using ExampleApp.Identity

Listing 16-15.Adding Expressions in the _ViewImports.cshtml File in the Pages Folder

在视图导入文件中定义这些名称空间意味着我不必在每个使用 Identity 特性的 Razor 页面中导入它们。

接下来,创建Pages/Store文件夹并添加一个名为Users.cshtml的 Razor 页面,内容如清单 16-16 所示。

@page "/users/{searchname?}"
@model ExampleApp.Pages.Store.FindUserModel

<div class="m-2">
     <form method="get" class="mb-2" action="/users">
        <div class="container-fluid">
            <div class="row">
                <div class="col-9">
                    <input name="searchname" class="w-100" value="@Model.Searchname"
                        placeholder="Enter Username or ID" />
                </div>
                <div class="col-auto">
                    <button type="submit"
                        class="btn btn-primary btn-sm">Find</button>
                    <a class="btn btn-secondary btn-sm" href="/users">Clear</a>
                </div>
            </div>
        </div>
    </form>
    @if (Model.Users?.Count() > 0) {
        <table class="table table-sm table-striped table-bordered">
            <thead>
                <tr><th>Username</th><th>Normalized</th><th/></tr>
            </thead>
            <tbody>
                @foreach (AppUser user in Model.Users) {
                    <tr>
                        <td>@user.UserName</td>
                        <td>@user.NormalizedUserName</td>
                        <td>
                            <form asp-page-handler="delete" method="post">
                                <partial name="_UserTableRow" model="@user.Id" />
                                <input type="hidden" name="id" value="@user.Id" />
                                <button type="submit" class="btn btn-sm btn-danger">
                                    Delete
                                </button>
                            </form>
                        </td>
                    </tr>
                }
            </tbody>
        </table>
    } else if (!string.IsNullOrEmpty(Model.Searchname)) {
        <h6>No match</h6>
    }
    <a asp-page="edituser" class="btn btn-primary">Create</a>
</div>

Listing 16-16.The Contents of the Users.cshtml File in the Pages/Store Folder

Razor 页面提供了一个input元素,允许用户输入搜索词,以及一个显示搜索返回的用户详细信息的表格。表中的每一行都将显示用户摘要和一系列按钮,这些按钮将用于管理商店中的数据。

为了创建页面模型,定义页面模型类的Users.cshtml.cs类文件如清单 16-17 所示。如果您使用 Visual Studio Razor 页面模板创建了Users.cshtml文件,那么这个类文件就已经创建好了。如果您使用的是 Visual Studio 代码,将名为Users.cshtml.cs的文件添加到ExampleApp/Pages/Store文件夹中。

using ExampleApp.Identity;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace ExampleApp.Pages.Store {

    public class FindUserModel : PageModel {

        public FindUserModel(UserManager<AppUser> userMgr) {
            UserManager = userMgr;
        }

        public UserManager<AppUser> UserManager { get; set; }

        public IEnumerable<AppUser> Users { get; set; }
            = Enumerable.Empty<AppUser>();

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

        public async Task OnGet() {
            if (Searchname != null) {
                AppUser nameUser = await UserManager.FindByNameAsync(Searchname);
                if (nameUser != null) {
                    Users = Users.Append(nameUser);
                }
                AppUser idUser = await UserManager.FindByIdAsync(Searchname);
                if (idUser!= null) {
                    Users = Users.Append(idUser);
                }
            }
        }

        public async Task<IActionResult> OnPostDelete(string id) {
            AppUser user = await UserManager.FindByIdAsync(id);
            if (user != null) {
                await UserManager.DeleteAsync(user);
            }
            return RedirectToPage();
        }
    }
}

Listing 16-17.The Contents of the Users.cshtml.cs File in the Pages/Store Folder

这个 Razor 页面的页面模型类声明了对UserManager<AppUser>服务的依赖,通过这个服务,它可以按名称或 ID 在商店中搜索用户。POST 处理程序方法接收一个 ID 值,并通过调用UserManager<T>.DeleteAsync方法使用它来删除用户。

为了创建显示按钮的局部视图,这些按钮将导致其他管理特性,添加一个名为_UserTableRow.cshtml的 Razor 视图到Pages/Store文件夹,其内容如清单 16-18 所示。

@model string

<a asp-page="edituser" asp-route-id="@Model" class="btn btn-sm btn-secondary">
    Edit
</a>

Listing 16-18.The Contents of the _UserTableRow.cshtml File in the Pages/Store Folder

当用户点击编辑按钮时,浏览器被重定向到一个名为EditUser的 Razor 页面,一个名为id的路由变量提供了所选用户的 ID。在Pages/Store文件夹中添加一个名为EditUser.cshtml的 Razor 页面,内容如清单 16-19 所示。

@page "/users/edit/{id?}"
@model ExampleApp.Pages.Store.UsersModel

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

<div class="m-2">
    <form method="post">
        <input type="hidden" name="id" value="@Model.AppUserObject.Id" />
        <table class="table table-sm table-striped">
            <tbody>
                <partial name="_EditUserBasic" model="@Model.AppUserObject" />
            </tbody>
        </table>
        <div>
            <button type="submit" class="btn btn-primary">Save</button>
            <a asp-page="users" class="btn btn-secondary">Cancel</a>
        </div>
    </form>
</div>

Listing 16-19.The Contents of the EditUser.cshtml File in the Pages/Store Folder

Razor 页面显示一个允许显示和编辑用户对象属性的表格,还有一个保存按钮,它将应用用户存储中的更改,还有一个取消按钮,它将返回到Users Razor 页面。随着 Identity 特性的引入,我将逐步构建编辑支持,每个特性都有自己的局部视图,可以向表中添加行。

使用EditUser.cshtml.cs文件定义页面模型类,如清单 16-20 所示。如果您使用的是 Visual Studio 代码,则必须创建该文件。

using ExampleApp.Identity;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.Threading.Tasks;

namespace ExampleApp.Pages.Store {

    public class UsersModel : PageModel {

        public UsersModel(UserManager<AppUser> userMgr) => UserManager = userMgr;

        public UserManager<AppUser> UserManager { get; set; }

        public AppUser AppUserObject { get; set; } = new AppUser();

        public async Task OnGetAsync(string id) {
            if (id != null) {
                AppUserObject = await UserManager.FindByIdAsync(id) ?? new AppUser();
            }
        }

        public async Task<IActionResult> OnPost(AppUser user) {
            IdentityResult result;
            AppUser storeUser = await UserManager.FindByIdAsync(user.Id);
            if (storeUser == null) {
                result = await UserManager.CreateAsync(user);
            } else {
                storeUser.UpdateFrom(user);
                result = await UserManager.UpdateAsync(storeUser);
            }
            if (result.Succeeded) {
                return RedirectToPage("users", new { searchname = user.Id });
            } else {
                foreach (IdentityError err in result.Errors) {
                    ModelState.AddModelError("", err.Description ?? "Error");
                }
                AppUserObject = user;
                return Page();
            }
        }
    }
}

Listing 16-20.The Contents of the EditUser.cshtml.cs File in the Pages/Store Folder

GET page 处理程序接收一个 ID 值,用于定位用户对象。POST 处理程序方法依靠 ASP.NET Core 模型绑定器从 HTTP 请求创建一个AppUser对象。如果存储中没有对象的 ID 是请求中接收到的,那么就用CreateAsync方法存储该对象。如果有一个已存在的对象,那么我将属性值从模型绑定器创建的AppUser对象复制到从用户存储中检索的对象,然后使用UpdateAsync方法存储更改。

...
storeUser.UpdateFrom(user);
result = await UserManager.UpdateAsync(storeUser);
...

这种方法允许我处理只为 user 类定义的某些属性提供值的 HTTP 请求。如果没有这一步,我将使用 HTTP 请求中没有可用值的属性的null或默认值来覆盖存储中的数据。

Razor 页面依赖于一个局部视图来显示用于编辑的单个字段,这将使我以后添加特性更加容易。在Pages/Store文件夹中添加一个名为_EditUserBasic.cshtml的 Razor 视图,内容如清单 16-21 所示。

@model AppUser
<tr>
    <td>ID</td>
    <td>@Model.Id</td>
</tr>
<tr>
    <td>Username</td>
    <td>
        <input class="w-00" asp-for="UserName" />
    </td>
</tr>
<tr>
    <td>Normalized UserName</td>
    <td>
        @(Model.NormalizedUserName ?? "(Not Set)")
        <input type="hidden" asp-for="NormalizedUserName" />
    </td>
</tr>

Listing 16-21.The Contents of the _EditUserBasic.cshtml File in the Pages/Store Folder

重启 ASP.NET Core,请求http://localhost:5000/users,在文本字段中输入 1 并点击查找按钮。单击编辑按钮,将用户名更改为 AliceSmith,然后单击保存按钮。更新后的摘要显示新用户名已经保存,如图 16-2 所示。

img/508695_1_En_16_Fig2_HTML.jpg

图 16-2。

访问用户数据

存储操作的结果用IdentityResult对象来描述,我在本章前面已经描述过了。当操作失败时,我将问题的细节添加到模型状态中,这样我就可以使用 ASP.NET Core 数据验证特性向用户显示错误。你可以在本章的后面看到它是如何工作的。

请注意,当您更改UserName属性时,规范化用户名会更新。这是由表 16-3 中描述的CreateAsyncUpdateAsync方法执行的过程的一部分。

添加可选商店功能

用户存储可以工作,但是它没有包含足够有用的数据。Identity 使用一系列可选接口来存储实现,以声明它们可以存储其他数据类型。表 16-6 描述了最重要的接口。(还有其他支持登录和认证的接口,我将在后面的章节中介绍。)

表 16-6。

可选的用户存储界面

|

名字

|

描述

| | --- | --- | | IQueryableUserStore<T> | 该接口由允许使用 LINQ 查询用户的用户存储实现。我在“查询用户存储”一节中实现了这个接口。 | | IUserEmailStore<T> | 该接口由可以管理电子邮件地址的用户存储实现。我将在本节的后面实现这个接口。 | | IUserPhoneNumberStore<T> | 该接口由可以管理电话号码和地址的用户存储实现。我将在本节的后面实现这个接口。 | | IUserPasswordStore<T> | 该接口由可以管理密码的用户存储实现。我在第 18 章中使用这个接口。 | | IUserClaimStore<T> | 该接口由可以管理声明的用户存储实现。我在第 17 章中使用这个接口。 | | IUserRoleStore<T> | 该接口由可以管理角色的用户存储实现。我在第 17 章中使用这个接口。 |

添加对查询用户存储的支持

在上一节中创建的 Razor 页面允许您通过名称或 ID 查找用户,但是要求完全匹配。一种更灵活的方法是允许使用 LINQ 查询用户数据,并且可以查询的用户存储实现了IQueryableUserStore<T>接口,该接口定义了表 16-7 中描述的属性。

表 16-7。

IQueryableUserStore 接口

|

名字

|

描述

| | --- | --- | | Users | 该属性返回一个IQueryable<T>对象,其中T是商店的用户类。 |

我在本章中为自定义用户存储所采用的方法使得通过添加另一个分部类来添加新特性变得容易。为了添加对查询用户数据的支持,在Identity/Store文件夹中添加一个名为UserStoreQueryable.cs的类文件,代码如清单 16-22 所示。

using Microsoft.AspNetCore.Identity;
using System.Linq;

namespace ExampleApp.Identity.Store {

    public partial class UserStore : IQueryableUserStore<AppUser> {

        public IQueryable<AppUser> Users => users.Values
            .Select(user => user.Clone()).AsQueryable<AppUser>();
    }
}

Listing 16-22.The Contents of the UserStoreQueryable.cs File in the Identity/Store Folder

为了实现这个接口,我使用了AsQueryable<T>扩展方法,这是 LINQ 提供的将IEnumerable<T>对象转换成IQueryable<T>对象的方法,这样它们就可以在 LINQ 查询中使用。LINQ Select方法用于复制AppUser对象,以便在执行显式更新之前不会将更改添加到存储中。

查询用户存储

UserManager<T>类定义了两个属性,用于访问通过IQueryableStore<T>接口提供的功能,如表 16-8 所述。

表 16-8。

可查询用户存储的 UserManager 属性

|

名字

|

描述

| | --- | --- | | SupportsQueryableUsers | 如果用户存储实现了IQueryableUserStore<T>接口,该属性将返回true。 | | Users | 该属性返回一个IQueryable<T>对象,其中T是商店的用户类。 |

在清单 16-23 中,我已经更新了Users Razor 页面模型类中的 GET 处理程序,以使用表 16-8 中的属性。

...
public async Task OnGet() {
    if (UserManager.SupportsQueryableUsers) {
        string normalizedName =
            UserManager.NormalizeName(Searchname ?? string.Empty);
        Users = string.IsNullOrEmpty(Searchname)
            ? UserManager.Users.OrderBy(u => u.UserName)
            : UserManager.Users.Where(user => user.Id == Searchname ||
                user.NormalizedUserName.Contains(normalizedName))
                .OrderBy(u => u.UserName);
    } else if (Searchname != null) {
        AppUser nameUser = await UserManager.FindByNameAsync(Searchname);
        if (nameUser != null) {
            Users = Users.Append(nameUser);
        }
        AppUser idUser = await UserManager.FindByIdAsync(Searchname);
        if (idUser!= null) {
            Users = Users.Append(idUser);
        }
    }
}
...

Listing 16-23.Querying Users in the Users.cshtml.cs File in the Pages/Store Folder

在执行查询之前检查SupportsQueryableUsers属性的值很重要,因为如果商店没有实现IQueryableStore<T>接口,读取Users属性将导致异常。

在清单中,我使用带有 LINQ Where方法的UserManager<T>.Users属性来查找AppUser对象的NormalizedUserName属性包含输入到文本字段中的搜索词,或者Id属性与搜索词完全匹配。如果没有指定搜索词,将显示所有用户。

我对搜索项进行了规范化以执行 LINQ 查询,这可以使用表 16-9 中描述的UserManager<T>类提供的规范化方法来完成。这些都是方便的方法,因此组件不必直接声明对ILookupNormalizer服务的依赖。

表 16-9。

用户管理器规范化便利方法

|

名字

|

描述

| | --- | --- | | NormalizeName(name) | 这个方法调用ILookupNormalizer服务上的NormalizeName方法。 | | NormalizeEmail(email) | 这个方法调用ILookupNormalizer服务上的NormalizeEmail方法。 |

要在用户存储上执行查询,重启 ASP.NET Core,并请求http://localhost:5000/users。在文本字段中输入,然后单击查找按钮。将显示两个匹配,如图 16-3 所示。

img/508695_1_En_16_Fig3_HTML.jpg

图 16-3。

查询用户存储

添加对存储电子邮件地址和电话号码的支持

大多数应用存储电子邮件地址和电话号码,或者用来识别用户或者与他们通信。可以管理电子邮件地址的用户存储实现了IUserEmailStore<T>接口,其中T是用户类。IUserEmailStore<T>接口定义了表 16-10 所示的方法。(与其他用户存储接口一样,表中显示的所有方法都定义了一个token参数,通过该参数提供了一个CancellationToken对象,允许在异步操作被取消时接收通知。)

表 16-10。

IUserEmailStore 方法

|

名字

|

描述

| | --- | --- | | FindByEmailAsync(email, token) | 该方法返回具有指定的规范化电子邮件地址的 user 类实例。 | | GetEmailAsync(user, token) | 此方法返回指定用户对象的电子邮件地址。 | | SetEmailAsync(user, email, token) | 此方法为指定的用户对象设置电子邮件地址。 | | GetNormalizedEmailAsync(user, token) | 此方法返回指定用户对象的规范化电子邮件地址。 | | SetNormalizedEmailAsync(user, email, token) | 此方法为指定的用户对象设置规范化的电子邮件地址。 | | GetEmailConfirmedAsync(user, token) | 这个方法获取用于指示电子邮件地址是否已被确认的属性值,我在第 17 章中演示了这个方法。 | | SetEmailConfirmedAsync(user, confirmed, token) | 这个方法设置属性的值,用来指示电子邮件地址是否已经被确认,我在第 17 章演示了这个方法。 |

可以存储电话号码的用户存储实现了IUserPhoneNumberStore<T>接口,该接口定义了表 16-11 中描述的方法。

表 16-11。

IUserPhoneNumberStore 方法

|

名字

|

描述

| | --- | --- | | SetPhoneNumberAsync(user, phone, token) | 此方法为指定用户设置电话号码。 | | GetPhoneNumberAsync(user, token) | 此方法为指定用户设置电话号码。 | | GetPhoneNumberConfirmedAsync(user, token) | 这个方法获取用于指示电话号码是否已被确认的属性的值,我在第 17 章中演示了这个方法。 | | SetPhoneNumberConfirmedAsync(user, token) | 这个方法设置用于指示电话号码是否已经确认的属性的值,我在第 17 章中演示了这个方法。 |

第一步是向用户类添加属性来存储电子邮件和电话数据,如清单 16-24 所示。

using System;

namespace ExampleApp.Identity {
    public class AppUser {

        public string Id { get; set; } = Guid.NewGuid().ToString();

        public string UserName { get; set; }

        public string NormalizedUserName { get; set; }

        public string EmailAddress { get; set; }
        public string NormalizedEmailAddress { get; set; }
        public bool EmailAddressConfirmed { get; set; }

        public string PhoneNumber { get; set; }
        public bool PhoneNumberConfirmed { get; set; }
    }
}

Listing 16-24.Adding Properties to the User Class in the AppUser.cs File in the Identity Folder

与用户名一样,Identity 存储电子邮件地址的常规和规范化版本,因此我为这些值在 user 类中添加了两个属性。我还添加了用于确认用户电子邮件地址和电话号码的属性,我在第 17 章对此进行了描述。

接下来,将名为UserStoreEmail.cs的类文件添加到Identity/Store方法中,并使用它来定义清单 16-25 中所示的分部类,该分部类扩展了用户存储类以支持IUserEmailStore<T>接口。

using Microsoft.AspNetCore.Identity;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace ExampleApp.Identity.Store {

    public partial class UserStore : IUserEmailStore<AppUser> {

        public Task<AppUser> FindByEmailAsync(string normalizedEmail,
                CancellationToken token) =>
            Task.FromResult(Users.FirstOrDefault(user =>
                user.NormalizedEmailAddress == normalizedEmail));

        public Task<string> GetEmailAsync(AppUser user,
                CancellationToken token) =>
            Task.FromResult(user.EmailAddress);

        public Task SetEmailAsync(AppUser user, string email,
                CancellationToken token) {
            user.EmailAddress = email;
            return Task.CompletedTask;
        }

        public Task<string> GetNormalizedEmailAsync(AppUser user,
                CancellationToken token) =>
            Task.FromResult(user.NormalizedEmailAddress);

        public Task SetNormalizedEmailAsync(AppUser user, string normalizedEmail,
                CancellationToken token) {
            user.NormalizedEmailAddress = normalizedEmail;
            return Task.CompletedTask;
        }

        public Task<bool> GetEmailConfirmedAsync(AppUser user,
                CancellationToken token) =>
            Task.FromResult(user.EmailAddressConfirmed);

        public Task SetEmailConfirmedAsync(AppUser user, bool confirmed,
                CancellationToken token) {
            user.EmailAddressConfirmed = confirmed;
            return Task.CompletedTask;
        }
    }
}

Listing 16-25.The Contents of the UserStoreEmail.cs File in the Identity/Store Folder

一旦有了核心特性,添加对可选接口的支持就很简单了。使用针对由IQueryableUserStore<T>接口定义的Users属性的 LINQ 查询来实现FindByEmailAsync方法。GetEmailAsyncSetEmailAsyncGetNormalizedEmailAsyncSetNormalizedEmailAsync方法依赖于添加到清单 16-25 中的用户类的属性。第 17 章描述了GetEmailConfirmedAsyncSetEmailConfirmedAsync方法。

接下来,将名为UserStorePhone.cs的类文件添加到Identity/Store文件夹中,并使用它来定义清单 16-26 中所示的分部类,该分部类扩展了用户存储以实现IUserPhoneNumberStore<T>接口。

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

namespace ExampleApp.Identity.Store {
    public partial class UserStore : IUserPhoneNumberStore<AppUser> {
        public Task<string> GetPhoneNumberAsync(AppUser user,
            CancellationToken token) => Task.FromResult(user.PhoneNumber);

        public Task SetPhoneNumberAsync(AppUser user, string phoneNumber,
                CancellationToken token) {
            user.PhoneNumber = phoneNumber;
            return Task.CompletedTask;
        }

        public Task<bool> GetPhoneNumberConfirmedAsync(AppUser user,
            CancellationToken token) => Task.FromResult(user.PhoneNumberConfirmed);

        public Task SetPhoneNumberConfirmedAsync(AppUser user, bool confirmed,
                CancellationToken token) {
            user.PhoneNumberConfirmed = confirmed;
            return Task.CompletedTask;
        }
    }
}

Listing 16-26.The Contents of the UserStorePhone.cs File in the Identity/Store Folder

要用电子邮件地址和电话号码作为用户存储的种子,将清单 16-27 中所示的语句添加到Identity/Store文件夹中的UserStore.cs文件中。

using Microsoft.AspNetCore.Identity;

namespace ExampleApp.Identity.Store {

    public partial class UserStore {

        public ILookupNormalizer Normalizer { get; set; }

        public UserStore(ILookupNormalizer normalizer) {
            Normalizer = normalizer;
            SeedStore();
        }

        private void SeedStore() {

            int idCounter = 0;

            string EmailFromName(string name) => $"{name.ToLower()}@example.com";

            foreach (string name in UsersAndClaims.Users) {
                AppUser user = new AppUser {
                    Id = (++idCounter).ToString(),
                    UserName = name,
                    NormalizedUserName = Normalizer.NormalizeName(name),
                    EmailAddress = EmailFromName(name),
                    NormalizedEmailAddress =
                        Normalizer.NormalizeEmail(EmailFromName(name)),
                    EmailAddressConfirmed = true,
                    PhoneNumber = "123-4567",
                    PhoneNumberConfirmed = true
                };
                users.TryAdd(user.Id, user);
            }
        }
    }
}

Listing 16-27.Adding Seed Data in the UserStore.cs File in the Identity/Store Folder

清单中的变化在example.com域中为每个用户生成电子邮件地址,并为每个用户分配相同的电话号码。使用表 16-4 中描述的NormalizeEmail方法对电子邮件地址进行规范化。

使用电子邮件地址和电话号码

UserManager<T>类定义了表 16-12 中所示的成员,用于处理电子邮件地址。

表 16-12。

电子邮件地址的用户管理员成员

|

名字

|

描述

| | --- | --- | | SupportsUserEmail | 如果存储实现了IUserEmailStore<T>接口,该属性返回 true。 | | FindByEmailAsync(email) | 这种方法通过电子邮件地址定位用户。电子邮件地址在被传递给商店的FindByEmailAsync方法之前被规范化。 | | GetEmailAsync(user) | 此方法返回 user 类的指定实例的电子邮件地址。 | | SetEmailAsync(user, email) | 此方法为 user 类的指定实例设置电子邮件地址。电子邮件地址被传递给存储的SetEmailAsync方法,调用SetEmailConfirmedAsync方法将确认状态设置为false,之后更新安全标记并应用用户存储的更新序列。第 17 章描述安全邮票。 |

为了允许编辑电子邮件地址,在Pages/Store文件夹中添加一个名为_EditUserEmail.cshtml的 Razor 视图,其内容如清单 16-28 所示。

@model AppUser
@inject UserManager<AppUser> UserManager

@if (UserManager.SupportsUserEmail) {
    <tr>
        <td>Email</td>
        <td>
            <input class="w-00" asp-for="EmailAddress" />
        </td>
    </tr>
    <tr>
        <td>Normalized Email</td>
        <td>
            @(Model.NormalizedEmailAddress?? "(Not Set)")
            <input type="hidden" asp-for="NormalizedEmailAddress" />
            <input type="hidden" asp-for="EmailAddressConfirmed" />
        </td>
    </tr>
}

Listing 16-28.The Contents of the _EditUserEmail.cshtml File in the Pages/Store Folder

还有一组管理电话号码的成员,如表 16-13 所述。

表 16-13。

电话号码的用户经理成员

|

名字

|

描述

| | --- | --- | | SupportsUserPhoneNumber | 如果存储实现了IUserPhoneNumberStore<T>接口,该属性返回true。 | | GetPhoneNumberAsync(user) | 这个方法通过调用 store 的GetPhoneNumberAsync方法来获取指定用户的电话号码。 | | SetPhoneNumberAsync(user, number) | 该方法通过调用商店的SetPhoneNumberAsync方法为用户设置电话号码。调用商店的SetPhoneNumberConfirmedAsync方法将确认状态设置为 false,更新安全戳(如第 17 章所述),用户接受验证(如“验证用户数据”一节所述),并应用用户管理器的更新序列。 |

为了允许设置电话号码,在Pages/Store文件夹中创建一个名为_EditUserPhone.cshtml的 Razor 视图,内容如清单 16-29 所示。

@model AppUser
@inject UserManager<AppUser> UserManager

@if (UserManager.SupportsUserPhoneNumber) {
    <tr>
        <td>Phone</td>
        <td>
            <input class="w-00" asp-for="PhoneNumber" />
            <input type="hidden" asp-for="PhoneNumberConfirmed" />
        </td>
    </tr>
}

Listing 16-29.The Contents of the _EditUserPhone.cshtml File in the Pages/Store Folder

将清单 16-30 中所示的元素添加到Pages/Store文件夹中的EditUser.cshtml文件中,以将新视图合并到应用中。

@page "/users/edit/{id?}"
@model ExampleApp.Pages.Store.UsersModel

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

<div class="m-2">
    <form method="post">
        <input type="hidden" name="id" value="@Model.AppUserObject.Id" />
        <table class="table table-sm table-striped">
            <tbody>
                <partial name="_EditUserBasic" model="@Model.AppUserObject" />
                <partial name="_EditUserEmail" model="@Model.AppUserObject" />
                <partial name="_EditUserPhone" model="@Model.AppUserObject" />
            </tbody>
        </table>
        <div>
            <button type="submit" class="btn btn-primary">Save</button>
            <a asp-page="users" class="btn btn-secondary">Cancel</a>
        </div>
    </form>
</div>

Listing 16-30.Adding Partial Views in the EditUser.cshtml File in the Pages/Store Folder

重启 ASP.NET Core,请求http://localhost:5000/users,点击其中一个用户的编辑按钮,查看为电子邮件地址和电话号码添加的附加属性,如图 16-4 所示。

img/508695_1_En_16_Fig4_HTML.jpg

图 16-4。

添加对存储电子邮件地址和电话号码的支持

添加自定义用户类属性

用户存储可以定义特定于应用的附加属性。这是一个在使用声明之前的特性,并不是必需的,因为关于用户的任何数据都可以用声明来表示。但是它仍然被支持,所以清单 16-31AppUser类添加了新的属性。(我将在第 17 章中解释如何管理用户商店中的索赔。)

Note

自定义用户类属性并不像它们看起来那样有用,因为当用户登录时,它们不会添加到 ASP.NET Core 的数据提供者中,除非您也创建了自定义翻译类,如第 18 章所示。我的建议是将所有附加数据存储为索赔,这样更简单,并且会自动提交给 ASP.NET Core。

using System;

namespace ExampleApp.Identity {
    public class AppUser {

        public string Id { get; set; } = Guid.NewGuid().ToString();

        public string UserName { get; set; }

        public string NormalizedUserName { get; set; }

        public string EmailAddress { get; set; }
        public string NormalizedEmailAddress { get; set; }
        public bool EmailAddressConfirmed { get; set; }

        public string PhoneNumber { get; set; }
        public bool PhoneNumberConfirmed { get; set; }

        public string FavoriteFood { get; set; }
        public string Hobby { get; set; }
    }
}

Listing 16-31.Adding Custom Properties in the AppUser.cs File in the Identity Folder

FavoriteFoodHobby属性是string属性。将名为_EditUserCustom.cshtml的 Razor 视图添加到Pages/Store文件夹中,并使用它来定义清单 16-32 中所示的局部视图,其中包含编辑添加到清单 31 中的 user 类的属性所需的元素。

@model AppUser

<tr>
    <td>Favorite Food</td>
    <td><input class="w-100" asp-for="FavoriteFood" /></td>
</tr>
<tr>
    <td>Hobby</td>
    <td><input class="w-100" asp-for="Hobby" /></td>
</tr>

Listing 16-32.The Contents of the _EditUserCustom.cshtml File in the Pages/Store Folder

将清单 16-33 中所示的元素添加到Pages/Store文件夹中的EditUser.cshtml文件中,以将局部视图合并到应用中。

@page "/users/edit/{id?}"
@model ExampleApp.Pages.Store.UsersModel

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

<div class="m-2">
    <form method="post">
        <input type="hidden" name="id" value="@Model.AppUserObject.Id" />
        <table class="table table-sm table-striped">
            <tbody>
                <partial name="_EditUserBasic" model="@Model.AppUserObject" />
                <partial name="_EditUserEmail" model="@Model.AppUserObject" />
                <partial name="_EditUserPhone" model="@Model.AppUserObject" />
                <partial name="_EditUserCustom" model="@Model.AppUserObject" />
            </tbody>
        </table>
        <div>
            <button type="submit" class="btn btn-primary">Save</button>
            <a asp-page="users" class="btn btn-secondary">Cancel</a>
        </div>
    </form>
</div>

Listing 16-33.Adding an Element in the EditUser.cshtml File in the Pages/Store Folder

为了给用户存储植入新属性的值,将清单 16-34 中所示的语句添加到Identity/Store文件夹中的UserStore.cs文件中。

using Microsoft.AspNetCore.Identity;
using System.Collections.Generic;

namespace ExampleApp.Identity.Store {

    public partial class UserStore {

        public ILookupNormalizer Normalizer { get; set; }

        public UserStore(ILookupNormalizer normalizer) {
            Normalizer = normalizer;
            SeedStore();
        }

        private void SeedStore() {

            var customData = new Dictionary<string, (string food, string hobby)> {
                { "Alice", ("Pizza", "Running") },
                { "Bob", ("Ice Cream", "Cinema") },
                { "Charlie", ("Burgers", "Cooking") }
            };

            int idCounter = 0;

            string EmailFromName(string name) => $"{name.ToLower()}@example.com";

            foreach (string name in UsersAndClaims.Users) {
                AppUser user = new AppUser {
                    Id = (++idCounter).ToString(),
                    UserName = name,
                    NormalizedUserName = Normalizer.NormalizeName(name),
                    EmailAddress = EmailFromName(name),
                    NormalizedEmailAddress =
                        Normalizer.NormalizeEmail(EmailFromName(name)),
                    EmailAddressConfirmed = true,
                    PhoneNumber = "123-4567",
                    PhoneNumberConfirmed = true,
                    FavoriteFood = customData[name].food,
                    Hobby = customData[name].hobby
                };
                users.TryAdd(user.Id, user);
            }
        }
    }
}

Listing 16-34.Seeding the Store in the UserStore.cs File in the Identity/Store Folder

重启 ASP.NET Core,请求http://localhost:5000/users,点击其中一个编辑按钮。您将看到自定义属性,填充了种子数据,如图 16-5 所示。

img/508695_1_En_16_Fig5_HTML.jpg

图 16-5。

向用户类添加自定义属性

验证用户数据

在将用户对象传递给存储之前,CreateAsyncUpdateAsync方法验证用户对象。要查看验证示例,请请求http://localhost:5000/users,单击 Bob 的编辑按钮,并在用户名字段中输入 Charlie。点击保存,您将看到如图 16-6 所示的响应。

img/508695_1_En_16_Fig6_HTML.jpg

图 16-6。

对用户数据的验证响应

验证约束使用IUserValidator<T>接口表示,其中T是用户类。UserManager<T>类使用依赖注入来接收已经注册的一组IUserValidator<T>服务,并使用表 16-14 中描述的方法来验证用户对象。

表 16-14。

IUserValidator 方法

|

名字

|

描述

| | --- | --- | | ValidateAsync(manager, user) | 该方法验证指定的用户对象,并可以通过UserManager<T>参数访问用户存储。验证结果用一个IdentityResult对象表示。 |

接口的默认实现检查用户名和电子邮件地址是唯一的,并且只包含允许的字符。正是这个验证器产生了如图 16-6 所示的错误信息。

Configuring the Default User Validator

默认的验证器可以使用Startup类中的选项模式来配置。有两个配置选项可用,通过IdentityOptions.User属性访问,如下所示:

...
services.AddIdentityCore<AppUser>(opts => {
    opts.User.AllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyz";
    opts.User.RequireUniqueEmail = true;
});
...

属性指定了用户名可以包含的字符集。默认值允许大写和小写字符、数字和一些符号。RequireUniqueEmail属性指定电子邮件地址是否必须是唯一的,默认为false

要创建一个定制的验证器,用清单 16-35 中所示的代码将一个名为EmailValidator.cs的类文件添加到ExampleApp/Identity文件夹中。

using Microsoft.AspNetCore.Identity;
using System.Linq;
using System.Threading.Tasks;

namespace ExampleApp.Identity {
    public class EmailValidator : IUserValidator<AppUser> {
        private static string[] AllowedDomains = new[] { "example.com", "acme.com" };
        private static IdentityError err
            = new IdentityError { Description = "Email address domain not allowed" };

        public EmailValidator(ILookupNormalizer normalizer) {
            Normalizer = normalizer;
        }

        private ILookupNormalizer Normalizer { get; set; }

        public Task<IdentityResult> ValidateAsync(UserManager<AppUser> manager,
                AppUser user) {
            string normalizedEmail = Normalizer.NormalizeEmail(user.EmailAddress);
            if (AllowedDomains.Any(domain =>
                    normalizedEmail.EndsWith($"@{domain}"))) {
                return Task.FromResult(IdentityResult.Success);
            }
            return Task.FromResult(IdentityResult.Failed(err));
        }
    }
}

Listing 16-35.The Contents of the EmailValidator.cs File in the Identity Folder

注册自定义验证器时必须小心。如果在调用AddIdentityCore方法之前没有现有的IUserValidator<T>服务,Identity 将只添加默认的验证器。这意味着如果您想替换默认验证器,您应该在AddIdentityCore方法之前注册定制服务,如果您想保留默认验证器,您应该在AddIdentityCore方法之后注册定制服务。我想补充默认的验证,所以我在设置了 Identity 之后注册了我的自定义类,如清单 16-36 所示。

...
public void ConfigureServices(IServiceCollection services) {
    services.AddSingleton<ILookupNormalizer, Normalizer>();
    services.AddSingleton<IUserStore<AppUser>, UserStore>();

    services.AddIdentityCore<AppUser>();

    services.AddSingleton<IUserValidator<AppUser>, EmailValidator>();

    services.AddAuthentication(opts => {
        opts.DefaultScheme
            = CookieAuthenticationDefaults.AuthenticationScheme;
    }).AddCookie(opts => {
        opts.LoginPath = "/signin";
        opts.AccessDeniedPath = "/signin/403";
    });
    services.AddAuthorization(opts => {
        AuthorizationPolicies.AddPolicies(opts);
    });
    services.AddRazorPages();
    services.AddControllersWithViews();
}
...

Listing 16-36.Registering a User Validator in the Startup.cs File in the ExampleApp Folder

要查看新验证器的效果,重启 ASP.NET Core,请求http://localhost:5000/users,并为 Alice 用户单击 Edit 按钮。将电子邮件字段更改为 alice@mycompany.com,然后单击保存按钮。新的验证器类将产生如图 16-7 所示的错误。

img/508695_1_En_16_Fig7_HTML.jpg

图 16-7。

自定义用户验证程序

摘要

在本章中,我描述了由用户存储实现的接口。我创建了一个自定义用户存储,它支持核心特性,还支持 LINQ 查询、电子邮件地址和电话号码,以及自定义用户类属性。我还解释了如何在将用户数据添加到存储之前对其进行验证。在下一章中,我将添加对声明、角色和用户确认的支持。