ASP.NET Core 2.2 - Require A Confirmed Email
Update 03/21/2019
The cut and paste results were confusing. I found and corrected the string type in the migrationBuilder.AddColumn was not properly escaped.
The articles I wrote for Code Project Part 1 and Part 2 detailing how to require a confirmed email in ASP.NET 2.2 included some of this information. A few improvements have been added here. This article will cover the user interface, extending the IdentityUser and overriding the UserManager. I will assume you have created a new ASP.NET Core 2.2 Razor Pages project with Individual User Accounts, updated the database with the CreateIdentitySchema migration, scaffolded the Identity UI and implemented an EmailSender.
Require Confirmed and Unique Email.
Edit Startup.cs > ConfigureServices, use RequireConfirmedEmail and RequireUniqueEmail:
services.AddIdentity<IdentityUser, IdentityRole>(config => { config.SignIn.RequireConfirmedEmail = true; config.User.RequireUniqueEmail = true; }) //.AddDefaultUI(UIFramework.Bootstrap4) .AddEntityFrameworkStores<ApplicationDbContext>(); .AddDefaultTokenProviders();
Add razor page CheckEmail in Account.
Edit CheckEmail.cshtml:
@page @model CheckEmailModel @{ ViewData["Title"] = "Check Email"; } <h1>@ViewData["Title"]</h1> <hr /> <div class="row"> <div class="col-12"> <p> Please check your inbox to confirm your account. It should arrive within a couple of minutes. If not, try syncing your email or checking the junk folder. </p> </div> </div>
Edit CheckEmail.cshtml.cs, add [AllowAnonymous] decoration:
[AllowAnonymous] public class CheckEmailModel : PageModel { public void OnGet() { } }
Edit Register.cshtml.cs > OnPostAsync:
//await _signInManager.SignInAsync(user, isPersistent: false); //return LocalRedirect(returnUrl); return RedirectToPage("./CheckEmail");
Add Login Name for UserName.
Edit Register.cshtml.cs, add UserName property to Inputmodel:
[Required] [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] [Display(Name = "Login Name")] public string UserName { get; set; }
Edit Register.cshtml, add UserName input:
<div class="form-group"> <label asp-for="Input.UserName"></label> <input asp-for="Input.UserName" class="form-control" /> <span asp-validation-for="Input.UserName" class="text-danger"></span> </div>
Edit Register.cshtml.cs > OnPostAsync, use Input.UserName in new IdentityUser constructor:
var user = new IdentityUser { UserName = Input.UserName, Email = Input.Email };
Edit Login.cshtml.cs > InputModel, replace Email with UserName:
public class InputModel { [Required] [Display(Name = "Login Name")] public string UserName { get; set; } [Required] [DataType(DataType.Password)] public string Password { get; set; } [Display(Name = "Remember me?")] public bool RememberMe { get; set; } }
Edit Login.cshtml.cs > OnPostAsync, replace Input.Email with Input.UserName:
var result = await _signInManager.PasswordSignInAsync(Input.UserName, Input.Password, Input.RememberMe, lockoutOnFailure: true);
Edit Login.cshtml, replace Email with UserName on the asp-for:
<div class="form-group"> <label asp-for="Input.UserName"></label> <input asp-for="Input.UserName" class="form-control" /> <span asp-validation-for="Input.UserName" class="text-danger"></span> </div>
Add razor page UnconfirmedEmail in Account.
Edit UnconfirmedEmail.cshtml:
@page "{userId}" @model UnconfirmedEmailModel @{ ViewData["Title"] = "Confirm Email"; } <h1>@ViewData["Title"]</h1> <hr /> <h4>You can update your email.</h4> <div class="row"> <div class="col-md-6"> <form method="post"> <input type="hidden" asp-for="Input.Id" /> <div asp-validation-summary="All" class="text-danger"></div> <div class="form-group"> <label asp-for="Input.Email"></label> <input asp-for="Input.Email" class="form-control" /> <span asp-validation-for="Input.Email" class="text-danger"></span> </div> <button type="submit" class="btn btn-primary">Send</button> </form> </div> </div>
Edit UnconfirmedEmail.cshtml.cs:
using <YourProjectName>.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Text.Encodings.Web; using System.Threading.Tasks; namespace <YourProjectName>.Areas.Identity.Pages.Account { [AllowAnonymous] public class UnconfirmedEmailModel : PageModel { private readonly UserManager<IdentityUser> _userManager; private readonly IEmailSender _emailSender; public UnconfirmedEmailModel( UserManager<IdentityUser> userManager, IEmailSender emailSender) { _userManager = userManager; _emailSender = emailSender; } [BindProperty(SupportsGet = true)] public InputModel Input { get; set; } public class InputModel { public string Id { get; set; } [Required] [EmailAddress] public string Email { get; set; } } public async Task OnGetAsync(string userId) { Input.Id = userId; var user = await _userManager.FindByIdAsync(userId); Input.Email = user.Email; ModelState.Clear(); } public async Task<IActionResult> OnPostAsync() { if (ModelState.IsValid) { var user = await _userManager.FindByIdAsync(Input.Id); if (user == null) { // Don't reveal that the user does not exist return RedirectToPage("./CheckEmail"); } if (user.Email != Input.Email) { var errors = new List<IdentityError>(); if (_userManager.Options.User.RequireUniqueEmail) { var owner = await _userManager.FindByEmailAsync(Input.Email); if (owner != null && !string.Equals(await _userManager.GetUserIdAsync(owner), await _userManager.GetUserIdAsync(user))) { ModelState.AddModelError(string.Empty, new IdentityErrorDescriber().DuplicateEmail(Input.Email).Description); return Page(); } } await _userManager.SetEmailAsync(user, Input.Email); } var result = await _userManager.UpdateSecurityStampAsync(user); if (!result.Succeeded) { foreach (var error in result.Errors) { ModelState.AddModelError(string.Empty, error.Description); return Page(); } } var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); var callbackUrl = Url.Page( "/Account/ConfirmEmail", pageHandler: null, values: new { userId = user.Id, code }, protocol: Request.Scheme); await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>."); return RedirectToPage("./CheckEmail"); } return Page(); } } }
Modify Login.
Inject UserManager to Login.cshtml.cs:
private readonly UserManager<IdentityUser> _userManager; private readonly SignInManager<IdentityUser> _signInManager; private readonly ILogger<LoginModel> _logger; public LoginModel( UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager, ILogger<LoginModel> logger) { _userManager = userManager; _signInManager = signInManager; _logger = logger; }
Add ShowResend and UserId properties to Login.cshtml.cs:
public bool ShowResend { get; set; } public string UserId { get; set; }
Add to Login.cshtml.cs > OnPostAsync after result.IsLockedOut:
if (result.IsNotAllowed) { _logger.LogWarning("User email is not confirmed."); ModelState.AddModelError(string.Empty, "Email is not confirmed."); var user = await _userManager.FindByNameAsync(Input.UserName); UserId = user.Id; ShowResend = true; return Page(); }
Edit Login.cshtml after asp-validation-summary:
@{ if (Model.ShowResend) { <p> <a asp-page="./UnconfirmedEmail" asp-route-userId="@Model.UserId">Resend verification?</a> </p> } }
Modify Confirm Email.
Add ShowInvalid property to ConfirmEmail.cshtml.cs:
public bool ShowInvalid { get; set; }
Edit ConfirmEmail.cshtml.cs > OnGetAsync:
if (!result.Succeeded) { //throw new InvalidOperationException($"Error confirming email for user with ID '{userId}':"); foreach (var error in result.Errors) { ModelState.AddModelError(string.Empty, error.Description); } ShowInvalid = true; }
Edit ConfirmEmail.cshtml:
<div asp-validation-summary="All" class="text-danger"></div> @{ if (Model.ShowInvalid) { <p> Error confirming your email. </p> <p> If you can login, try updating your email again.<br /> If you cannot login, try resend verification. </p> } else { <p> Thank you for confirming your email. </p> } }
Add UnconfirmedEmail Property to IdentityUser.
Add new class named ApplicationUser in Entities folder:
using Microsoft.AspNetCore.Identity; namespace <YourProjectName>.Entities { public class ApplicationUser : IdentityUser { [PersonalData] public string UnconfirmedEmail { get; set; } } }
Use Find and Replace to Replace IdentityUser with ApplicationUser in Current Project excluding migration files and the ApplicationUser class inheritance. Resolve namespace issues where you replaced IdentityUser.
Update the Database.
Edit ApplicationDbContext in the Data folder, add ApplicationUser:
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
Run the command "Add-Migration UnconfirmedEmail" from the Package Manager Console in VS 2017.
The migration will attempt to set the new UnconfirmedEmail field to nvarchar(max). It may want to change the maxLength of some fields to nvarchar(max) which were set correctly in the CreateIdentitySchema migration. You can edit the migration file before you update the database. After the database update, the ModelSnapshot should be synced.
Edit the UnconfirmedEmail Migration class, add maxLength parameter to AddColumn:
public partial class UnconfirmedEmail : Migration { protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.AddColumn<string>( name: "UnconfirmedEmail", table: "AspNetUsers", nullable: true, maxLength: 256); } protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropColumn( name: "UnconfirmedEmail", table: "AspNetUsers"); } }
Run the command "Update-Database".
Add ChangeEmail page to manage profile.
Edit ManageNavPages.cs in the Account\Manage folder, add ChangeEmail property above ChangePassword:
public static string ChangeEmail => "ChangeEmail";
Edit ManageNavPages.cs, add ChangeEmailNavClass property above ChangePasswordNavClass:
public static string ChangeEmailNavClass(ViewContext viewContext) => PageNavClass(viewContext, ChangeEmail);
Edit _ManageNav.cshtml, add below Profile item:
<li class="nav-item"> <a class="nav-link @ManageNavPages.ChangeEmailNavClass(ViewContext)" id="change-email" asp-page="./ChangeEmail">Email</a> </li>
Add razor page ChangeEmail in Account\Manage.
Edit ChangeEmail.cshtml:
@page @model ChangeEmailModel @{ ViewData["Title"] = "Change Email"; ViewData["ActivePage"] = ManageNavPages.ChangeEmail; } <h4>@ViewData["Title"]</h4> <partial name="_StatusMessage" for="StatusMessage" /> <div class="row"> <div class="col-md-6"> <form id="change-email-form" method="post"> <div asp-validation-summary="All" class="text-danger"></div> <div class="form-group"> <label asp-for="Email"></label> <input asp-for="Email" class="form-control" disabled /> </div> <h5>New email needs to be verified.</h5> <div class="form-group"> <label asp-for="Input.Email"></label> <input asp-for="Input.Email" class="form-control" /> <span asp-validation-for="Input.Email" class="text-danger"></span> </div> <button type="submit" class="btn btn-primary">Update Email</button> </form> </div> </div> @section Scripts { <partial name="_ValidationScriptsPartial" /> }
Edit ChangeEmail.cshtml.cs:
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Text.Encodings.Web; using System.Threading.Tasks; using <YourProjectName>.Services; using <YourProjectName>.Entities; namespace <YourProjectName>.Areas.Identity.Pages.Account.Manage { public class ChangeEmailModel : PageModel { private readonly UserManager<ApplicationUser> _userManager; private readonly SignInManager<ApplicationUser> _signInManager; private readonly ILogger<ChangeEmailModel> _logger; private readonly IEmailSender _emailSender; public ChangeEmailModel( UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager, ILogger<ChangeEmailModel> logger, IEmailSender emailSender) { _userManager = userManager; _signInManager = signInManager; _logger = logger; _emailSender = emailSender; } [BindProperty] public InputModel Input { get; set; } [TempData] [Display(Name = "Verified Email")] public string Email { get; set; } [TempData] public string StatusMessage { get; set; } public class InputModel { [Required] [EmailAddress] [Display(Name = "New Email")] public string Email { get; set; } } public async Task<IActionResult> OnGetAsync() { var user = await _userManager.GetUserAsync(User); if (user == null) { return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); } var email = await _userManager.GetEmailAsync(user); Email = email; return Page(); } public async Task<IActionResult> OnPostAsync() { if (!ModelState.IsValid) { return Page(); } var user = await _userManager.GetUserAsync(User); if (user == null) { return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); } var email = await _userManager.GetEmailAsync(user); if (Input.Email != email) { var errors = new List<IdentityError>(); if (_userManager.Options.User.RequireUniqueEmail) { var owner = await _userManager.FindByEmailAsync(Input.Email); if (owner != null && !string.Equals (await _userManager.GetUserIdAsync(owner), await _userManager.GetUserIdAsync(user))) { ModelState.AddModelError(string.Empty, new IdentityErrorDescriber().DuplicateEmail(Input.Email).Description); return Page(); } } var setEmailResult = await _userManager.SetEmailAsync(user, Input.Email); if (!setEmailResult.Succeeded) { var userId = await _userManager.GetUserIdAsync(user); throw new InvalidOperationException($"Unexpected error occurred setting email for user with ID '{userId}'."); } if (Input.Email.ToUpper() != email.ToUpper()) { var result = await _userManager.UpdateSecurityStampAsync(user); if (!result.Succeeded) { foreach (var error in result.Errors) { ModelState.AddModelError(string.Empty, error.Description); return Page(); } } var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); var callbackUrl = Url.Page( "/Account/ConfirmEmail", pageHandler: null, values: new { userId = user.Id, code }, protocol: Request.Scheme); await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>."); _logger.LogInformation("User updated their UnconfirmedEmail."); StatusMessage = "Please check your inbox to confirm the new email."; } else { _logger.LogInformation("User updated their Email."); StatusMessage = "Your email has been updated."; } } return RedirectToPage(); } } }
Modify profile to use ChangeEmail page.
Edit Index.cshtml.cs in Account\Manage.
Add:
public string Email { get; set; }
Remove:
public bool IsEmailConfirmed { get; set; }
InputModel, remove:
[Required] [EmailAddress] public string Email { get; set; }
OnGetAsync, add:
Email = email;
OnGetAsync > new InputModel, remove:
Email = email,
OnGetAsync, remove:
IsEmailConfirmed = await _userManager.IsEmailConfirmedAsync(user);
OnPostAsync, remove:
var email = await _userManager.GetEmailAsync(user); if (Input.Email != email) { var setEmailResult = await _userManager.SetEmailAsync(user, Input.Email); if (!setEmailResult.Succeeded) { var userId = await _userManager.GetUserIdAsync(user); throw new InvalidOperationException($"Unexpected error occurred setting email for user with ID '{userId}'."); } }
Remove:
public async TaskOnPostSendVerificationEmailAsync() { if (!ModelState.IsValid) { return Page(); } var user = await _userManager.GetUserAsync(User); if (user == null) { return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); } var userId = await _userManager.GetUserIdAsync(user); var email = await _userManager.GetEmailAsync(user); var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); var callbackUrl = Url.Page( "/Account/ConfirmEmail", pageHandler: null, values: new { userId = userId, code = code }, protocol: Request.Scheme); await _emailSender.SendEmailAsync( email, "Confirm your email", $"Please confirm your account by clicking here."); StatusMessage = "Verification email sent. Please check your email."; return RedirectToPage(); }
Edit Index.cshtml in Account\Manage.
Replace:
<label asp-for="Input.Email"></label> @if (Model.IsEmailConfirmed) { <div class="input-group"> <input asp-for="Input.Email" class="form-control" /> <span class="input-group-addon" aria-hidden="true"> <span class="glyphicon glyphicon-ok text-success"></span> </span> </div> } else { <input asp-for="Input.Email" class="form-control" /> <button id="email-verification" type="submit" asp-page-handler="SendVerificationEmail" class="btn btn-link"> Send verification email </button> } <span asp-validation-for="Input.Email" class="text-danger"></span>
With:
<label asp-for="Email"></label> <input asp-for="Email" class="form-control" disabled />
This leaves the Phone Number the only editable field on the Manage\Index page. You can add a ChangePhoneNumber page and modify the navigation for consistency.
Override UserManager.
Add new class named ApplicationUserManager in Services folder:
using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; using System.Collections.Generic; using System.Threading.Tasks; using <YourProjectName>.Entities; namespace <YourProjectName>.Services { public class ApplicationUserManager : UserManager<ApplicationUser> { public ApplicationUserManager(IUserStore<ApplicationUser> store, IOptions<IdentityOptions> optionsAccessor, IPasswordHasher<ApplicationUser> passwordHasher, IEnumerable<IUserValidator<ApplicationUser>> userValidators, IEnumerable<IPasswordValidator<ApplicationUser>> passwordValidators, ILookupNormalizer keyNormalizer, IdentityErrorDescriber errors, IServiceProvider services, ILogger<UserManager<ApplicationUser>> logger) : base(store, optionsAccessor, passwordHasher, userValidators, passwordValidators, keyNormalizer, errors, services, logger) { } /// <summary> /// Sets the <paramref name="email"/> address for a <paramref name="user"/>. /// </summary> /// <param name="user">The user whose email should be set.</param> /// <param name="email">The email to set.</param> /// <returns> /// The <see cref="Task"/> that represents the asynchronous operation, /// containing the <see cref="IdentityResult"/> /// of the operation. /// </returns> public override async Task<IdentityResult> SetEmailAsync(ApplicationUser user, string email) { ThrowIfDisposed(); if (user == null) { throw new ArgumentNullException(nameof(user)); } if (user.EmailConfirmed && user.Email.ToUpper() != email.ToUpper()) user.UnconfirmedEmail = email; else user.Email = email; return await UpdateUserAsync(user); } /// <summary> /// Validates that an email confirmation token matches the specified /// <paramref name="user"/> and if successful sets /// EmailConfirmed to true and if UnconfirmedEmail is not NULL or Empty, /// copies the user's UnconfirmedEmail to user's /// Email and sets UnconfirmedEmail to NULL. /// </summary> /// <param name="user">The user to validate the token against.</param> /// <param name="token">The email confirmation token to validate.</param> /// <returns> /// The <see cref="Task"/> that represents the asynchronous operation, /// containing the <see cref="IdentityResult"/> /// of the operation. /// </returns> public override async Task<IdentityResult> ConfirmEmailAsync(ApplicationUser user, string token) { ThrowIfDisposed(); if (user == null) { throw new ArgumentNullException(nameof(user)); } IdentityResult result; var provider = Options.Tokens.EmailConfirmationTokenProvider; var isValidToken = await base.VerifyUserTokenAsync(user, provider, "EmailConfirmation", token); if (isValidToken) { if (!string.IsNullOrEmpty(user.UnconfirmedEmail)) { user.Email = user.UnconfirmedEmail; user.UnconfirmedEmail = null; } user.EmailConfirmed = true; result = await UpdateUserAsync(user); } else { result = IdentityResult.Failed(new IdentityErrorDescriber().InvalidToken()); } return result; } } }
Edit Startup.cs > ConfigureServices, add .AddUserManager():
services.AddIdentity<ApplicationUser, IdentityRole>(config => { config.SignIn.RequireConfirmedEmail = true; config.User.RequireUniqueEmail = true; }) .AddDefaultUI(UIFramework.Bootstrap4) .AddEntityFrameworkStores<ApplicationDbContext>() .AddUserManager<ApplicationUserManager>() .AddDefaultTokenProviders();
If you want to do more than override with ApplicationUserManager. Use Find and Replace to Replace UserManager<ApplicationUser> with ApplicationUserManager in pages you have injected the UserManager. Resolve namespace issues where you replace UserManager<ApplicationUser>.
Comments(0)