Exercise - Customize Identity

Completed

In the previous unit, you learned how customization works in ASP.NET Core Identity. In this unit, you extend the Identity data model and make the corresponding UI changes.

Customize the user account data

In this section, you're going to create and customize the Identity UI files to be used in lieu of the default Razor Class Library.

  1. Add the user registration files to be modified to the project:

    dotnet aspnet-codegenerator identity --dbContext RazorPagesPizzaAuth --files "Account.Manage.EnableAuthenticator;Account.Manage.Index;Account.Register;Account.ConfirmEmail" --userClass RazorPagesPizzaUser --force
    

    In the preceding command:

    • The --dbContext option provides the tool with knowledge of the existing DbContext-derived class named RazorPagesPizzaAuth.
    • The --files option specifies a semicolon-delimited list of unique files to be added to the Identity area.
    • The --userClass option results in the creation of an IdentityUser-derived class named RazorPagesPizzaUser.
    • The --force option causes existing files in the Identity area to be overwritten.

    Tip

    Run the following command from the project root to view valid values for the --files option: dotnet aspnet-codegenerator identity --listFiles

    The following files are added to the Areas/Identity directory:

    • Data/
      • RazorPagesPizzaUser.cs
    • Pages/
      • _ViewImports.cshtml
      • Account/
        • _ViewImports.cshtml
        • ConfirmEmail.cshtml
        • ConfirmEmail.cshtml.cs
        • Register.cshtml
        • Register.cshtml.cs
        • Manage/
          • _ManageNav.cshtml
          • _ViewImports.cshtml
          • EnableAuthenticator.cshtml
          • EnableAuthenticator.cshtml.cs
          • Index.cshtml
          • Index.cshtml.cs
          • ManageNavPages.cs

    Additionally, the Data/RazorPagesPizzaAuth.cs file, which existed before running the preceding command, was overwritten because the --force option was used. The RazorPagesPizzaAuth class declaration now references the newly created user type of RazorPagesPizzaUser:

    public class RazorPagesPizzaAuth : IdentityDbContext<RazorPagesPizzaUser>
    

    The EnableAuthenticator and ConfirmEmail Razor pages were scaffolded, though they aren't modified until later in the module.

  2. In Program.cs, the call to AddDefaultIdentity needs to be made aware of the new Identity user type. Incorporate the following highlighted changes. (Example reformatted for readability.)

    using Microsoft.AspNetCore.Identity;
    using Microsoft.EntityFrameworkCore;
    using RazorPagesPizza.Areas.Identity.Data;
    
    var builder = WebApplication.CreateBuilder(args);
    var connectionString = builder.Configuration.GetConnectionString("RazorPagesPizzaAuthConnection");
    builder.Services.AddDbContext<RazorPagesPizzaAuth>(options => options.UseSqlServer(connectionString)); 
    builder.Services.AddDefaultIdentity<RazorPagesPizzaUser>(options => options.SignIn.RequireConfirmedAccount = true)
          .AddEntityFrameworkStores<RazorPagesPizzaAuth>();
    
    // Add services to the container.
    builder.Services.AddRazorPages();
    
  3. Update Pages/Shared/_LoginPartial.cshtml to incorporate the following highlighted changes at the top. Save your changes.

    @using Microsoft.AspNetCore.Identity
    @using RazorPagesPizza.Areas.Identity.Data
    @inject SignInManager<RazorPagesPizzaUser> SignInManager
    @inject UserManager<RazorPagesPizzaUser> UserManager
    
    <ul class="navbar-nav">
    

    The preceding changes update the user type passed to both SignInManager<T> and UserManager<T> in the @inject directives. Instead of the default IdentityUser type, RazorPagesPizzaUser user is now referenced. The @using directive was added to resolve the RazorPagesPizzaUser references.

    Pages/Shared/_LoginPartial.cshtml is physically located outside of the Identity area. So the file wasn't updated automatically by the scaffold tool. The appropriate changes must be made manually.

    Tip

    As an alternative to manually editing the _LoginPartial.cshtml file, it can be deleted prior to running the scaffold tool. The _LoginPartial.cshtml file is recreated with references to the new RazorPagesPizzaUser class.

  4. Update Areas/Identity/Data/RazorPagesPizzaUser.cs to support storage and retrieval of the additional user profile data. Make the following changes:

    1. Add the FirstName and LastName properties:

      public class RazorPagesPizzaUser : IdentityUser
      {
          [Required]
          [MaxLength(100)]
          public string FirstName { get; set; } = string.Empty;
      
          [Required]
          [MaxLength(100)]
          public string LastName { get; set; } = string.Empty;
      }
      

      The properties in the preceding snippet represent additional columns to be created in the underlying AspNetUsers table. Both properties are required and are therefore annotated with the [Required] attribute. Additionally, the [MaxLength] attribute indicates that a maximum length of 100 characters is allowed. The underlying table column's data type is defined accordingly. A default value of string.Empty is assigned since nullable context is enabled in this project and the properties are non-nullable strings.

    2. Add the following using statement to the top of the file.

      using System.ComponentModel.DataAnnotations;
      

      The preceding code resolves the data annotation attributes applied to the FirstName and LastName properties.

Update the database

Now that the model changes have been made, accompanying changes must be made to the database.

  1. Ensure that all your changes are saved.

  2. Create and apply an EF Core migration to update the underlying data store:

    dotnet ef migrations add UpdateUser
    dotnet ef database update
    

    The UpdateUser EF Core migration applied a DDL change script to the AspNetUsers table's schema. Specifically, FirstName and LastName columns were added, as seen in the following migration output excerpt:

    info: Microsoft.EntityFrameworkCore.Database.Command[20101]
        Executed DbCommand (37ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
        ALTER TABLE [AspNetUsers] ADD [FirstName] nvarchar(100) NOT NULL DEFAULT N'';
    info: Microsoft.EntityFrameworkCore.Database.Command[20101]
        Executed DbCommand (36ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
        ALTER TABLE [AspNetUsers] ADD [LastName] nvarchar(100) NOT NULL DEFAULT N'';
    
  3. Examine the database to analyze the effect of the UpdateUser EF Core migration on the AspNetUsers table's schema.

    In the SQL Server pane, expand the Columns node on the dbo.AspNetUsers table.

    Screenshot of the schema of the AspNetUsers table.

    The FirstName and LastName properties in the RazorPagesPizzaUser class correspond to the FirstName and LastName columns in the preceding image. A data type of nvarchar(100) was assigned to each of the two columns because of the [MaxLength(100)] attributes. The non-null constraint was added because FirstName and LastName in the class are non-nullable strings. Existing rows show empty strings in the new columns.

Customize the user registration form

You've added new columns for FirstName and LastName. Now you need to edit the UI to display matching fields on the registration form.

  1. In Areas/Identity/Pages/Account/Register.cshtml, add the following highlighted markup:

    <form id="registerForm" asp-route-returnUrl="@Model.ReturnUrl" method="post">
        <h2>Create a new account.</h2>
        <hr />
        <div asp-validation-summary="ModelOnly" class="text-danger"></div>
        <div class="form-floating">
            <input asp-for="Input.FirstName" class="form-control" />
            <label asp-for="Input.FirstName"></label>
            <span asp-validation-for="Input.FirstName" class="text-danger"></span>
        </div>
        <div class="form-floating">
            <input asp-for="Input.LastName" class="form-control" />
            <label asp-for="Input.LastName"></label>
            <span asp-validation-for="Input.LastName" class="text-danger"></span>
        </div>
        <div class="form-floating">
            <input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" />
            <label asp-for="Input.Email"></label>
            <span asp-validation-for="Input.Email" class="text-danger"></span>
        </div>
    

    With the preceding markup, First name and Last name text boxes are added to the user registration form.

  2. In Areas/Identity/Pages/Account/Register.cshtml.cs, add support for the name text boxes.

    1. Add the FirstName and LastName properties to the InputModel nested class:

      public class InputModel
      {
          [Required]
          [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 1)]
          [Display(Name = "First name")]
          public string FirstName { get; set; }
      
          [Required]
          [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 1)]
          [Display(Name = "Last name")]
          public string LastName { get; set; }
      
          /// <summary>
          ///     This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
          ///     directly from your code. This API may change or be removed in future releases.
          /// </summary>
          [Required]
          [EmailAddress]
          [Display(Name = "Email")]
          public string Email { get; set; }
      

      The [Display] attributes define the label text to be associated with the text boxes.

    2. Modify the OnPostAsync method to set the FirstName and LastName properties on the RazorPagesPizza object. Add the following highlighted lines:

      public async Task<IActionResult> OnPostAsync(string returnUrl = null)
      {
          returnUrl ??= Url.Content("~/");
          ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
          if (ModelState.IsValid)
          {
              var user = CreateUser();
      
              user.FirstName = Input.FirstName;
              user.LastName = Input.LastName;
              
              await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
              await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
              var result = await _userManager.CreateAsync(user, Input.Password);
      
      

      The preceding change sets the FirstName and LastName properties to the user input from the registration form.

Customize the site header

Update Pages/Shared/_LoginPartial.cshtml to display the first and last name collected during user registration. The highlighted lines in the following snippet are needed:

<ul class="navbar-nav">
@if (SignInManager.IsSignedIn(User))
{
    RazorPagesPizzaUser user = await UserManager.GetUserAsync(User);
    var fullName = $"{user.FirstName} {user.LastName}";

    <li class="nav-item">
        <a id="manage" class="nav-link text-dark" asp-area="Identity" asp-page="/Account/Manage/Index" title="Manage">Hello, @fullName!</a>
    </li>

Customize the profile management form

You've added the new fields to the user registration form, but you should also add them to the profile management form so existing users can edit them.

  1. In Areas/Identity/Pages/Account/Manage/Index.cshtml, add the following highlighted markup. Save your changes.

    <form id="profile-form" method="post">
        <div asp-validation-summary="ModelOnly" class="text-danger"></div>
        <div class="form-floating">
            <input asp-for="Input.FirstName" class="form-control" />
            <label asp-for="Input.FirstName"></label>
            <span asp-validation-for="Input.FirstName" class="text-danger"></span>
        </div>
        <div class="form-floating">
            <input asp-for="Input.LastName" class="form-control" />
            <label asp-for="Input.LastName"></label>
            <span asp-validation-for="Input.LastName" class="text-danger"></span>
        </div>
        <div class="form-floating">
            <input asp-for="Username" class="form-control" disabled />
            <label asp-for="Username" class="form-label"></label>
        </div>
    
  2. In Areas/Identity/Pages/Account/Manage/Index.cshtml.cs, make the following changes to support the name text boxes.

    1. Add the FirstName and LastName properties to the InputModel nested class:

      public class InputModel
      {
          [Required]
          [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 1)]
          [Display(Name = "First name")]
          public string FirstName { get; set; }
      
          [Required]
          [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 1)]
          [Display(Name = "Last name")]
          public string LastName { get; set; }
      
          [Phone]
          [Display(Name = "Phone number")]
          public string PhoneNumber { get; set; }
      }
      
    2. Incorporate the highlighted changes in the LoadAsync method:

      private async Task LoadAsync(RazorPagesPizzaUser user)
      {
          var userName = await _userManager.GetUserNameAsync(user);
          var phoneNumber = await _userManager.GetPhoneNumberAsync(user);
      
          Username = userName;
      
          Input = new InputModel
          {
              PhoneNumber = phoneNumber,
              FirstName = user.FirstName,
              LastName = user.LastName
          };
      }
      

      The preceding code supports retrieving the first and last names for display in the corresponding text boxes of the profile management form.

    3. Incorporate the highlighted changes in the OnPostAsync method. Save your changes.

      public async Task<IActionResult> OnPostAsync()
      {
          var user = await _userManager.GetUserAsync(User);
          if (user == null)
          {
              return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
          }
      
          if (!ModelState.IsValid)
          {
              await LoadAsync(user);
              return Page();
          }
      
          user.FirstName = Input.FirstName;
          user.LastName = Input.LastName;
          await _userManager.UpdateAsync(user);
      
          var phoneNumber = await _userManager.GetPhoneNumberAsync(user);
          if (Input.PhoneNumber != phoneNumber)
          {
              var setPhoneResult = await _userManager.SetPhoneNumberAsync(user, Input.PhoneNumber);
              if (!setPhoneResult.Succeeded)
              {
                  StatusMessage = "Unexpected error when trying to set phone number.";
                  return RedirectToPage();
              }
          }
      
          await _signInManager.RefreshSignInAsync(user);
          StatusMessage = "Your profile has been updated";
          return RedirectToPage();
      }
      

      The preceding code supports updating the first and last names in the database's AspNetUsers table.

Configure the confirmation email sender

In order to send the confirmation email, you need to create an implementation of IEmailSender and register it in the dependency injection system. To keep things simple, your implementation doesn't actually send email to an SMTP server. It just writes the email content to the console.

  1. Since you're going to view the email in plain text in the console, you should change the generated message to exclude HTML-encoded text. In Areas/Identity/Pages/Account/Register.cshtml.cs, find the following code:

    await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
        $"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
    

    Change it to:

    await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
        $"Please confirm your account by visiting the following URL:\r\n\r\n{callbackUrl}");
    
  2. In the Explorer pane, right-click the Services folder and create a new file named EmailSender.cs. Open the file and add the following code:

    using Microsoft.AspNetCore.Identity.UI.Services;
    namespace RazorPagesPizza.Services;
    
    public class EmailSender : IEmailSender
    {
        public EmailSender() {}
    
        public Task SendEmailAsync(string email, string subject, string htmlMessage)
        {
            Console.WriteLine();
            Console.WriteLine("Email Confirmation Message");
            Console.WriteLine("--------------------------");
            Console.WriteLine($"TO: {email}");
            Console.WriteLine($"SUBJECT: {subject}");
            Console.WriteLine($"CONTENTS: {htmlMessage}");
            Console.WriteLine();
    
            return Task.CompletedTask;
        }
    }
    

    The preceding code creates an implementation of IEmailSender that writes the contents of the message to the console. In a real-world implementation, SendEmailAsync would connect to an external mail service or some other action to send email.

  3. In Program.cs, add the highlighted lines:

    using Microsoft.AspNetCore.Identity;
    using Microsoft.EntityFrameworkCore;
    using RazorPagesPizza.Areas.Identity.Data;
    using Microsoft.AspNetCore.Identity.UI.Services;
    using RazorPagesPizza.Services;
    
    var builder = WebApplication.CreateBuilder(args);
    var connectionString = builder.Configuration.GetConnectionString("RazorPagesPizzaAuthConnection");
    builder.Services.AddDbContext<RazorPagesPizzaAuth>(options => options.UseSqlServer(connectionString)); 
    builder.Services.AddDefaultIdentity<RazorPagesPizzaUser>(options => options.SignIn.RequireConfirmedAccount = true)
          .AddEntityFrameworkStores<RazorPagesPizzaAuth>();
    
    // Add services to the container.
    builder.Services.AddRazorPages();
    builder.Services.AddTransient<IEmailSender, EmailSender>();
    
    var app = builder.Build();
    

    The preceding registers EmailSender as an IEmailSender in the dependency injection system.

Test the changes to the registration form

That's everything! Let's test the changes to the registration form and confirmation email.

  1. Make sure you've saved all your changes.

  2. In the terminal pane, build the project and run the app with dotnet run.

  3. In your browser, navigate to the app. Select Logout if you're still logged in.

  4. Select Register and use the updated form to register a new user.

    Note

    The validation constraints on the First name and Last name fields reflect the data annotations on the FirstName and LastName properties of InputModel.

  5. After registering, you're redirected to the Register confirmation screen. In the terminal pane, scroll up to find the console output that resembles the following:

    Email Confirmation Message
    --------------------------
    TO: jana.heinrich@contoso.com
    SUBJECT: Confirm your email
    CONTENTS: Please confirm your account by visiting the following URL:
    
    https://localhost:7192/Identity/Account/ConfirmEmail?<query string removed>
    

    Navigate to the URL with Ctrl+click. The confirmation screen displays.

    Note

    If you're using GitHub Codespaces, you might need to add -7192 to the first part of the forwarded URL. For example, scaling-potato-5gr4j4-7192.preview.app.github.dev.

  6. Select Login and sign in with the new user. The app's header now contains Hello, [First name] [Last name]!.

  7. In the SQL Server pane in VS Code, right-click on the RazorPagesPizza database and select New query. In the tab that appears, enter the following query and press Ctrl+Shift+E to run it.

    SELECT UserName, Email, FirstName, LastName
    FROM dbo.AspNetUsers
    

    A tab with results similar to the following appears:

    UserName Email FirstName LastName
    kai.klein@contoso.com kai.klein@contoso.com
    jana.heinrich@contoso.com jana.heinrich@contoso.com Jana Heinrich

    The first user registered prior to adding FirstName and LastName to the schema. So the associated AspNetUsers table record doesn't have data in those columns.

Test the changes to the profile management form

You should also test the changes you made to the profile management form.

  1. In the web app, sign in with the first user you created.

  2. Select the Hello, ! link to navigate to the profile management form.

    Note

    The link doesn't display correctly because the AspNetUsers table's row for this user doesn't contain values for FirstName and LastName.

  3. Enter valid values for First name and Last name. Select Save.

    The app's header updates to Hello, [First name] [Last name]!.

  4. Press Ctrl+C in the terminal pane in VS Code to stop the app.

Summary

In this unit, you customized Identity to store custom user information. You also customized the confirmation email. In the next unit, you'll learn about implementing multi-factor authentication in Identity.