Password Expiration Policy in ASP.NET Core Identity

Password Expiration Policy in ASP.NET Core Identity

In this article, I will explain How to Implement the Password Expiration Policy in ASP.NET Core Identity with an Example. Please read our previous article discussing How to Implement Two-Factor Authentication in ASP.NET Core Identity.

Why Password Expiration Policy in ASP.NET Core?

A Password Expiration or Change Policy is a set of rules and requirements established by an organization or system to govern how and when users must change their passwords. This policy is an essential component of cybersecurity and is designed to protect user accounts and sensitive data from unauthorized access. The key elements of a Password Expiration or Change Policy typically include:

  1. Password Expiration: The policy may specify a time period after which a password must be changed. For instance, it might require users to change their passwords every 90 days.
  2. Password History: The policy might maintain a history of used passwords to prevent users from reusing recent passwords. A common practice is not to allow the reuse of the last 5 to 10 passwords.
  3. Password Complexity Requirements: This includes rules about the minimum password length and using a mix of characters (uppercase, lowercase, numbers, and special characters) to increase password strength.
  4. Mandatory Password Changes: The policy might mandate an immediate password change in certain scenarios, such as after a security breach or when a password is suspected to have been compromised.
  5. User Notification: The policy might include rules for notifying users about upcoming password expirations, usually through email or system prompts, giving them time to update their passwords before expiration.
How to Implement Password Expiration Policy in ASP.NET Core Identity?

ASP.NET Core Identity doesn’t provide out-of-the-box support for password expiration. You need to implement this logic yourself. For example, you can add a LastPasswordChangedDate field to your user entity. So, modify the ApplicationUser class as follows:

using Microsoft.AspNetCore.Identity;
using System.ComponentModel.DataAnnotations;

namespace ASPNETCoreIdentityDemo.Models
{
    public class ApplicationUser : IdentityUser
    {
        [Display(Name = "First Name")]
        public string? FirstName { get; set; }

        [Display(Name = "Last Name")]
        public string? LastName { get; set; }

        public DateTime LastPasswordChangedDate { get; set; }
    }
}

Checking Password Expiration While Login:

In your login logic, you need to check if the user’s password has expired by using the following logic. Here, we are checking the LastPasswordChangedDate date, which must not be greater than 90 days. As per your business logic, you can set this to 180 days, 360 days, etc. Further, we are hardcoding the values, but you should get this value from some configuration file or from the database.

if (user?.LastPasswordChangedDate.AddDays(90) < DateTime.Now)
{
    // Password has expired
    // Redirect user to change password page
    return View("PasswordExpired");
}

So, modify the Login Post Action method of the account controller as follows:

[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> Login(LoginViewModel model, string? ReturnUrl)
{
    //If Model Login Failed, we also need to show the External Login Providers 
    model.ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList();

    if (ModelState.IsValid)
    {
        //First Fetch the User Details by Email Id
        var user = await userManager.FindByEmailAsync(model.Email);

        //Then Check if User Exists, EmailConfirmed and Password Is Valid
        //CheckPasswordAsync: Returns a flag indicating whether the given password is valid for the specified user.
        if (user != null && !user.EmailConfirmed &&
                    (await userManager.CheckPasswordAsync(user, model.Password)))
        {
            ModelState.AddModelError(string.Empty, "Email not confirmed yet");
            return View(model);
        }

        // The last boolean parameter lockoutOnFailure indicates if the account should be locked on failed login attempt. 
        // On every failed login attempt AccessFailedCount column value in AspNetUsers table is incremented by 1. 
        // When the AccessFailedCount reaches the configured MaxFailedAccessAttempts which in our case is 5,
        // the account will be locked and LockoutEnd column is populated.
        // After the account is lockedout, even if we provide the correct username and password,
        // PasswordSignInAsync() method returns Lockedout result and
        // the login will not be allowed for the duration the account is locked.
        var result = await signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);

        if (result.Succeeded)
        {
            if (user?.LastPasswordChangedDate.AddDays(90) < DateTime.Now)
            {
                // Password has expired
                // Redirect user to change password page
                return View("PasswordExpired");
            }

            // Handle successful login
            // Check if the ReturnUrl is not null and is a local URL
            if (!string.IsNullOrEmpty(ReturnUrl) && Url.IsLocalUrl(ReturnUrl))
            {
                return Redirect(ReturnUrl);
            }
            else
            {
                // Redirect to default page
                return RedirectToAction("Index", "Home");
            }
        }
        if (result.RequiresTwoFactor)
        {
            // Handle two-factor authentication case
            // Generate a 2FA token, send that token to user Email and Phone Number
            // and redirect to the 2FA verification view
            var TwoFactorAuthenticationToken = await userManager.GenerateTwoFactorTokenAsync(user, "Email");

            //Sending SMS
            await smsSender.SendSmsAsync(user.PhoneNumber, $"Your 2FA Token is {TwoFactorAuthenticationToken}");

            //Sending Email
            await emailSender.SendEmailAsync(user.Email, "2FA Token", $"Your 2FA Token is {TwoFactorAuthenticationToken}", false);

            return RedirectToAction("VerifyTwoFactorToken", "Account", new { model.Email, ReturnUrl, model.RememberMe, TwoFactorAuthenticationToken });
        }
        if (result.IsLockedOut)
        {
            //It's important to inform users when their account is locked.
            //This can be done through the UI or by sending an email notification.
            await SendAccountLockedEmail(model.Email);
            return View("AccountLocked");
        }
        else
        {
            // Handle failure
            // Get the number of attempts left
            var attemptsLeft = userManager.Options.Lockout.MaxFailedAccessAttempts - await userManager.GetAccessFailedCountAsync(user);

            ModelState.AddModelError(string.Empty, $"Invalid Login Attempt. Remaining Attempts : {attemptsLeft}");
            return View(model);
        }
    }

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

Next, add a view named PasswordExpired.cshtml within the Views/Account folder and then copy and paste the following code. This is the view that will render when the user password has expired.

@{
    ViewData["Title"] = "PasswordExpired";
}

<h1>PasswordExpired</h1>

<h3 class="text-danger">
    Your Password has Expired Please
    <a asp-action="ForgotPassword" asp-controller="Account">
        reset your password by clicking here
    </a>
</h3>
Updating Last Password Changed Date:

When the user successfully changes their password, update the LastPasswordChangedDate using the following logic.

user.LastPasswordChangedDate = DateTime.Now;
await userManager.UpdateAsync(user);
Modifying ResetPassword Post Action Method:

Modify the ResetPassword Post Action Method of the Account Controller as follows:

[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> ResetPassword(ResetPasswordViewModel model)
{
    if (ModelState.IsValid)
    {
        // Find the user by email
        var user = await userManager.FindByEmailAsync(model.Email);

        if (user != null)
        {
            // reset the user password
            var result = await userManager.ResetPasswordAsync(user, model.Token, model.Password);

            if (result.Succeeded)
            {
                // Upon successful password reset, update the LastPasswordChangedDate
                user.LastPasswordChangedDate = DateTime.Now;
                await userManager.UpdateAsync(user);

                // Upon successful password reset and if the account is lockedout,
                // set the account lockout end date to current UTC date time, 
                // so the user can login with the new password
                if (await userManager.IsLockedOutAsync(user))
                {
                    await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow);
                }

                //Once the Password is Reset, remove the token from the database if you are storing the token
                await userManager.RemoveAuthenticationTokenAsync(user, "ResetPassword", "ResetPasswordToken");
                return RedirectToAction("ResetPasswordConfirmation", "Account");
            }

            // Display validation errors. For example, password reset token already
            // used to change the password or password complexity rules not met
            foreach (var error in result.Errors)
            {
                ModelState.AddModelError("", error.Description);
            }
            return View(model);
        }

        // To avoid account enumeration and brute force attacks, don't
        // reveal that the user does not exist
        return RedirectToAction("ResetPasswordConfirmation", "Account");
    }
    // Display validation errors if model state is not valid
    return View(model);
}
Modifying ChangePassword Post Action Method:

Modify the ChangePassword Post Action Method of the Account Controller as follows:

[Authorize]
[HttpPost]
public async Task<IActionResult> ChangePassword(ChangePasswordViewModel model)
{
    if (ModelState.IsValid)
    {
        //fetch the User Details
        var user = await userManager.GetUserAsync(User);
        if (user == null)
        {
            //If User does not exists, redirect to the Login Page
            return RedirectToAction("Login", "Account");
        }

        // ChangePasswordAsync Method changes the user password
        var result = await userManager.ChangePasswordAsync(user, model.OldPassword, model.NewPassword);

        // The new password did not meet the complexity rules or the current password is incorrect.
        // Add these errors to the ModelState and rerender ChangePassword view
        if (!result.Succeeded)
        {
            foreach (var error in result.Errors)
            {
                ModelState.AddModelError(string.Empty, error.Description);
            }
            return View();
        }

        // Upon successful change password, update the LastPasswordChangedDate
        user.LastPasswordChangedDate = DateTime.Now;
        await userManager.UpdateAsync(user);

        // Upon successfully changing the password refresh sign-in cookie
        await signInManager.RefreshSignInAsync(user);

        //Then redirect the user to the ChangePasswordConfirmation view
        return RedirectToAction("ChangePasswordConfirmation", "Account");
    }

    return View(model);
}
Modifying AddPassword Post Action Method:

Modify the AddPassword Post Action Method of the Account Controller as follows:

[Authorize]
[HttpPost]
public async Task<IActionResult> AddPassword(AddPasswordViewModel model)
{
    if (ModelState.IsValid)
    {
        var user = await userManager.GetUserAsync(User);
        if (user == null)
        {
            ModelState.AddModelError(string.Empty, "Unable to Load User.");
            return View();
        }

        //Call the AddPasswordAsync method to set the new password without old password
        var result = await userManager.AddPasswordAsync(user, model.NewPassword);

        // Handle the failure scenario
        if (!result.Succeeded)
        {
            //fetch all the error messages and display on the view
            foreach (var error in result.Errors)
            {
                ModelState.AddModelError(string.Empty, error.Description);
            }
            return View();
        }
        // Upon successful Add password to External Account, update the LastPasswordChangedDate
        user.LastPasswordChangedDate = DateTime.Now;
        await userManager.UpdateAsync(user);

        // Handle Success Scenario
        // refresh the authentication cookie to store the updated user information
        await signInManager.RefreshSignInAsync(user);

        //redirecting to the AddPasswordConfirmation action method
        return RedirectToAction("AddPasswordConfirmation", "Account");
    }

    return View();
}

Modifying Register Post Action Method:

While registering a local user, we also need to set the LastPasswordChangedDate property value to the current date. Modify the Register Post Action Method of the Account Controller as follows:

[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> Register(RegisterViewModel model)
{
    if (ModelState.IsValid)
    {
        // Copy data from RegisterViewModel to ApplicationUser
        var user = new ApplicationUser
        {
            UserName = model.Email,
            Email = model.Email,
            FirstName = model.FirstName,
            LastName = model.LastName,
            PhoneNumber = model.PhoneNumber,
            LastPasswordChangedDate = DateTime.Now
        };

        // Store user data in AspNetUsers database table
        var result = await userManager.CreateAsync(user, model.Password);

        // If user is successfully created, sign-in the user using
        // SignInManager and redirect to index action of HomeController
        if (result.Succeeded)
        {
            //Then send the Confirmation Email to the User
            await SendConfirmationEmail(model.Email, user);

            // If the user is signed in and in the Admin role, then it is
            // the Admin user that is creating a new user. 
            // So redirect the Admin user to ListUsers action of Administration Controller
            if (signInManager.IsSignedIn(User) && User.IsInRole("Admin"))
            {
                return RedirectToAction("ListUsers", "Administration");
            }

            //If it is not Admin user, then redirect the user to RegistrationSuccessful View
            return View("RegistrationSuccessful");
        }

        // If there are any errors, add them to the ModelState object
        // which will be displayed by the validation summary tag helper
        foreach (var error in result.Errors)
        {
            ModelState.AddModelError(string.Empty, error.Description);
        }
    }

    return View(model);
}

As we already discussed, whenever we add or update domain classes or configurations, we need to sync the database with the model using add-migration and update-database commands using Package Manager Console or .NET Core CLI.

So, open Package Manager Console and Execute the following add-migration and update-database commands. You can give any name to your migration. Here, I am giving Mig1. The name that you are giving it should not be given earlier.

How to Implement the Password Expiration Policy in ASP.NET Core Identity with an Example

Now, the LastPasswordChangedDate column should be added to the AspNetUsers table, as shown in the image below.

How to Implement the Password Expiration Policy in ASP.NET Core Identity

To test the Password Expire functionality, let us update the LastPasswordChangedDate column value to date, which is less than 90 days from the current date. So, execute the following SQL statement.

UPDATE AspNetUsers SET LastPasswordChangedDate = '2022-11-11 00:00:00.0000000'

With the above changes in place, run the application and log in using valid credentials, as shown in the image below.

Password Expiration Policy in ASP.NET Core Identity

Once you click the Login button, it will display the following page, saying your password is expired and asking you to reset it.

Password Expiration Policy in ASP.NET Core

And we have already discussed how to reset passwords using Forgot Password. Once you click on the link, it will open the Forgot Password page, which you can use to reset the password. When you successfully reset the password, the LastPasswordChangedDate column value will update to the current date, and this password will be valid for the next 90 days.

In the next article, I will discuss How to Restrict users from using an Old Password while Resetting the Password in ASP.NET Core Identity. In this article, I explain How to Implement the Password Expiration Policy in ASP.NET Core Identity. I hope you enjoy this article on Implementing Password Expiration Policy in ASP.NET Core Identity.

Leave a Reply

Your email address will not be published. Required fields are marked *