使用 ASP.NET 标识进行帐户确认和密码恢复 (C#)

在完成本教程之前,应首先完成 使用登录、电子邮件确认和密码重置创建安全 ASP.NET MVC 5 Web 应用。 本教程包含更多详细信息,介绍如何为本地帐户确认设置电子邮件,并允许用户在 ASP.NET 标识中重置忘记的密码。

本地用户帐户要求用户为该帐户创建密码,并且该密码存储在 Web 应用中(安全)。 ASP.NET 标识还支持社交帐户,无需用户为应用创建密码。 社交帐户 使用第三方(如 Google、Twitter、Facebook 或 Microsoft)对用户进行身份验证。 本主题涵盖以下内容:

新用户注册其电子邮件别名,用于创建本地帐户。

帐户注册窗口的图像

选择“注册”按钮会将包含验证令牌的确认电子邮件发送到其电子邮件地址。

显示电子邮件已发送确认信息的图像

向用户发送一封电子邮件,其中包含其帐户的确认令牌。

确认令牌 的 图像

选择链接将确认帐户。

确认电子邮件地址的图像

密码恢复/重置

忘记密码的本地用户可以将安全令牌发送到其电子邮件帐户,使他们能够重置其密码。

忘记密码重置窗口的图像

用户很快就会收到一封电子邮件,其中包含允许他们重置其密码的链接。

显示重置密码电子邮件的图像
选择链接会将他们带到“重置”页面。

显示用户密码重置窗口的图像

选择“重置”按钮将确认密码已重置。

显示密码已重置确认信息的图像 的图像

创建 ASP.NET Web 应用

首先安装和运行 Visual Studio 2017

  1. 创建新的 ASP.NET Web 项目并选择 MVC 模板。 Web 窗体还支持 ASP.NET 标识,因此可以在 Web 窗体应用中执行类似的步骤。

  2. 将身份验证更改为 个人用户帐户

  3. 运行应用,选择 注册 链接并注册用户。 此时,对电子邮箱进行的唯一验证是通过 [EmailAddress] 属性进行的。

  4. 在服务器资源管理器中,导航到 数据连接\DefaultConnection\Tables\AspNetUsers,右键单击并选择 打开表定义

    下图显示了 AspNetUsers 架构:

    显示 AspNetUsers 架构的图像

  5. 右键单击“AspNetUsers”表,并选择“显示表数据”

    显示表数据的图像

    此时尚未确认电子邮箱。

ASP.NET 标识的默认数据存储是 Entity Framework,但你可以将其配置为使用其他数据存储并添加其他字段。 请参阅本教程末尾的 其他资源 部分。

当应用启动并调用 App_Start\Startup.Auth.cs中的 ConfigureAuth 方法时,将调用 OWIN 启动类Startup.cs),该方法配置 OWIN 管道并初始化 ASP.NET 标识。 检查 ConfigureAuth 方法。 每个 CreatePerOwinContext 调用都会注册一个回调(保存在 OwinContext中),每个请求都会调用一次,以创建指定类型的实例。 可以在每种类型 (ApplicationDbContext, ApplicationUserManager) 的构造函数和 Create 方法中设置断点,并验证是否会对每个请求调用它们。 ApplicationDbContextApplicationUserManager 的实例存储在 OWIN 上下文中,可在整个应用程序中访问。 ASP.NET Identity 通过 Cookie 中间件挂接到 OWIN 管道。 有关详细信息,请参阅按请求对 ASP.NET Identity 中的 UserManager 类进行的生命周期管理

更改安全配置文件时,会在 AspNetUsers 表的 SecurityStamp 字段中生成并存储新的安全标记。 请注意,SecurityStamp 字段不同于安全 Cookie。 安全 Cookie 不会存储在 AspNetUsers 表中(或标识数据库中的其他任何地方)。 安全 Cookie 令牌使用 DPAPI 进行自签名,并使用 UserId, SecurityStamp 和到期时间信息进行创建。

在每次请求中,Cookie 中间件会检查 Cookie。 Startup 类中的 SecurityStampValidator 方法会根据 validateInterval 指定的设置定期访问数据库并检查安全戳。 这只会每隔 30 分钟(在我们的示例中)发生一次,除非你更改了安全配置文件。 选择 30 分钟的间隔,以尽量减少对数据库的查询。 有关详细信息,请参阅 双重身份验证教程

根据代码中的注释,UseCookieAuthentication 方法支持 Cookie 身份验证。 SecurityStamp 字段和关联的代码为你的应用提供了一层额外的安全保护,当更改密码时,你将从已登录的浏览器中注销。 SecurityStampValidator.OnValidateIdentity 方法使应用能够在用户登录时验证安全令牌,该令牌在更改密码或使用外部登录时使用。 这需要确保使用旧密码生成的任何令牌(cookie)都失效。 在示例项目中,如果更改用户密码,则会为用户生成新令牌,任何以前的令牌都失效,并更新 SecurityStamp 字段。

标识系统允许你配置应用,以便在用户安全配置文件更改时(例如,当用户更改其密码或更改关联的登录名(如来自 Facebook、Google、Microsoft 帐户等)时,用户将注销所有浏览器实例。 例如,下图显示了 单一登录示例 应用,该示例允许用户通过选择一个按钮注销所有浏览器实例(在本例中为 IE、Firefox 和 Chrome)。 或者,该示例允许你仅注销特定的浏览器实例。

显示单一注销示例应用窗口的图像

单一注销示例应用展示了 ASP.NET Identity 如何让你能够重新生成安全令牌。 这需要确保使用旧密码生成的任何令牌(cookie)都失效。 此功能为你的应用程序提供了一层额外的安全保护;当更改密码时,你将从这个已登录的应用程序中注销。

App_Start\IdentityConfig.cs 文件包含 ApplicationUserManagerEmailServiceSmsService 类。 每个 EmailServiceSmsService 类都实现 IIdentityMessageService 接口,因此在每个类中都有用于配置电子邮件和短信的常用方法。 虽然本教程仅演示如何通过 SendGrid添加电子邮件通知,但可以使用 SMTP 和其他机制发送电子邮件。

Startup 类还包含用于添加社交登录(如 Facebook、Twitter 等)的样板代码,更多信息请参阅我的教程 使用 Facebook、Twitter、LinkedIn 和 Google OAuth2 登录的 MVC 5 应用

检查 ApplicationUserManager 类,其中包含用户标识信息并配置以下功能:

  • 密码强度要求。
  • 用户锁定(尝试次数和时间)。
  • 双因素身份验证(2FA)。 我将在另一个教程中介绍 2FA 和短信。
  • 挂接电子邮件和短信服务。 (我将在另一个教程中介绍短信)。

ApplicationUserManager 类派生自泛型 UserManager<ApplicationUser> 类。 ApplicationUser 派生自 IdentityUserIdentityUser 派生自泛型 IdentityUser 类:

//     Default EntityFramework IUser implementation
public class IdentityUser<TKey, TLogin, TRole, TClaim> : IUser<TKey>
   where TLogin : IdentityUserLogin<TKey>
   where TRole : IdentityUserRole<TKey>
   where TClaim : IdentityUserClaim<TKey>
{
   public IdentityUser()
   {
      Claims = new List<TClaim>();
      Roles = new List<TRole>();
      Logins = new List<TLogin>();
   }

   ///     User ID (Primary Key)
   public virtual TKey Id { get; set; }

   public virtual string Email { get; set; }
   public virtual bool EmailConfirmed { get; set; }

   public virtual string PasswordHash { get; set; }

   ///     A random value that should change whenever a users credentials have changed (password changed, login removed)
   public virtual string SecurityStamp { get; set; }

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

   public virtual bool TwoFactorEnabled { get; set; }

   ///     DateTime in UTC when lockout ends, any time in the past is considered not locked out.
   public virtual DateTime? LockoutEndDateUtc { get; set; }

   public virtual bool LockoutEnabled { get; set; }

   ///     Used to record failures for the purposes of lockout
   public virtual int AccessFailedCount { get; set; }
   
   ///     Navigation property for user roles
   public virtual ICollection<TRole> Roles { get; private set; }

   ///     Navigation property for user claims
   public virtual ICollection<TClaim> Claims { get; private set; }

   ///     Navigation property for user logins
   public virtual ICollection<TLogin> Logins { get; private set; }
   
   public virtual string UserName { get; set; }
}

上述属性与上面所示 AspNetUsers 表中的属性相吻合。

IUser 上的泛型参数让你能够使用主键的不同类型来派生类。 请参阅 ChangePK 示例,该示例演示如何将主键从字符串更改为 int 或 GUID。

ApplicationUser

ApplicationUserpublic class ApplicationUserManager : UserManager<ApplicationUser>) 在 Models\IdentityModels.cs 中定义为:

public class ApplicationUser : IdentityUser
{
    public async Task<ClaimsIdentity> GenerateUserIdentityAsync(
        UserManager<ApplicationUser> manager)
    {
        // Note the authenticationType must match the one defined in 
       //   CookieAuthenticationOptions.AuthenticationType
        var userIdentity = await manager.CreateIdentityAsync(this, 
    DefaultAuthenticationTypes.ApplicationCookie);
        // Add custom user claims here
        return userIdentity;
    }
}

上面突出显示的代码会生成 ClaimsIdentity。 ASP.NET 标识和 OWIN Cookie 身份验证是基于声明的,因此框架要求应用为用户生成 ClaimsIdentityClaimsIdentity 包含有关用户的所有声明的信息,例如用户的姓名、年龄以及用户所属的角色。 还可以在此阶段为用户添加更多声明。

OWIN AuthenticationManager.SignIn 方法会传入 ClaimsIdentity 并使用户登录:

private async Task SignInAsync(ApplicationUser user, bool isPersistent)
{
    AuthenticationManager.SignOut(DefaultAuthenticationTypes.ExternalCookie);
    AuthenticationManager.SignIn(new AuthenticationProperties(){
       IsPersistent = isPersistent }, 
       await user.GenerateUserIdentityAsync(UserManager));
}

支持 Facebook、Twitter、LinkedIn 和 Google OAuth2 登录的 MVC 5 应用介绍了如何向 ApplicationUser 类添加其他属性。

电子邮件确认

最好核实新用户注册的电子邮件,以验证他们是否没有冒充他人(也就是说,他们没有用他人的电子邮件注册)。 假设你有一个讨论论坛,你想要阻止 "bob@example.com" 注册为 "joe@contoso.com"。 如果没有电子邮件确认,"joe@contoso.com" 可能会从应用收到不需要的电子邮件。 假定 Bob 意外注册为 "bib@example.com",但并未发现,他将无法使用密码恢复,因为应用并没有获得他的正确电子邮箱。 电子邮件确认仅提供对机器人的有限保护,并且不提供来自确定垃圾邮件发送者的保护,他们有许多可用于注册的工作电子邮件别名。在下面的示例中,在确认其帐户之前,用户将无法更改其密码(通过他们选择在注册的电子邮件帐户上收到的确认链接)。可以将此工作流应用于其他方案,例如发送链接来确认和重置管理员创建新帐户的密码、在用户更改其配置文件时发送电子邮件等。 通常,你希望阻止新用户在通过电子邮件、短信或其他机制确认之前将任何数据发布到网站。

生成更完整的示例

在本部分中,你将使用 NuGet 下载我们将使用的更完整的示例。

  1. 创建新的 ASP.NET Web 项目。

  2. 在包管理器控制台中,输入以下命令:

    Install-Package SendGrid
    Install-Package -Prerelease Microsoft.AspNet.Identity.Samples
    

    在本教程中,我们将使用 SendGrid 发送电子邮件。 Identity.Samples 包将安装我们将使用的代码。

  3. 项目设置为使用 SSL

  4. 通过运行应用、选择 注册 链接并发布注册表单来测试本地帐户的创建。

  5. 选择模拟电子邮件确认的演示电子邮件链接。

  6. 从示例中删除演示电子邮件链接确认代码(帐户控制器中的 ViewBag.Link 代码。请参阅 DisplayEmailForgotPasswordConfirmation 操作方法以及 Razor 视图)。

警告

如果更改此示例中的任何安全设置,生产应用将需要进行安全审核来显式调用所做的更改。

检查 App_Start\IdentityConfig.cs 中的代码

此示例演示如何创建帐户并将其添加到 管理员 角色。 应将示例中的电子邮件替换为将用于管理员帐户的电子邮件。 现在创建管理员帐户的最简单方法是使用 Seed 方法以编程方式创建。 我们希望将来有一个工具,使你能够创建和管理用户和角色。 示例代码允许你创建和管理用户和角色,但必须先有管理员帐户才能运行角色和用户管理员页面。 在此示例中,在设定数据库种子时会创建管理员帐户。

更改密码并将名称更改为可以接收电子邮件通知的帐户。

警告

安全性 - 从不将敏感数据存储在源代码中。

如前所述,在启动类中调用 app.CreatePerOwinContext 会向应用数据库内容、用户管理器和角色管理器类的 Create 方法添加回调。 OWIN 管道为每个请求调用这些类的 Create 方法,并存储每个类的上下文。 帐户控制器从 HTTP 上下文公开用户管理器(其中包含 OWIN 上下文):

public ApplicationUserManager UserManager
{
    get
    {
        return _userManager ?? 
    HttpContext.GetOwinContext().GetUserManager<ApplicationUserManager>();
    }
    private set
    {
        _userManager = value;
    }
}

当用户注册本地帐户时,将调用 HTTP Post Register 方法:

[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Register(RegisterViewModel model)
{
    if (ModelState.IsValid)
    {
        var user = new ApplicationUser { UserName = model.Email, Email = model.Email };
        var result = await UserManager.CreateAsync(user, model.Password);
        if (result.Succeeded)
        {
            var code = await UserManager.GenerateEmailConfirmationTokenAsync(user.Id);
            var callbackUrl = Url.Action(
               "ConfirmEmail", "Account", 
               new { userId = user.Id, code = code }, 
               protocol: Request.Url.Scheme);

            await UserManager.SendEmailAsync(user.Id, 
               "Confirm your account", 
               "Please confirm your account by clicking this link: <a href=\"" 
                                               + callbackUrl + "\">link</a>");
            // ViewBag.Link = callbackUrl;   // Used only for initial demo.
            return View("DisplayEmail");
        }
        AddErrors(result);
    }

    // If we got this far, something failed, redisplay form
    return View(model);
}

上面的代码使用模型数据通过输入的电子邮件和密码创建新的用户帐户。 如果电子邮件别名位于数据存储区中,帐户创建将失败,并再次显示表单。 GenerateEmailConfirmationTokenAsync 方法创建一个安全确认令牌,并将其存储在 ASP.NET 标识数据存储中。 Url.Action 方法创建一个包含 UserId 和确认令牌的链接。 然后,此链接将通过电子邮件发送给用户,用户可以在其电子邮件应用中选择该链接以确认其帐户。

设置电子邮件确认

转到 SendGrid 注册页面并注册免费帐户。 添加类似于以下内容的代码以配置 SendGrid:

public class EmailService : IIdentityMessageService
{
   public Task SendAsync(IdentityMessage message)
   {
      return configSendGridasync(message);
   }

   private Task configSendGridasync(IdentityMessage message)
   {
      var myMessage = new SendGridMessage();
      myMessage.AddTo(message.Destination);
      myMessage.From = new System.Net.Mail.MailAddress(
                          "Joe@contoso.com", "Joe S.");
      myMessage.Subject = message.Subject;
      myMessage.Text = message.Body;
      myMessage.Html = message.Body;

      var credentials = new NetworkCredential(
                 ConfigurationManager.AppSettings["mailAccount"],
                 ConfigurationManager.AppSettings["mailPassword"]
                 );

      // Create a Web transport for sending email.
      var transportWeb = new Web(credentials);

      // Send the email.
      if (transportWeb != null)
      {
         return transportWeb.DeliverAsync(myMessage);
      }
      else
      {
         return Task.FromResult(0);
      }
   }
}

注意

电子邮件客户端经常只接受短信(无 HTML)。 应以文本和 HTML 形式提供消息。 在上面的 SendGrid 示例中,这是使用上面所示的 myMessage.TextmyMessage.Html 代码完成的。

以下代码演示如何使用 MailMessage 类发送电子邮件,其中 message.Body 仅返回链接。

void sendMail(Message message)
{
#region formatter
   string text = string.Format("Please click on this link to {0}: {1}", message.Subject, message.Body);
   string html = "Please confirm your account by clicking this link: <a href=\"" + message.Body + "\">link</a><br/>";

   html += HttpUtility.HtmlEncode(@"Or click on the copy the following link on the browser:" + message.Body);
#endregion

   MailMessage msg = new MailMessage();
   msg.From = new MailAddress("joe@contoso.com");
   msg.To.Add(new MailAddress(message.Destination));
   msg.Subject = message.Subject;
   msg.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(text, null, MediaTypeNames.Text.Plain));
   msg.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(html, null, MediaTypeNames.Text.Html));

   SmtpClient smtpClient = new SmtpClient("smtp.gmail.com", Convert.ToInt32(587));
   System.Net.NetworkCredential credentials = new System.Net.NetworkCredential("joe@contoso.com", "XXXXXX");
   smtpClient.Credentials = credentials;
   smtpClient.EnableSsl = true;
   smtpClient.Send(msg);
}

警告

安全性 - 从不将敏感数据存储在源代码中。 帐户和凭据存储在 appSetting 中。 在 Azure 上,可以在 Azure 门户中 “配置”选项卡上安全地存储这些值。 请参阅 将密码和其他敏感数据部署到 ASP.NET 和 Azure的最佳做法。

输入您的 SendGrid 凭据,运行应用程序,使用电子邮件别名注册后,在电子邮件中选择确认链接。 若要了解如何使用 Outlook.com 电子邮件帐户来执行此操作,请参阅 John Atten 的《C# SMTP Configuration for Outlook.Com SMTP 主机》和他的《ASP.NET Identity 2.0:设置帐户验证和 Two-Factor 授权》帖子。

用户选择 注册 按钮后,将包含验证令牌的确认电子邮件发送到其电子邮件地址。

电子邮件发送确认窗口的图像

向用户发送一封电子邮件,其中包含其帐户的确认令牌。

收到的电子邮件图像

检查代码

以下代码显示了 POST ForgotPassword 方法。

public async Task<ActionResult> ForgotPassword(ForgotPasswordViewModel model)
{
    if (ModelState.IsValid)
    {
        var user = await UserManager.FindByNameAsync(model.Email);
        if (user == null || !(await UserManager.IsEmailConfirmedAsync(user.Id)))
        {
            // Don't reveal that the user does not exist or is not confirmed
            return View("ForgotPasswordConfirmation");
        }

        var code = await UserManager.GeneratePasswordResetTokenAsync(user.Id);
        var callbackUrl = Url.Action("ResetPassword", "Account", 
    new { UserId = user.Id, code = code }, protocol: Request.Url.Scheme);
        await UserManager.SendEmailAsync(user.Id, "Reset Password", 
    "Please reset your password by clicking here: <a href=\"" + callbackUrl + "\">link</a>");        
        return View("ForgotPasswordConfirmation");
    }

    // If we got this far, something failed, redisplay form
    return View(model);
}

如果未确认用户电子邮件,则该方法以无提示方式失败。 如果因无效电子邮件地址而发出错误,则恶意用户可能会利用这些信息来查找有效 userId(电子邮件别名)以发起攻击。

以下代码显示了在用户选择发送给他们的电子邮件中的确认链接时调用的帐户控制器中的 ConfirmEmail 方法:

public async Task<ActionResult> ConfirmEmail(string userId, string code)
{
    if (userId == null || code == null)
    {
        return View("Error");
    }
    var result = await UserManager.ConfirmEmailAsync(userId, code);
    if (result.Succeeded)
    {
        return View("ConfirmEmail");
    }
    AddErrors(result);
    return View();
}

使用忘记的密码令牌后,该令牌将失效。 Create 方法(App_Start\IdentityConfig.cs 文件中)中的以下代码更改将令牌设置为在 3 小时内过期。

if (dataProtectionProvider != null)
 {
    manager.UserTokenProvider =
       new DataProtectorTokenProvider<ApplicationUser>
          (dataProtectionProvider.Create("ASP.NET Identity"))
          {                    
             TokenLifespan = TimeSpan.FromHours(3)
          };
 }

使用上面的代码,忘记的密码和电子邮件确认令牌将在 3 小时内过期。 默认 TokenLifespan 为一天。

以下代码显示了电子邮件确认方法:

// GET: /Account/ConfirmEmail
[AllowAnonymous]
public async Task<ActionResult> ConfirmEmail(string userId, string code)
{
   if (userId == null || code == null)
   {
      return View("Error");
   }
   IdentityResult result;
   try
   {
      result = await UserManager.ConfirmEmailAsync(userId, code);
   }
   catch (InvalidOperationException ioe)
   {
      // ConfirmEmailAsync throws when the userId is not found.
      ViewBag.errorMessage = ioe.Message;
      return View("Error");
   }

   if (result.Succeeded)
   {
      return View();
   }

   // If we got this far, something failed.
   AddErrors(result);
   ViewBag.errorMessage = "ConfirmEmail failed";
   return View("Error");
}

若要使应用更安全,ASP.NET 标识支持 Two-Factor 身份验证(2FA)。 请参阅 ASP.NET Identity 2.0:设置帐户验证和双因素身份验证(作者 John Atten)。 尽管可以设置在尝试输入登录密码失败时锁定帐户,但这种方法会使登录帐户容易遭受 DOS 锁定攻击。 建议仅在启用双重身份验证(2FA)时使用帐户锁定功能。

其他资源