Core Identity: Enforcing 2FA Setup on Login
Implementing two/multi-factor authentication (2FA/MFA) with the ASP.NET Core Identity Framework is not difficult, especially if users can decide for themselves whether and when to set up two-factor authentication. It becomes tricky when user accounts are created by third parties (e.g. administrators) and the users are required to set up their second factor, e.g. a TOTP authenticator app, before using the system. This article describes a possible solution for this scenario. It uses only public interfaces and standard concepts from Identity Core, so that the code is future-proof and does not interfere with the inner workings of the framework. Although the standard Identity Core example is used for the demonstration, the individual steps should also be easy to reproduce in an existing application. The scenario and the requirements are described below. This allows you to check whether this article can meet your expectations. After that, the solution strategy is described and the implementation is shown. Scenario / Requirements A web application is to be protected with the help of Identity Core. As it is an enterprise application that has to be operated and maintained for several years, it is essential that only public interfaces and standard concepts of the framework are used. The application should be able to be used by three different types of users: Anonymous users: Can access the homepage, help pages and login page. Registered users (legacy): May access the protected area. As their accounts were set up before the introduction of MFA, they do not have to use it to sign in. However, they can set up and use MFA if they wish. Registered users (standard): May access the protected area. As their accounts were set up after the introduction of MFA, they are forced to use MFA. Users cannot register themselves. A user account is created by administrators. There is no differentiation of login methods within the protected area. There are therefore no HTTP endpoints that are only accessible in the MFA case. Solution strategy As the administrators do not have access to the users' Authenticator app during setup, they cannot set up 2FA in its full extent. Therefore, standard users must configure 2FA the first time they access the protected area. The types of users mentioned above result in the following four logical types of user accounts: Legacy user account without 2FA Legacy user account with 2FA Standard user account without completed setup Standard user account with completed setup Core Identity does not recognize different types of user accounts. Therefore, the user account will have to be extended by a property that can be used to differentiate between legacy and standard user accounts. The standard implementation of the login process of Core Identity automatically ensures that 2FA is used if this has been set up correctly for the user account. In the case of type 3 user accounts, the user must be immediately redirected to the 2FA setup page after the password-based login. Forwarding to the 2FA setup page ensures that users are forwarded to the next step of the workflow, but access to the protected area is possible because the user is correctly logged in from Core Identity's point of view. To prevent this, each request must be examined to ensure that the signed-in user has used the authentication procedure permitted for them. This can be implemented with the help of a middleware. This allows all requests to pass, except for those from user accounts of type 3. These requests are checked and, if necessary, redirected to the 2FA setup page. Implementation Preparation As already mentioned in the introduction, the standard example from Identity Core is to be used here. To create the corresponding code, the following command must be executed on the CLI in an empty directory: dotnet new webapp --auth Individual -o WebApp1 The code of the front end is not generated by default. To do this, the ASP.NET Core Staffolder is required: dotnet tool install -g dotnet-aspnet-codegenerator Switch to the directory WebApp1 and add a few more packages: dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design dotnet add package Microsoft.EntityFrameworkCore.Design dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore dotnet add package Microsoft.AspNetCore.Identity.UI dotnet add package Microsoft.EntityFrameworkCore.Tools Code for the missing pages can then be generated: dotnet aspnet-codegenerator identity -fi Account.Login;Account.LoginWith2fa;Account.Register;Account.Manage.EnableAuthenticator -dbProvider sqlite Database The scaffolder creates a DbContext that is not used and can lead to issues. Look for the file Areas/Identity/Data/WebApp1IdentityDbContext.cs and delete it. Open Program.cs and remove the unused and unresolvable import of WebApp1.Areas.Identity.Data. To initialize the database, exe

Implementing two/multi-factor authentication (2FA/MFA) with the ASP.NET Core Identity Framework is not difficult, especially if users can decide for themselves whether and when to set up two-factor authentication.
It becomes tricky when user accounts are created by third parties (e.g. administrators) and the users are required to set up their second factor, e.g. a TOTP authenticator app, before using the system.
This article describes a possible solution for this scenario. It uses only public interfaces and standard concepts from Identity Core, so that the code is future-proof and does not interfere with the inner workings of the framework.
Although the standard Identity Core example is used for the demonstration, the individual steps should also be easy to reproduce in an existing application.
The scenario and the requirements are described below. This allows you to check whether this article can meet your expectations. After that, the solution strategy is described and the implementation is shown.
Scenario / Requirements
A web application is to be protected with the help of Identity Core. As it is an enterprise application that has to be operated and maintained for several years, it is essential that only public interfaces and standard concepts of the framework are used.
The application should be able to be used by three different types of users:
- Anonymous users: Can access the homepage, help pages and login page.
- Registered users (legacy): May access the protected area. As their accounts were set up before the introduction of MFA, they do not have to use it to sign in. However, they can set up and use MFA if they wish.
- Registered users (standard): May access the protected area. As their accounts were set up after the introduction of MFA, they are forced to use MFA.
Users cannot register themselves. A user account is created by administrators.
There is no differentiation of login methods within the protected area. There are therefore no HTTP endpoints that are only accessible in the MFA case.
Solution strategy
As the administrators do not have access to the users' Authenticator app during setup, they cannot set up 2FA in its full extent. Therefore, standard users must configure 2FA the first time they access the protected area.
The types of users mentioned above result in the following four logical types of user accounts:
- Legacy user account without 2FA
- Legacy user account with 2FA
- Standard user account without completed setup
- Standard user account with completed setup
Core Identity does not recognize different types of user accounts. Therefore, the user account will have to be extended by a property that can be used to differentiate between legacy and standard user accounts.
The standard implementation of the login process of Core Identity automatically ensures that 2FA is used if this has been set up correctly for the user account. In the case of type 3 user accounts, the user must be immediately redirected to the 2FA setup page after the password-based login.
Forwarding to the 2FA setup page ensures that users are forwarded to the next step of the workflow, but access to the protected area is possible because the user is correctly logged in from Core Identity's point of view.
To prevent this, each request must be examined to ensure that the signed-in user has used the authentication procedure permitted for them. This can be implemented with the help of a middleware. This allows all requests to pass, except for those from user accounts of type 3. These requests are checked and, if necessary, redirected to the 2FA setup page.
Implementation
Preparation
As already mentioned in the introduction, the standard example from Identity Core is to be used here. To create the corresponding code, the following command must be executed on the CLI in an empty directory:
dotnet new webapp --auth Individual -o WebApp1
The code of the front end is not generated by default. To do this, the ASP.NET Core Staffolder is required:
dotnet tool install -g dotnet-aspnet-codegenerator
Switch to the directory WebApp1 and add a few more packages:
dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.AspNetCore.Identity.UI
dotnet add package Microsoft.EntityFrameworkCore.Tools
Code for the missing pages can then be generated:
dotnet aspnet-codegenerator identity -fi Account.Login;Account.LoginWith2fa;Account.Register;Account.Manage.EnableAuthenticator -dbProvider sqlite
Database
The scaffolder creates a DbContext that is not used and can lead to issues. Look for the file Areas/Identity/Data/WebApp1IdentityDbContext.cs and delete it.
Open Program.cs and remove the unused and unresolvable import of WebApp1.Areas.Identity.Data.
To initialize the database, execute the following command:
dotnet ef database update
Differentiation between legacy and standard user accounts
The solution strategy requires a differentiation between legacy user accounts (2FA is voluntary) and standard user accounts (2FA is mandatory). As a result, this property must be assigned during user registration.
Note: This article is about showing the technical solution. For better readability, therefore, no administration API is implemented, but the existing "Register User" form is regarded as the administrator interface.
In order to be able to create both types of user account for test purposes, a checkbox is added to the register form, which can be used to differentiate between legacy and standard users.
Open Register.cshtml and add a checkbox below the second password field:
class="form-check mb-3">
asp-for="Input.RequireMfa" class="form-check-input" type="checkbox" />
And add a property to the model (Register.cshtml.cs):
public class InputModel
{
///
/// Indicates whether the user is required to use 2FA/MFA
///
public bool RequireMfa { get; set; }
}
The method that processes the input must be adapted so that the type of user account is saved.
Make sure that the user was created. Use the log-message "User created a new account with password." as an anchor. Add a corresponding claim to the user and update the identity-session to make the change available immediately:
_logger.LogInformation("User created a new account with password.");
await _userManager.AddClaimAsync(user, new("enforceMfa", Input.RequireMfa ? "true" : "false"));
await _signInManager.RefreshSignInAsync(user);
The use of a claim to flag user accounts may seem odd. However, it offers two advantages:
There is no need to create a separate user account type (derived from IdentityUser
). This means that persistence is completely taken over by Core Identity or its Entity Framework integration. In addition, the claim is automatically transferred to the claims of the principal in the identity session. This means that the value can later be accessed directly in the middleware without the need for database access.
Modify login logic
Standard users without configured 2FA should be redirected to the setup page. A redirect may therefore need to be made after a login with a password. For this purpose, the logic in Login.cshtml.cs after a user has logged in needs to be extended.
Use the log message "User logged in." as anchor:
_logger.LogInformation("User logged in.");
var user = await _userManager.FindByNameAsync(Input.Email);
var claims = await _userManager.GetClaimsAsync(user);
var claim = claims.FirstOrDefault(c => c.Type == "enforceMfa");
var mfaIsRequired = claim is not null && claim.Value == "true";
if (mfaIsRequired)
{
// MFA is required but was not used to log in. Redirect user to setup.
return RedirectToPage("./Manage/EnableAuthenticator");
}
else
{
return LocalRedirect(returnUrl);
}
The default implementation does not use a UserManager
. Add the required dependency using constructor-injection:
public class LoginModel : PageModel
{
private readonly UserManager<IdentityUser> _userManager;
public LoginModel(UserManager<IdentityUser> userManager, SignInManager<IdentityUser> signInManager, ILogger<LoginModel> logger)
{
_userManager = userManager;
}
Simplify Authenticator Setup
To be able to conveniently set up the authenticator app later during testing, a corresponding QR code must be generated. To generate this on the server side, an additional package is required:
dotnet add package QrCoder
Add a property to the model EnableAuthenticatorModel
, which will contain the QR code as a Base64-encoded PNG:
///
/// QR code for authenticator setup encoded as base64 PNG
///
public string AuthenticatorQrCodeAsBase64 { get; set; }
Add QR code generation to LoadSharedKeyAndQrCodeUriAsync
just below the AuthenticatorUri
-assignment.
AuthenticatorUri = GenerateQrCodeUri(email, unformattedKey);
var qrGenerator = new QRCodeGenerator();
var qrCodeData = qrGenerator.CreateQrCode(AuthenticatorUri, QRCodeGenerator.ECCLevel.Q);
var qrCode = new PngByteQRCode(qrCodeData);
AuthenticatorQrCodeAsBase64 = Convert.ToBase64String(qrCode.GetGraphic(5));
To display the QR code, change the page EnableAuthenticator.cshtml to display the image in div-block qrCode.
id="qrCode">
src="data:image/png;base64,@Model.AuthenticatorQrCodeAsBase64" alt="QR Code" />
Modify 2FA setup logic
After the user has successfully set up their second authentication factor, they must be forcibly logged out. Due to the necessary new login, the account is then recognizable for all parts of the system as a fully configured standard user account.
To log out the user, a logout must be executed in EnableAuthenticator.cshtml.cs directly after setting the StatusMessage
with the help of SignInManager
:
StatusMessage = "Your authenticator app has been verified.";
await _signInManager.SignOutAsync();
The SignInManager
is not used by the default EnableAuthenticatorModel
. Therefore, the corresponding member must be created and passed via the constructor:
public class EnableAuthenticatorModel : PageModel
{
private readonly SignInManager<IdentityUser> _signInManager;
public EnableAuthenticatorModel(
UserManager<IdentityUser> userManager,
SignInManager<IdentityUser> signInManager,
ILogger<EnableAuthenticatorModel> logger,
UrlEncoder urlEncoder)
{
_userManager = userManager;
_signInManager = signInManager;
}
Implementing the middleware
The middleware, which prevents users with an incorrectly configured standard user account from accessing the protected area, is of central importance.
It is designed in such a way that it only becomes active when a logged-in user accesses a protected endpoint. If this is the case, the system checks what type of user account is involved and which authentication method is used.
If a standard user without two-factor-authentication is recognized (using the amr-claim), the request is answered with a redirect to the 2FA setup page.
However, there are two exceptions to this rule: The 2FA setup page and the logout page must obviously always be accessible for this type of user account.
The code for this looks as follows:
using Microsoft.AspNetCore.Authorization;
///
/// Middleware that ensures that requests to protected resources are only forwarded if a user who needs to use 2fa has set it up correctly.
///
public class EnforceMfaMiddleware
{
private readonly RequestDelegate _next;
private const string s_mfaSetupPath = "/Identity/Account/Manage/EnableAuthenticator";
public EnforceMfaMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext context)
{
var endpoint = context.GetEndpoint();
var isProtected = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>() != null;
if (isProtected)
{
var userAuthenticated = context.User?.Identity is not null && context.User.Identity.IsAuthenticated;
var pathAllowed = IsPathProtectedButAllowed(context.Request.Path);
if (userAuthenticated && !pathAllowed)
{
var mfaRequired = context!.User!.HasClaim("enforceMfa", "true");
var mfaUsed = context!.User!.HasClaim("amr", "mfa");
if (mfaRequired && !mfaUsed)
{
context.Response.Redirect(s_mfaSetupPath);
return;
}
}
}
await _next(context);
}
private static bool IsPathProtectedButAllowed(string path)
{
return path == s_mfaSetupPath || path == "/Identity/Account/Logout";
}
}
The middleware needs to be registered. As it requires the user from the HttpContext
, it must be executed after the authorization middleware. A good place is between UseAuthorization()
and MapStaticAssets()
in your Program.cs.
app.UseAuthorization();
app.UseMiddleware<EnforceMfaMiddleware>();
app.MapStaticAssets();
GitHub repository
You can find a GitHub repository here which contains the code from this article. If you have problems following along, take a look at its history. Each step is included in a separate commit.