Master ASP.NET Core Identity in .NET10: ASP.NET Core Identity MFA
Tutor: Frank Liu
What is MFA
Logging in with just a user name and password is just one factor authentication - it only checks these two things match. This is OK, but not particularly safe.
If we add another check to this, for example a security question, this is known as two-factor authentication. You must pass both parts of authentication before you are authenticated.
The second factor could be anything, not just a security question, for example you may need to supple a security code from a text message or authenticator app, etc.
Any authentication that is more than one factor is known as Multi-factor Authentication (MFA).
2FA Through Email
sequenceDiagram
actor User
User ->> Browser: Login
Browser ->> +Identity: Check Credentials
Identity ->> Browser: Return Security Token
Identity ->> -User: Send Email with Code
User ->> +Browser: Enter code from Email
Browser ->> Identity: Verify code
Identity ->> Browser: Grant access
Browser ->> -User: Display page
When configuring identity, the call to AddDefaultTokenProviders() provides two factor authentication functionality. The IdentityUser base class has a boolean field of TwoFactorEnabled (you can see this in the AspNetUsers database table) so for users who have this enabled this must be populated with true (1). This will normally be set by the user in their profile/details page where they can specify if they want to use two-factor authentication, set via the usual update methods, for example:
var user = await _userManager.FindByNameAsync(User.Identity?.Name ?? "");
user.TwoFactorEnabled = UserProfile.TwoFactorEnabled
await _userManager.UpdateAsync(user);
Once you have the two factor enabled column set to true, you won't be able to log in successfully without passing this step. As such we need to update the login page to handle this situation. When we call PasswordSignInAsync() this will fail, so we need to add a section to handle this to redirect to the two factor page:
var result = await _signInManager.PasswordSignInAsync( ... );
if (result.Succeeded)
{
return RedirectToPage("/Index");
}
if (result.RequiresTwoFactor)
{
return RedirectToPage("/Account/TwoFactor",
new {
Email = Credential.Email,
RememberMe = Credential.RememberMe
});
}
Now we need to create an empty Razor page for /Account/TwoFactor and implement as:
TwoFactor.cshtml.cs:
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.RazorPages;
using WebApp.Data.Accounts;
using WebApp.Services;
namespace WebApp.Pages.Account;
public class TwoFactorModel : PageModel
{
private readonly UserManager<User> _userManager;
private readonly IEmailService _emailService;
public TwoFactorModel(UserManager<User> userManager, IEmailService emailService)
{
_userManager = userManager;
_emailService = emailService;
}
public async Task OnGet(string email, bool rememberMe)
{
// Get the user
var user = await _userManager.FindByEmailAsync(email);
if (user == null) {
// Handle user not found case (e.g., show an error message)
return;
}
// Generate the security code
var securityCode = await _userManager.GenerateTwoFactorTokenAsync(user, "Email");
// Send the security code to the user via email
await _emailService.SendEmailAsync(email,
"Your Two-Factor Authentication Code", $"Your security code is: {securityCode}");
}
}
Running this code and trying to login should now send an email with the code:

We now need to update this page so the user can enter the code and click verify, logging in correctly if the code supplied matches the generated code. This code is kept in a browser cookie Identity.TwiFactorUserId as obviously REST is session-less and we need to send the cookie with the security code back for comparison:
TwoFactor.cshtml:
@page
@model WebApp.Pages.Account.TwoFactorModel
@{
}
<p>
<h3>Please enter the security code from your email:</h3>
</p>
<br />
<div class="container border" style="padding: 20px;">
<form method="post">
<div class="text-danger" asp-validation-summary="All"></div>
<div class="row mb-3">
<div class="col-2">
<label asp-for="EmailMFA.MFAToken" class="form-label"></label>
</div>
<div class="col-5">
<input type="text" asp-for="EmailMFA.MFAToken" class="form-control" />
<span class="text-danger" asp-validation-for="EmailMFA.MFAToken"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<button type="submit" class="btn btn-primary">Verify</button>
</div>
<div class="col-5">
<input type="hidden" asp-for="EmailMFA.RememberMe" />
</div>
</div>
</form>
</div>
And update the code-behind in TwoFactor.cshtml.cs:
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using WebApp.Data.Accounts;
using WebApp.Services;
namespace WebApp.Pages.Account;
public class TwoFactorModel : PageModel
{
private readonly UserManager<User> _userManager;
private readonly SignInManager<User> _signInManager;
private readonly IEmailService _emailService;
[BindProperty]
public EmailMultiFactorAuth EmailMFA { get; set; } = new();
public TwoFactorModel(
UserManager<User> userManager,
SignInManager<User> signInManager,
IEmailService emailService)
{
_userManager = userManager;
_signInManager = signInManager;
_emailService = emailService;
}
public async Task OnGet(string email, bool rememberMe)
{
// Get the user
var user = await _userManager.FindByEmailAsync(email);
EmailMFA.SecurityCode = string.Empty;
EmailMFA.RememberMe = rememberMe;
if (user == null) {
ModelState.AddModelError("Login2FA", "Invalid email.");
}
// Generate the security code
var securityCode = await _userManager.GenerateTwoFactorTokenAsync(user, "Email");
// Send the security code to the user via email
await _emailService.SendEmailAsync(email,
"Your Two-Factor Authentication Code", $"Your security code is: {securityCode}");
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
var result = await _signInManager.TwoFactorSignInAsync(
"Email",
EmailMFA.SecurityCode,
EmailMFA.RememberMe,
rememberClient: false);
if (result.Succeeded)
{
return RedirectToPage("/Index");
}
if (result.IsLockedOut)
{
ModelState.AddModelError("Login2FA", "You are locked out.");
}
else
{
ModelState.AddModelError("Login2FA", "Failed to login.");
}
return Page();
}
}
public class EmailMultiFactorAuth
{
[Required]
[DisplayName("Security Code")]
public string SecurityCode { get; set; } = string.Empty;
public bool RememberMe { get; set; }
}
This code should now work, and as long as you don't take too long before entering the security code emailed to you, you should be able to log in correctly.
How an Authenticator Works
When using an authenticator app the web application does not directly communicate with the Authenticator App. Instead, there is a setup process to set up the MFA Authenticator app to work with a particular application.
The setup process works like this:
- You login into your web application with the first factor (the username and password)
- Then you configure the Web App to support MFA. When that happens, your application generates a security key (which is used as the seed going forward forever)
- You enter this key into your authenticator app in your own mobile device.
- The mobile device now has a copy of the security key.
- The authenticator app generates a
time based one time password. To do this it takes the current time (probably UTC) at a 30 second resolution, and take a hashing algorithm, and then use the key to generate a hashed value. That hash value is what we call the security code. - At the same time the Web App will generate an identical time based one time password in the same way.
- The user enters the security code displayed on the authenticator app into the web application.
- The web app compares the two codes and if identical that means the user who is using the cell phone is the user who originally configured the authenticator app.
This completes the setup process.
Any subsequent logging will trigger the second factor, which is the authenticator. This generates a new time based one time password, which you send to the Web App, which does the same, and if they match then you are logged in.
Implementing Authenticator MFA (Minimal)
To get started we need to focus on the first two steps:
- The web application will generate the key. The user needs to copy the key from the web application to the mobile device.
- The authenticator app generates a security code and the user enters this into the Web App where it is compared.
So firstly we need to create an MFA setup page that displays the security key, then allow the user to enter the authenticator app generated code onto the page to verify the code and make sure the authenticator app has the right key and works for verification.
Again we need to ensure the call to AddDefaultTokenProviders() is present in Program.cs, otherwise the authenticator MFA process will not work.
Create a new empty Razor page /Account/AuthenticatorWithMFASetup and implement as follows.
AuthenticatorWithMFASetup.cshtml.cs:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using WebApp.Data.Accounts;
namespace WebApp.Pages.Account;
[Authorize]
public class AuthenticatorWithMFASetupModel : PageModel
{
private readonly UserManager<User> _userManager;
[BindProperty]
public SetupMFAViewModel SetupMFA { get; set; }
public AuthenticatorWithMFASetupModel(UserManager<User> userManager)
{
_userManager = userManager;
SetupMFA = new SetupMFAViewModel();
}
public async Task OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null) {
ModelState.AddModelError("User", "User not found.");
return;
}
await _userManager.ResetAuthenticatorKeyAsync(user);
var key = await _userManager.GetAuthenticatorKeyAsync(user);
SetupMFA.Key = key;
}
}
public class SetupMFAViewModel
{
public string Key { get; set; }
}
Since the user has to log in before setting up the MFA, this page is annotated with [Authorize].
AuthenticatorWithMFASetup.cshtml:
@page
@model WebApp.Pages.Account.AuthenticatorWithMFASetupModel
@{
}
<h3>Please enter the key into to your authenticator app:</h3>
<p>
@Model.SetupMFA.Key
</p>
Now to test this log into the app with a user who has 2FA disables, then navigate to the /Account/AuthenticatorWithMFASetup page, you should see a key has been generated. This will also be present in the database table [AspNetUserTokens].
We reset the key every time to make sure a new key is generated each time the user attempt to setup MFA with the app.
Now we need to move on to step 2 and verify the code. Update AuthenticatorWithMFASetup.cshtml:
@page
@model WebApp.Pages.Account.AuthenticatorWithMFASetupModel
@{
}
<h3>Step 1</h3>
<p>
Please enter the key into to your authenticator app: <b>@Model.SetupMFA.Key</b>
</p>
<br />
<h3>Step 2</h3>
<p>
Please enter the code from your authenticator app to verify the setup:
</p>
@if (Model.Succeeded)
{
<div class="alert alert-success" role="alert">
Authenticator app setup successful! You can now use it for two-factor authentication.
</div>
}
else
{
<div class="container border" style="padding: 20px;">
<form method="post">
<div class="text-danger" asp-validation-summary="All"></div>
<div class="row mb-3">
<div class="col-2">
<label asp-for="SetupMFA.SecurityCode" class="form-label"></label>
</div>
<div class="col-5">
<input type="text" asp-for="SetupMFA.SecurityCode" class="form-control" />
<span class="text-danger" asp-validation-for="SetupMFA.SecurityCode"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<button type="submit" class="btn btn-primary">Verify</button>
</div>
<div class="col-5">
@* Hidden field to pass the key back to the server for verification *@
<input type="hidden" asp-for="SetupMFA.Key" />
</div>
</div>
</form>
</div>
}
And add a post handler and update the view model in AuthenticatorWithMFASetup.cshtml.cs:
public class AuthenticatorWithMFASetupModel : PageModel
{
...
[BindProperty]
public bool Succeeded { get; set; }
public AuthenticatorWithMFASetupModel(UserManager<User> userManager)
{
...
Succeeded = false;
}
public async Task OnGetAsync()
{
...
SetupMFA.Key = key;
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
var user = await _userManager.GetUserAsync(User);
if (user == null) {
ModelState.AddModelError("User", "User not found.");
return Page();
}
var isValid = await _userManager.VerifyTwoFactorTokenAsync(
user,
_userManager.Options.Tokens.AuthenticatorTokenProvider,
SetupMFA.SecurityCode);
if (!isValid)
{
ModelState.AddModelError("SecurityCode", "Invalid security code.");
return Page();
}
await _userManager.SetTwoFactorEnabledAsync(user, true);
Succeeded = true;
// Optionally, you can add a success message or redirect to another page
return Page();
}
}
public class SetupMFAViewModel
{
public string? Key { get; set; }
[Required]
[DisplayName("Security Code")]
public string SecurityCode { get; set; } = string.Empty;
}
It should now be possible to create the MFA authentication.
Note: I made the following change to present a QR code to the user, rather than having to type in the long security code:
Add the QRCoder package:
dotnet add package QRCoder
Update the AuthenticatorWithMFASetup.cshtml.cs code-behind:
...
using QRCoder;
...
namespace WebApp.Pages.Account;
[Authorize]
public class AuthenticatorWithMFASetupModel : PageModel
{
...
public async Task OnGetAsync()
{
...
await _userManager.ResetAuthenticatorKeyAsync(user);
var key = await _userManager.GetAuthenticatorKeyAsync(user);
GenerateQrCode(user.Email, key);
SetupMFA.Key = key;
}
private void GenerateQrCode(string? userEmail, string? secretKey)
{
var appName = "Udemy Security Web App";
// 1. Format the URI
// Format: otpauth://totp/{Issuer}:{User}?secret={Key}&issuer={Issuer}
string uri = $"otpauth://totp/{Uri.EscapeDataString(appName)}:{Uri.EscapeDataString(userEmail)}?secret={secretKey}&issuer={Uri.EscapeDataString(appName)}";
// 2. Generate the QR Code
using var qrGenerator = new QRCodeGenerator();
using var qrCodeData = qrGenerator.CreateQrCode(uri, QRCodeGenerator.ECCLevel.Q);
using var qrCode = new PngByteQRCode(qrCodeData);
// 3. Convert to Base64 to pass to the view
byte[] qrCodeImage = qrCode.GetGraphic(20);
SetupMFA.QrCodeBase64 = Convert.ToBase64String(qrCodeImage);
}
...
}
public class SetupMFAViewModel
{
...
public string? QrCodeBase64 { get; internal set; }
}
and add it to the layour in AuthenticatorWithMFASetup.cshtml:
...
<h3>Step 1</h3>
<p>
Please enter the key into to your authenticator app:
<b>@Model.SetupMFA.Key</b>
</p>
@if (Model.SetupMFA.QrCodeBase64 != null)
{
<p>
Alternatively, you can scan the QR code below with your authenticator app:
<img src="data:image/png;base64,@Model.SetupMFA.QrCodeBase64" alt="Scan me with your Authenticator App" width="250" height="250" />
</p>
}
<br />
<h3>Step 2</h3>
...
Implement MFA Code Checking
Now the Authenticator is set up correctly, we need to add the ability to enter and verify code from the authenticator app whenever a user tries to sign in, after entering a correct user name and password. Create a new empty Razor page /Account/LoginTwoFactorWithAuthenticator. This page will be very, very similar to the login two factor with email, so we use this as a base and modify it.
In LoginTwoFactorWithAuthenticator.cshtml add the code:
@page
@model WebApp.Pages.Account.LoginTwoFactorWithAuthenticatorModel
@{
}
<p>
<h3>Please enter the security code from your authenticator app:</h3>
</p>
<br />
<div class="container border" style="padding: 20px;">
<form method="post">
<div class="text-danger" asp-validation-summary="All"></div>
<div class="row mb-3">
<div class="col-2">
<label asp-for="AuthenticatorMFA.SecurityCode" class="form-label"></label>
</div>
<div class="col-5">
<input type="text" asp-for="AuthenticatorMFA.SecurityCode" class="form-control" />
<span class="text-danger" asp-validation-for="AuthenticatorMFA.SecurityCode"></span>
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<button type="submit" class="btn btn-primary">Verify</button>
</div>
<div class="col-5">
<input type="hidden" asp-for="AuthenticatorMFA.RememberMe" />
</div>
</div>
</form>
</div>
In LoginTwoFactorWithAuthenticator.cshtml.cs add the code:
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using WebApp.Data.Accounts;
namespace WebApp.Pages.Account;
public class LoginTwoFactorWithAuthenticatorModel : PageModel
{
private readonly SignInManager<User> _signInManager;
[BindProperty]
public AuthenticatorMFAViewModel AuthenticatorMFA { get; set; }
public LoginTwoFactorWithAuthenticatorModel(SignInManager<User> signInManager)
{
AuthenticatorMFA = new AuthenticatorMFAViewModel();
_signInManager = signInManager;
}
public void OnGet(bool rememberMe)
{
AuthenticatorMFA.SecurityCode = string.Empty;
AuthenticatorMFA.RememberMe = rememberMe;
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
return Page();
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(
AuthenticatorMFA.SecurityCode,
AuthenticatorMFA.RememberMe,
false);
if (result.Succeeded)
return RedirectToPage("/Index");
if (result.IsLockedOut)
ModelState.AddModelError("Login2FA", "You are locked out.");
else
ModelState.AddModelError("Login2FA", "Failed to login.");
return Page();
}
public class AuthenticatorMFAViewModel
{
[Required, DisplayName("Code")]
public string SecurityCode { get; set; } = string.Empty;
[BindProperty]
public bool RememberMe { get; set; }
}
}
We can also update our two factor redirection in the Login page to go to this page instead:
if (result.RequiresTwoFactor)
{
return RedirectToPage("/Account/LoginTwoFactorWithAuthenticator",
new {
Credential.RememberMe
});
}
Now when we try to login we should be redirected correctly and able to enter the code from the authenticator app to login.