Back to: ASP.NET Core Identity Tutorials
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 Account Lockout in ASP.NET Core Identity.
What is a Password Change Policy?
A Password Change Policy is a set of rules and guidelines established by an organization or system to indicate how and when the users create, manage, and update their passwords. These policies enhance security by ensuring passwords are strong, regularly updated, and properly managed to prevent unauthorized access.
Common elements of the Password Change Policy include enforcing password complexity, setting expiration periods for passwords, and preventing the reuse of previous passwords.
- 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, 180 days, or 365 days.
- Password History: To prevent users from reusing recent passwords, the policy might maintain a history of used passwords. It is advisable not to allow the reuse of the last 5 to 10 passwords.
- 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.
- Mandatory Password Changes: The policy might mandate an immediate password change in specific scenarios, such as after a security breach or when a password is suspected to have been compromised.
- User Notification: The policy might include rules for notifying users about upcoming password expirations, usually through email or some user interface, giving them time to update their passwords before expiration.
Password Change 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, extend the IdentityUser class to store metadata like the last password change date if their password has expired. 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; } } }
Creating Change Password History Model:
Next, we need to create a table to store password hashes. So, create a class file named UserPasswordHistory.cs within the Models folder and then copy and paste the following code:
namespace ASPNETCoreIdentityDemo.Models { public class UserPasswordHistory { public int Id { get; set; } public string UserId { get; set; } public string PasswordHash { get; set; } public DateTime CreatedDate { get; set; } public ApplicationUser User { get; set; } } }
Modify ApplicationDbContext
Next, modify the DbContext class as follows. Here, we are adding UserPasswordHistory as a DbSet Property so that when we do migration, the corresponding database table will be created.
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace ASPNETCoreIdentityDemo.Models { public class ApplicationDbContext : IdentityDbContext<ApplicationUser, ApplicationRole, string> { public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { } protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); // Customizing the ASP.NET Identity model and overriding the defaults if needed builder.Entity<IdentityUserRole<string>>() .HasOne<ApplicationRole>() .WithMany() .HasForeignKey(ur => ur.RoleId) .OnDelete(DeleteBehavior.NoAction); } public DbSet<UserPasswordHistory> UserPasswordHistory { get; set; } } }
Generating and Applying Migration:
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. So, open the Package Manager Console and Execute the Add-Migration and Update-Database commands as follows. You can give any name to your migration. Here, I am giving Mig5. The name that you are giving it should not be given earlier.
Now, the LastPasswordChangedDate column should be added to the AspNetUsers table, as shown in the image below.
Also, it should have added the UserPasswordHistory table, as shown in the below image:
Modifying Login Post Action Method to Check Password Expiration:
In our login logic, we need to check if the user’s password has expired. Here, we need to check that the LastPasswordChangedDate date must not exceed 90 days. As per our business logic, we can set this to 180 days, 360 days, etc. Further, we are hardcoding the values, but we should get this value from some configuration file or from the database. So, modify the Login Post Action method of the Account controller as follows:
[HttpPost] public async Task<IActionResult> Login(LoginViewModel model) { if (ModelState.IsValid) { // Find user by email var user = await userManager.FindByEmailAsync(model.Email); if (user == null) { // Generic error message to avoid disclosing details ModelState.AddModelError(string.Empty, "Invalid login attempt."); model.ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); return View(model); } // Check if the user is locked out if (await userManager.IsLockedOutAsync(user)) { // Inform the user that their account is locked ModelState.AddModelError(string.Empty, "Your account is locked due to multiple unsuccessful login attempts. Please try again later or contact support."); model.ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); return View(model); } // Check Password first to avoid leaking information about Email Confirmation var passwordCheck = await userManager.CheckPasswordAsync(user, model.Password); if (!passwordCheck) { // Increment access failed count await userManager.AccessFailedAsync(user); // Calculate remaining attempts var accessFailedCount = await userManager.GetAccessFailedCountAsync(user); var maxFailedAccessAttempts = userManager.Options.Lockout.MaxFailedAccessAttempts; var attemptsLeft = maxFailedAccessAttempts - accessFailedCount; // Provide feedback to the user if (accessFailedCount != 0) { ModelState.AddModelError(string.Empty, $"Invalid login attempt. You have {attemptsLeft} more {(attemptsLeft == 1 ? "attempt" : "attempts")} before your account gets locked."); } else { // Handle lockout scenario await SendAccountLockedEmail(user); return RedirectToAction("AccountLocked", "Account"); } model.ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); return View(model); } // Password is valid, now check email confirmation if (!user.EmailConfirmed) { ModelState.AddModelError(string.Empty, "Your email address is not confirmed. Please confirm your email to log in."); model.ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); return View(model); } // SignIn the User // 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 will be populated. var result = await signInManager.PasswordSignInAsync(user, model.Password, model.RememberMe, lockoutOnFailure: true); if (result.Succeeded) { //Check Password Expiration //Here we are hardcoding days as 90 if (user.LastPasswordChangedDate.AddDays(90) < DateTime.Now) { // Password has expired // SignOut the user and redirect to PasswordExpired Pae await signInManager.SignOutAsync(); return View("PasswordExpired", "Account"); } // Redirect user after successful sign-in if (!string.IsNullOrEmpty(model.ReturnUrl) && Url.IsLocalUrl(model.ReturnUrl)) { return Redirect(model.ReturnUrl); } return RedirectToAction(nameof(HomeController.Index), "Home"); } else if (result.RequiresTwoFactor) { // Handle two-factor authentication // Generate a 2FA token either using DefaultPhoneProvider or DefaultEmailProvider // Which provider we use here, same we need to use while doing the verification var TwoFactorAuthenticationToken = await userManager.GenerateTwoFactorTokenAsync(user, TokenOptions.DefaultEmailProvider); //var TwoFactorAuthenticationToken3 = await userManager.GenerateTwoFactorTokenAsync(user, TokenOptions.DefaultPhoneProvider); // Send SMS if the phone number is confirmed if (user.PhoneNumberConfirmed && !string.IsNullOrEmpty(user.PhoneNumber)) { var smsMessage = $"Your Two-Factor Authentication code is: {TwoFactorAuthenticationToken}. Please use this code to log in."; await smsSender.SendSmsAsync(user.PhoneNumber, smsMessage); } // Send Email if the email is confirmed if (user.EmailConfirmed && !string.IsNullOrEmpty(user.Email)) { var emailSubject = "Two-Factor Authentication Code"; var emailBody = $@" <p>Hello {user.FirstName} {user.LastName},</p> <p>Your Two-Factor Authentication code is: <strong>{TwoFactorAuthenticationToken}</strong></p> <p>If you did not request this code, please contact our support team immediately.</p> <p>Thank you,<br/>Your Dot Net Tutorials Team</p>"; await emailSender.SendEmailAsync(user.Email, emailSubject, emailBody, IsBodyHtml: true); } // Redirect to Two-Factor Authentication verification page with data return RedirectToAction("VerifyTwoFactorToken", "Account", new { model.Email, model.ReturnUrl, model.RememberMe }); } else if (result.IsLockedOut) { // Handle lockout scenario // It's important to inform users when their account is locked. await SendAccountLockedEmail(user); return RedirectToAction("AccountLocked", "Account"); } else { // Generic error message for invalid credentials. Also displaying the number of attempts left var attemptsLeft = userManager.Options.Lockout.MaxFailedAccessAttempts - await userManager.GetAccessFailedCountAsync(user); ModelState.AddModelError(string.Empty, $"Invalid login attempt. You have {attemptsLeft} more {(attemptsLeft == 1 ? "attempt" : "attempts")} before your account gets locked."); return View(model); } } // If we got this far, something failed; redisplay the form model.ExternalLogins = (await signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); return View(model); }
Adding PasswordExpired Action Method
Next, add the following PasswordExpired Action Method to the Account Controller. This action method will render the Password Expired view to the user.
[HttpGet] public IActionResult PasswordExpired() { return View(); }
Adding PasswordExpired View:
Next add a view named PasswordExpired.cshtml within the Views/Account folder and then copy and paste the following code. This view will be rendered when the user password has expired.
@{ ViewData["Title"] = "Password Expired"; } <div class="container mt-5"> <div class="row justify-content-center"> <div class="col-md-8"> <div class="card shadow-lg"> <div class="card-header bg-danger text-white text-center"> <h2>Password Expired</h2> </div> <div class="card-body text-center"> <p class="lead text-muted"> For your account's security, your password has expired. To continue accessing your account, please reset your password. </p> <p> <a asp-action="ForgotPassword" asp-controller="Account" class="btn btn-primary btn-lg"> Reset Password </a> </p> <p class="text-muted mt-3"> If you have any issues resetting your password, please contact our <a href="mailto:info@dotnettutorials.net">Support Team</a>. </p> </div> <div class="card-footer text-center text-muted"> Thank you for helping us keep your account secure. </div> </div> </div> </div> </div>
Updating Last Password Changed Date and Making entry into UserPasswordHistory Table:
When the user updates the password, we need to do the following:
- The Password should not be used earlier. We need to use the UserPasswordHistory table to verify whether the provided password was used earlier. At least it should not be the last five passwords.
- If the password is valid, then we need to update the password, and we also need to update the LastPasswordChangedDate with the current datetime.
- If the password is updated successfully, we need to enter the UserPasswordHistory table with the old user password.
Note: We need to do the above things when updating the password using Forgot Password and Change Password. However, at the time of Registration, we only need to store the LastPasswordChangedDate column value.
Injecting ApplicationDbContext class to Account Controller:
Now, we will use the custom UserPasswordHistory table in our code. Identity Framework does not provide any API to work with this table. So, we need to do the operations manually by using the ApplicationDbContext class. First, let us inject the ApplicationDbContext instance into the Account controller. So, please modify the Account Controller as follows:
public class AccountController : Controller { //userManager will hold the UserManager instance private readonly UserManager<ApplicationUser> userManager; //signInManager will hold the SignInManager instance private readonly SignInManager<ApplicationUser> signInManager; //emailSender will hold the EmailSenderService instance private readonly EmailSenderService emailSender; //smsSender will hold the SMSSender instance private readonly SMSSender smsSender; //_context Service hold the ApplicationDbContext instance private readonly ApplicationDbContext _context; // UserManager, SignInManager, EmailSenderService, SMSSender, and ApplicationDbContext services are injected // into the AccountController using constructor injection public AccountController(UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager, EmailSenderService emailSender, SMSSender smsSender, ApplicationDbContext context) { this.userManager = userManager; this.signInManager = signInManager; this.emailSender = emailSender; this.smsSender = smsSender; _context = context; } //Rest of all Codes }
Modifying ResetPassword Post Action Method:
Next, while re-setting the password within the ResetPassword Post Action Method, we need to enforce the Password Change Policy. So, please modify the ResetPassword Post Action Method of the Account Controller as follows:
[HttpPost] [AllowAnonymous] public async Task<IActionResult> ResetPassword(ResetPasswordViewModel model) { // Check if the incoming model passes all validation rules specified in the data annotations. if (ModelState.IsValid) { // Find the user in the database using the provided email address. var user = await userManager.FindByEmailAsync(model.Email); // Proceed only if the user exists in the database. if (user != null) { // Check Password History var history = await _context.UserPasswordHistory .Where(h => h.UserId == user.Id) .OrderByDescending(h => h.CreatedDate) .Take(5) // Check last 5 passwords .ToListAsync(); //Check if the Provided Password is used earlier by generating the Password hash if (history.Any(h => userManager.PasswordHasher.VerifyHashedPassword(user, h.PasswordHash, model.Password) == PasswordVerificationResult.Success)) { ModelState.AddModelError(string.Empty, "You cannot reuse a previously used password."); return View(model); } // Attempt to reset the user's password using the token and the new password provided in the model. var result = await userManager.ResetPasswordAsync(user, model.Token, model.Password); //Remove the Token from the database await userManager.RemoveAuthenticationTokenAsync(user, "ResetPassword", "ResetPasswordToken"); // If the password reset operation is successful, redirect the user to the Reset Password Confirmation page. if (result.Succeeded) { // Update last password change date and password history table user.LastPasswordChangedDate = DateTime.UtcNow; _context.UserPasswordHistory.Add(new UserPasswordHistory { UserId = user.Id, PasswordHash = user.PasswordHash, //Old Password, not the updated one which saved into the AspNetUsers table CreatedDate = DateTime.UtcNow }); await _context.SaveChangesAsync(); // 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); } return RedirectToAction("ResetPasswordConfirmation", "Account"); } // If the password reset fails, loop through the list of errors returned by Identity // and add them to the ModelState to display on the view. foreach (var error in result.Errors) { ModelState.AddModelError("", error.Description); // Add each error description to ModelState. } // Return the model back to the view so the user can fix any validation errors. return View(model); } // If the user is not found, redirect to the Reset Password Confirmation page to avoid // revealing whether an account exists for the provided email. // This approach prevents account enumeration attacks. return RedirectToAction("ResetPasswordConfirmation", "Account"); } // If the model state is invalid (e.g., missing or incorrect data), return the same view // and display validation errors to the user. return View(model); }
Modifying ChangePassword Post Action Method:
Next, modify the ChangePassword Post Action Method of the Account Controller as follows to include the Password Change Policy:
[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"); } // Check password history var history = await _context.UserPasswordHistory .Where(h => h.UserId == user.Id) .OrderByDescending(h => h.CreatedDate) .Take(5) // Check last 5 passwords .ToListAsync(); //Check if the Provided Password is used earlier by generating then Password hash if (history.Any(h => userManager.PasswordHasher.VerifyHashedPassword(user, h.PasswordHash, model.NewPassword) == PasswordVerificationResult.Success)) { ModelState.AddModelError(string.Empty, "You cannot reuse a previously used password."); return View(model); } // ChangePasswordAsync Method changes the user password var result = await userManager.ChangePasswordAsync(user, model.CurrentPassword, 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) { // Update last password change date and password history table user.LastPasswordChangedDate = DateTime.UtcNow; _context.UserPasswordHistory.Add(new UserPasswordHistory { UserId = user.Id, PasswordHash = user.PasswordHash, //Old Password, not the updated one which saved into the AspNetUsers table CreatedDate = DateTime.UtcNow }); await _context.SaveChangesAsync(); // Dynamically get location and device info var location = await GetLocationAsync(HttpContext); var device = GetDeviceInfo(HttpContext); // Send the notification email await SendPasswordChangedNotificationEmail(user.Email, user, location, device); // 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"); } else { foreach (var error in result.Errors) { ModelState.AddModelError(string.Empty, error.Description); } } } return View(model); }
Modifying SetPassword Post Action Method:
Modify the SetPassword Post Action Method of the Account Controller as follows. Here, we don’t need to check the Password history, as when we execute this method, the password has not yet been set. So, here, we only need to set the LastPasswordChangedDate column value and store the password in the password history table.
[Authorize] [HttpPost] public async Task<IActionResult> SetPassword(SetPasswordViewModel model) { if (ModelState.IsValid) { var user = await userManager.GetUserAsync(User); if (user == null) { ModelState.AddModelError(string.Empty, "Invalid User."); return View(); } //Call the AddPasswordAsync method to set the new password without old password var result = await userManager.AddPasswordAsync(user, model.Password); // 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(); } // Update last password change date and password history table user.LastPasswordChangedDate = DateTime.UtcNow; _context.UserPasswordHistory.Add(new UserPasswordHistory { UserId = user.Id, PasswordHash = user.PasswordHash, CreatedDate = DateTime.UtcNow }); await _context.SaveChangesAsync(); // refresh the authentication cookie to store the updated user information await signInManager.RefreshSignInAsync(user); //redirecting to the AddPasswordConfirmation action method return RedirectToAction("SetPasswordConfirmation", "Account"); } return View(); }
Modifying Register Post Action Method:
Modify the Register Post Action Method of the Account Controller as follows. While creating the application user instance, we need to set the LastPasswordChangedDate column value as the current datetime. Again, here we don’t need to check the password history table as we are creating the user and setting the password for the first time. So, once we create the user, we need to store the password in the password history table.
[HttpPost] [AllowAnonymous] public async Task<IActionResult> Register(RegisterViewModel model) { if (ModelState.IsValid) { // Copy data from RegisterViewModel to IdentityUser var user = new ApplicationUser { FirstName = model.FirstName, LastName = model.LastName, UserName = model.Email, Email = model.Email, 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) { _context.UserPasswordHistory.Add(new UserPasswordHistory { UserId = user.Id, PasswordHash = user.PasswordHash, CreatedDate = DateTime.UtcNow }); await _context.SaveChangesAsync(); //Then send the Confirmation Email to the User await SendConfirmationEmail(model.Email, user); 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); }
Testing the Feature:
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 = '2023-11-11 00:00:00.0000000'
With the above changes, run the application and log in using valid credentials, as shown in the image below.
Once you click the Login button, it will display the following page, saying your password has expired and asking you to reset it.
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. Open a successful password reset, the LastPasswordChangedDate column value will update to the current date, and this password will be valid for the next 90 days.
Once you have successfully Reset the password, you should see one entry into the UserPasswordHistory table, as shown in the image below:
Now, let us try to Change the Password and give the old password, which is stored in the above UserPasswordHistory table. This time, you should see the following error message.
How Does a Password Change Policy Work in Web Applications?
- Tracking Password Changes: Store metadata about password updates, such as the last password change date and previous passwords.
- Password Expiration: Calculate the password’s age and enforce a reset if it exceeds the defined duration.
- Password History Check: Maintain a record of previously used passwords and validate that new passwords do not match any in the history.
- Enforcement at Login: Denies access and redirects users to a password reset page.
Implementing a Password Change Policy in your ASP.NET Core web application using ASP.NET Core Identity involves configuring password requirements, tracking password changes, enforcing expiration, preventing password reuse, and notifying users. By enforcing password expiration, updating users’ credentials regularly, and providing an interface for password change, we help secure our application and comply with best practices for handling user authentication and authorization.
In the next article, I will discuss How to Implement Captcha in ASP.NET Core Identity as part of Login. 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.