ASP.NET Core 3.1 - Must Change Password

This article will demonstrate the implementation of a must change password custom claim which uses middleware to redirect the user to a change password page. I will assume you have downloaded the ASP.NET Core 3.1 - Users Without Identity Project or created a new ASP.NET Core 3.1 Razor Pages project. See Tutorial: Get started with Razor Pages in ASP.NET Core. You should review the earlier articles of the Users Without Identity Project series.

Users Without Identity Project and Article Series

Users can not self-manage their password. There is no function for a user to self-reset a forgotten password. If we add a MustChangePassword bool property for the user, we can use a custom claim and middleware to redirect the user to a change password page. When an Admin creates a new user or a user forgets their passord, an Admin can use a temporary password and MustChangePassword to allow the user to enter a new password at first or next login.

If you have implemented the cookie validator, updating the user will log them out on their next request if they are currently logged in. Add the MustChangePassword bool property to the AppUser class.

Edit Entities > AppUser.cs:
public class AppUser
{
    [Key]
    public int Id { get; set; }

    [Required]
    [Display(Name = "Login Name")]
    [StringLength(32, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
    public string LoginName { get; set; }

    [Required]
    [StringLength(32)]
    public string LoginNameUppercase { get; set; }

    [Required]
    [MaxLength(84, ErrorMessage = "The {0} is max {1} characters long.")]
    public string PasswordHash { get; set; }

    [Display(Name = "Must Change Password")]
    public bool MustChangePassword { get; set; }

    [Display(Name = "Admin")]
    public bool IsAdmin { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; }
}

Add the MustChangePassword migration.

Package Manager Console:
add-migration MustChangePassword -o Data/Migrations
DotNet CLI:
dotnet-ef migrations add MustChangePassword -o Data/Migrations

The add migration produces a MigrationBuilder which will add the MustChangePassword column to the AppUsers table. The file name prepends a date time stamp to MustChangePassword.

20200123193145_ MustChangePassword. cs:
public partial class MustChangePassword : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<bool>(
            name: "MustChangePassword",
            table: "AppUsers",
            nullable: false,
            defaultValue: false);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn(
            name: "MustChangePassword",
            table: "AppUsers");
    }
}

If all looks good, update the database.

Package Manager Console:
update-database
DotNet CLI:
dotnet-ef database update

Add the MustChangePassword property to the InputModels for the create and edit user pages.

Edit Admin/ Users/ Create.cshtml.cs && Edit.cshtml.cs > InputModel:
[Display(Name = "Must Change Password")]
public bool MustChangePassword { get; set; }

Update the pages with MustChangePassword inputs.

Edit Create.cshtml && Edit.cshtml:
<div class="form-group">
    <div class="custom-control custom-checkbox">
        <input class="custom-control-input" asp-for="Input.MustChangePassword" />
        <label class="custom-control-label" asp-for="Input.MustChangePassword">
            @Html.DisplayNameFor(model => model.Input.MustChangePassword)
        </label>
    </div>
    <span asp-validation-for="Input.MustChangePassword" class="text-danger"></span>
</div>

Add/Update the MustChangePassword property when you save the user. Be sure to add the MustChangePassword value to the new InputModel in OnGet for the edit page. The code for details and delete doesn't need modification but I add the MustChangePassword property to the pages.

I define a custom claim type for MustChangePassword in the AppUser class. Add a public const string.

Edit Entities/ AppUser.cs:
public const string MustChangePasswordClaimType = "http://userswithoutidentity/claims/mustchangepassword";

If the user's MustChangePassword = true, add a new claim with a MustChangePasswordClaimType to the Claims Principle when the user logs in.

Edit Account/ Login.cshtml.cs > OnPost:
if (user.MustChangePassword)
{
    claims.Add(new Claim(AppUser.MustChangePasswordClaimType, string.Empty));
}

Notice the claim has an empty string for the value. I evaluate the existence of the claim as true.

I use middleware to detect the MustChangePasswordClaimType for authenticated users and redirect the use to a change password page if found. Create a new class named MustChangePasswordMiddleware in the Services folder. I include an IApplicationBuilder extension.

Services/ MustChangePasswordMiddleware.cs:
public class MustChangePasswordMiddleware
{
    private readonly RequestDelegate _next;

    public MustChangePasswordMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        if (context.User.Identity.IsAuthenticated &&
            context.Request.Path != new PathString("/account/mustchangepassword") &&
            context.Request.Path != new PathString("/account/logout") &&
            ((ClaimsIdentity)context.User.Identity).HasClaim(c => c.Type == AppUser.MustChangePasswordClaimType))
        {
            var returnUrl = context.Request.Path.Value == "/" ? "" : "?returnUrl=" + HttpUtility.UrlEncode(context.Request.Path.Value);
            context.Response.Redirect("/account/mustchangepassword" + returnUrl);
        }
        await _next(context).ConfigureAwait(true);
    }
}

public static class MustChangePasswordMiddlewareExtensions
{
    public static IApplicationBuilder UseMustChangePassword(
        this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<MustChangePasswordMiddleware>();
    }
}

Resolve all using statements. Call the MustChangePassword middleware from Startup.cs.

Edit Startup.cs > Configure:
// Redirects a user to the MustChangePassword page when user has a MustChangePassword claim.
app.UseMustChangePassword();

Add a MustChangePassword page to Pages/ Account. Notice the Authorize attribute and verifying the new password does not equal the current password.

MustChangePassword.cshtml.cs:
[Authorize]
public class MustChangePasswordModel : PageModel
{
    private readonly ApplicationDbContext _context;
    public MustChangePasswordModel(ApplicationDbContext context)
    {
        _context = context;
    }

    public string ReturnUrl { get; set; }

    [BindProperty]
    public InputModel Input { get; set; }
    public class InputModel
    {
        [Key]
        public int Id { get; set; }

        [Required]
        [StringLength(32, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
        [DataType(DataType.Password)]
        [Display(Name = "New password")]
        public string NewPassword { get; set; }

        [DataType(DataType.Password)]
        [Display(Name = "Confirm new password")]
        [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
        public string ConfirmPassword { get; set; }

        [Display(Name = "Remember me?")]
        public bool RememberMe { get; set; }

        public byte[] RowVersion { get; set; }
    }

    public async Task<IActionResult> OnGetAsync(string returnUrl = null)
    {

        if (!User.Identity.IsAuthenticated)
        {
            return RedirectToPage(Url.Content("~/"));
        }

        returnUrl ??= Url.Content("~/");
        ReturnUrl = returnUrl;

        int currentUserId;
        var idClaim = User.FindFirst(ClaimTypes.NameIdentifier);
        if (idClaim == null)
        {
            // Logout user and clear the existing external cookie
            await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
            return RedirectToPage(Url.Content("~/"));
        }

        try
        {
            currentUserId = Int32.Parse(idClaim.Value);
        }
        catch (FormatException)
        {
            // Logout user and clear the existing external cookie
            await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
            return RedirectToPage(Url.Content("~/"));
        }

        var user = await _context.AppUsers
            .AsNoTracking()
            .Where(a => a.Id == currentUserId)
            .FirstOrDefaultAsync()
            .ConfigureAwait(false);

        if (user == null)
        {
            // Logout user and clear the existing external cookie
            await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
            return RedirectToPage(Url.Content("~/"));
        }

        Input = new InputModel()
        {
            Id = user.Id,
            RowVersion = user.RowVersion
        };

        return Page();
    }

    public async Task<IActionResult> OnPostAsync(string returnUrl = null)
    {
        if (!ModelState.IsValid)
        {
            return Page();
        }

        if (!User.Identity.IsAuthenticated)
        {
            return RedirectToPage(Url.Content("~/"));
        }

        var user = await _context.AppUsers
            .AsNoTracking()
            .Where(a => a.Id == Input.Id)
            .FirstOrDefaultAsync()
            .ConfigureAwait(false);

        if (user == null)
        {
            // Logout user and clear the existing external cookie
            await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
            return RedirectToPage(Url.Content("~/"));
        }

        // Test for concurrency conflict
        if (!user.RowVersion.SequenceEqual(Input.RowVersion))
        {
            ModelState.Clear(); // required to update Input model

            ModelState.AddModelError(string.Empty, "There was an issue updating " +
                    "the record. Please try again.");

            Input = new InputModel()
            {
                Id = user.Id,
                RowVersion = user.RowVersion
            };

            return Page();
        }

        // Verify the new password does not equal the current password.
        var passwordHasher = new CustomPasswordHasher();
        if (passwordHasher.VerifyPassword(user.PasswordHash, Input.NewPassword))
        {
            ModelState.AddModelError(string.Empty, "You must use a new password.");
            return Page();
        }

        var hashedPassword = passwordHasher.HashPassword(Input.NewPassword);
        user.PasswordHash = hashedPassword;
        user.MustChangePassword = false;

        _context.Attach(user).State = EntityState.Modified;

        try
        {
            _context.Entry(user).OriginalValues["RowVersion"] = Input.RowVersion;
            await _context.SaveChangesAsync();
        }
        catch (DbUpdateConcurrencyException ex)
        {
            var entry = ex.Entries.Single();
            var clientValues = (AppUser)entry.Entity;
            var databaseEntry = entry.GetDatabaseValues();
            if (databaseEntry == null)
            {
                // Logout user and clear the existing external cookie
                await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
                return RedirectToPage(Url.Content("~/"));
            }
            else
            {
                ModelState.Clear(); // required to update Input model

                ModelState.AddModelError(string.Empty, "There was an issue updating " +
                    "the record. Please try again.");

                var databaseValues = (AppUser)databaseEntry.ToObject();
                Input = new InputModel()
                {
                    Id = databaseValues.Id,
                    RowVersion = databaseValues.RowVersion
                };
            }

            return Page();
        }
        catch (RetryLimitExceededException /* dex */)
        {
            // Retry Limit = 6
            ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, " +
                "see your system administrator.");
        }
        catch (DbUpdateException)
        {
            throw new InvalidOperationException("DbUpdateException occurred updating a user.");
        }

        // Logout user and clear the existing external cookie
        await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

        string rowVersionString = Encoding.Unicode.GetString(user.RowVersion);
        var claims = new List<Claim>
        {
            new Claim(ClaimTypes.NameIdentifier, user.Id.ToString(), ClaimValueTypes.Integer),
            new Claim(ClaimTypes.Name, user.LoginName),
            new Claim(ClaimTypes.UserData, rowVersionString)
        };

        if (user.IsAdmin)
        {
            claims.Add(new Claim(ClaimTypes.Role, "Admin"));
        }

        if (user.MustChangePassword)
        {
            claims.Add(new Claim(AppUser.MustChangePasswordClaimType, string.Empty));
        }

        var claimsIdentity = new ClaimsIdentity(
            claims, CookieAuthenticationDefaults.AuthenticationScheme);

        await HttpContext.SignInAsync(
            CookieAuthenticationDefaults.AuthenticationScheme,
            new ClaimsPrincipal(claimsIdentity),
            new AuthenticationProperties
            {
                IsPersistent = Input.RememberMe
            });

        returnUrl ??= Url.Content("~/");

        return LocalRedirect(returnUrl);
    }

}

Resolve all using statements.

MustChangePassword.cshtml:
@page
@model MustChangePasswordModel
@{
    ViewData["Title"] = "Must Change Password";
}

<div class="text-center text-primary border-bottom mb-2">
    <h1>@ViewData["Title"]</h1>
</div>
<div class="row">
    <div class="col-md-3 col-lg-4"></div>
    <div class="col-md-6 col-lg-4">
        <form id="ChangePasswordFormId" asp-route-returnUrl="@Model.ReturnUrl" method="post">
            <input asp-for="Input.Id" type="hidden" />
            <input asp-for="Input.RowVersion" type="hidden" />
            <div asp-validation-summary="All" class="text-danger"></div>
            <div class="form-group">
                <label asp-for="Input.NewPassword"></label>
                <input asp-for="Input.NewPassword" class="form-control" autocomplete="new-password" />
                <span asp-validation-for="Input.NewPassword" class="text-danger"></span>
            </div>
            <div class="form-group">
                <label asp-for="Input.ConfirmPassword"></label>
                <input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" />
                <span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
            </div>
            <div class="form-group">
                <div class="custom-control custom-checkbox">
                    <input class="custom-control-input" asp-for="Input.RememberMe" />
                    <label class="custom-control-label" asp-for="Input.RememberMe">
                        @Html.DisplayNameFor(model => model.Input.RememberMe)
                    </label>
                </div>
                <span asp-validation-for="Input.RememberMe" class="text-danger"></span>
            </div>
            <button type="submit" class="btn btn-primary">Update</button>
        </form>
    </div>
</div>

@section Scripts {
    <partial name="_ValidationScriptsPartial" />
}

Update Services/ EnsureAdministrator.cs. Set MustChangePassword = true for the new AppUser we automatically create at startup to guarantee an Administrator user. Add the MustChangePassword property to the users index.

Must Change Password page.
Update 02/23/2021

I added the Enhanced User Series' article links.

References:

Ken Haggerty
Created 02/02/20
Updated 02/24/21 00:14 GMT

Log In or Reset Quota to read more.

Article Tags:

Claims EF Core Model
Successfully completed. Thank you for contributing.
Contribute to enjoy content without advertisments.
Something went wrong. Please try again.

Comments(0)

Loading...
Loading...

Not accepting new comments.

Submit your comment. Comments are moderated.

User Image.
DisplayedName - Member Since ?