Require user password verification with ASP.NET Core Identity to access Razor Page

This post shows how an ASP.NET Core application which uses ASP.NET Core Identity to authenticate and authorize users of the application can be used to require user password verification to view specific Razor pages in the application. If the user opens one of the Razor pages which require a password verification to open the page, the user will be redirected to a separate Razor page to re-enter a password. All good, the original page can be opened.

Code https://github.com/damienbod/AspNetCoreHybridFlowWithApi

Setup the required password verification page

The RequirePasswordVerificationModel class implements the Razor page which requires that a user has verified a password for the identity user within the last ten minutes. The razor page inherits from the PasswordVerificationBase Razor page which implements the verification check. The constructor of the class needs to pass the parent dependencies. If the user has a valid verification, the page will be displayed, otherwise the application redirects to the password verification route.

public class RequirePasswordVerificationModel : PasswordVerificationBase
{
	public RequirePasswordVerificationModel(
		UserManager<ApplicationUser> userManager) : base(userManager){}

	public async Task<IActionResult> OnGetAsync()
	{
		var passwordVerificationOk = await ValidatePasswordVerification();
		
		if (!passwordVerificationOk)
		{
			return RedirectToPage("/PasswordVerification", 
				new { ReturnUrl = "/DoUserChecks/RequirePasswordVerification" });
		}

		return Page();
	}
}

The PasswordVerificationBase Razor page implements the PageModel. The ValidatePasswordVerification method checks if the user is already authenticated. It then checks if the user has not signed in after the last successful verification. The UserManager is used to fetch the data from the database. The last verification is implemented so that it can be no longer that ten minutes old.

public class PasswordVerificationBase : PageModel
{
	public static string PasswordCheckedClaimType = "passwordChecked";
	public static string LastLoginClaimType = "lastlogin";

	private readonly UserManager<ApplicationUser> _userManager;

	public PasswordVerificationBase(UserManager<ApplicationUser> userManager)
	{
		_userManager = userManager;
	}

	public async Task<bool> ValidatePasswordVerification()
	{
		if (User.Identity.IsAuthenticated)
		{
			if (User.HasClaim(c => c.Type == PasswordCheckedClaimType))
			{
				var user = await _userManager.FindByEmailAsync(User.Identity.Name);

				var lastLogin = DateTime.FromFileTimeUtc(
					Convert.ToInt64(user.LastLogin));

				var lastPasswordVerificationClaim 
					= User.FindFirst(PasswordCheckedClaimType);
					
				var lastPasswordVerification = DateTime.FromFileTimeUtc(
					Convert.ToInt64(lastPasswordVerificationClaim.Value));

				if (lastLogin > lastPasswordVerification)
				{
					return false;
				}
				else if (DateTime.UtcNow.AddMinutes(-10.0) > lastPasswordVerification)
				{
					return false;
				}

				return true;
			}
		}

		return false;
	}
}

If the user needs to re-enter credentials, the PasswordVerificationModel Razor page is used for this. This class was built using the identity scaffolded login Razor page from ASP.NET Core Identity. The old password verifications claims are removed using the UserManager service. A new password verification claim is created, if the user successfully re-entered the password and the sign in is refreshed with the new ClaimIdentity instance.

public class PasswordVerificationModel : PageModel
{
	private readonly UserManager<ApplicationUser> _userManager;
	private readonly SignInManager<ApplicationUser> _signInManager;
	private readonly ILogger<PasswordVerificationModel> _logger;

	public PasswordVerificationModel(SignInManager<ApplicationUser> signInManager,
		ILogger<PasswordVerificationModel> logger,
		UserManager<ApplicationUser> userManager)
	{
		_userManager = userManager;
		_signInManager = signInManager;
		_logger = logger;
	}

	[BindProperty]
	public CheckModel Input { get; set; }

	public IList<AuthenticationScheme> ExternalLogins { get; set; }

	public string ReturnUrl { get; set; }

	[TempData]
	public string ErrorMessage { get; set; }

	public class CheckModel
	{
		[Required]
		[DataType(DataType.Password)]
		public string Password { get; set; }
	}

	public async Task<IActionResult> OnGetAsync(string returnUrl = null)
	{
		if (!string.IsNullOrEmpty(ErrorMessage))
		{
			ModelState.AddModelError(string.Empty, ErrorMessage);
		}

		var user = await _userManager.GetUserAsync(User);
		if (user == null)
		{
			return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
		}

		var hasPassword = await _userManager.HasPasswordAsync(user);
		if (!hasPassword)
		{
			return NotFound($"User has no password'{_userManager.GetUserId(User)}'.");
		}

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

		return Page();
	}

	public async Task<IActionResult> OnPostAsync(string returnUrl = null)
	{
		returnUrl ??= Url.Content("~/");

		var user = await _userManager.GetUserAsync(User);
		if (user == null)
		{
			return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
		}

		if (ModelState.IsValid)
		{
			// This doesn't count login failures towards account lockout
			// To enable password failures to trigger account lockout, set lockoutOnFailure: true
			var result = await _signInManager.PasswordSignInAsync(user.Email, Input.Password, false, lockoutOnFailure: false);
			if (result.Succeeded)
			{
				_logger.LogInformation("User password re-entered");

				await RemovePasswordCheck(user);
				var claim = new Claim(PasswordVerificationBase.PasswordCheckedClaimType,
					DateTime.UtcNow.ToFileTimeUtc().ToString());
				await _userManager.AddClaimAsync(user, claim);
				await _signInManager.RefreshSignInAsync(user);

				return LocalRedirect(returnUrl);
			}
			if (result.IsLockedOut)
			{
				_logger.LogWarning("User account locked out.");
				return RedirectToPage("./Lockout");
			}
			else
			{
				ModelState.AddModelError(string.Empty, "Invalid login attempt.");
				return Page();
			}
		}

		// If we got this far, something failed, redisplay form
		return Page();
	}

	private async Task RemovePasswordCheck(ApplicationUser user)
	{
		if (User.HasClaim(c => c.Type == PasswordVerificationBase.PasswordCheckedClaimType))
		{
			var claims = User.FindAll(PasswordVerificationBase.PasswordCheckedClaimType);
			foreach (Claim c in claims)
			{
				await _userManager.RemoveClaimAsync(user, c);
			}
		}
	}
}

The PasswordVerificationModel Razor page html template displays the user input form with the password field.

@page
@model PasswordVerificationModel

@{
    ViewData["Title"] = "Password Verification";
}

<h1>@ViewData["Title"]</h1>
<div class="row">
    <div class="col-md-4">
        <section>
            <form id="account" method="post">
                <h4>Verify account using your password</h4>
                <hr />
                <div asp-validation-summary="All" class="text-danger"></div>
                <div class="form-group">
                    <label asp-for="Input.Password"></label>
                    <input asp-for="Input.Password" class="form-control" />
                    <span asp-validation-for="Input.Password" 
                       class="text-danger"></span>
                </div>
                <div class="form-group">
                    <button type="submit" class="btn btn-primary">
                       Re-enter password
                    </button>
                </div>
            </form>
        </section>
    </div>
</div>

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

The Login Razor page needs to be updated to add a login file time value for DateTime.UtcNow when the login successfully occurred. This value is used in the base Razor page to verify the password check. The LastLogin property was added for this.

var result = await _signInManager.PasswordSignInAsync(
	Input.Email, Input.Password, 
	Input.RememberMe, 
	lockoutOnFailure: false);
	
if (result.Succeeded)
{
	_logger.LogInformation("User logged in.");

	var user = await _userManager.FindByEmailAsync(Input.Email);
	if (user == null)
	{
		return NotFound("help....");
	}
	user.LastLogin = DateTime.UtcNow.ToFileTimeUtc().ToString();

	var lastLoginResult = await _userManager.UpdateAsync(user);

	return LocalRedirect(returnUrl);
}

The LastLogin property was added to the ApplicationUser which implements the IdentityUser. This value is persisted to the Entity Framework Core database.

public class ApplicationUser : IdentityUser
{
	public string LastLogin { get; set; }
}

When the application is started, the user can login and will need to verify a password to access the Razor page implemented to require this feature.

Notes:

This was relatively simple to implement thanks to the helpers in ASP.NET Core Identity. It requires a user password login to work which is not always available or used. A FIDO2 verification would probably be better or a simple authenticator push notification. Some applications have the requirement for password verification to use a page, a view or a service for some extra sensitive processing and this would help here.

Links:

https://www.learnrazorpages.com/razor-pages/

https://docs.microsoft.com/en-us/aspnet/core/razor-pages

https://docs.microsoft.com/en-us/aspnet/core/security

2 comments

  1. […] Require user password verification with ASP.NET Core Identity to access Razor Page (Damien Bowden) […]

  2. […] Require user password verification with ASP.NET Core Identity to access Razor Page – Damien Bowden […]

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: