Lead Software Engineer & 3× Umbraco MVP

Adding 2FA (+ recovery codes) for Umbraco Members

On a recent client project at work I was tasked with adding two factor authentication to a member login area in Umbraco 13.

After digging around the Umbraco Docs on the topic I found there was very little about adding recovery codes to it so set about trying to do it myself.

During the process I found a number of areas lacking, or having to do workarounds that feel a bit hacky, but a large number of those issues I think arise from the documentation assuming you'll use Umbraco's built in methods that aren't very visible.

Let's dig in...

Adding two factor auth

The first step is to add two factor authentication based on the documentation. This is fairly straightforward and the examples in the docs are spot on for this.

/// <summary>
/// App Authenticator implementation of the ITwoFactorProvider
/// </summary>
public class UmbracoMemberAppAuthenticator : ITwoFactorProvider
{
/// <summary>
/// The unique name of the ITwoFactorProvider. This is saved in a constant for reusability.
/// </summary>
public const string Name = "UmbracoMemberAppAuthenticator";
private readonly IMemberService _memberService;
/// <summary>
/// Initializes a new instance of the <see cref="UmbracoMemberAppAuthenticator"/> class.
/// </summary>
public UmbracoMemberAppAuthenticator(IMemberService memberService)
{
_memberService = memberService;
}
/// <summary>
/// The unique provider name of ITwoFactorProvider implementation.
/// </summary>
/// <remarks>
/// This value will be saved in the database to connect the member with this ITwoFactorProvider.
/// </remarks>
public string ProviderName => Name;
/// <summary>
/// Returns the required data to setup this specific ITwoFactorProvider implementation. In this case it will contain the url to the QR-Code and the secret.
/// </summary>
/// <param name="userOrMemberKey">The key of the user or member</param>
/// <param name="secret">The secret that ensures only this user can connect to the authenticator app</param>
/// <returns>The required data to setup the authenticator app</returns>
public Task<object> GetSetupDataAsync(Guid userOrMemberKey, string secret)
{
var member = _memberService.GetByKey(userOrMemberKey);
if (member == null)
{
return null;
}
var applicationName = "Member Login";
var twoFactorAuthenticator = new TwoFactorAuthenticator();
SetupCode setupInfo = twoFactorAuthenticator.GenerateSetupCode(applicationName, member.Username, secret, false);
return Task.FromResult<object>(new QrCodeSetupData()
{
SetupCode = setupInfo,
Secret = secret
});
}
/// <summary>
/// Validated the code and the secret of the user.
/// </summary>
public bool ValidateTwoFactorPIN(string secret, string code)
{
var twoFactorAuthenticator = new TwoFactorAuthenticator();
return twoFactorAuthenticator.ValidateTwoFactorPIN(secret, code);
}
/// <summary>
/// Validated the two factor setup
/// </summary>
/// <remarks>Called to confirm the setup of two factor on the user. In this case we confirm in the same way as we login by validating the PIN.</remarks>
public bool ValidateTwoFactorSetup(string secret, string token) => ValidateTwoFactorPIN(secret, token);
}

Compose this to register it for DI...

public class UmbracoAppAuthenticatorComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
var identityBuilder = new MemberIdentityBuilder(builder.Services);
identityBuilder.AddTwoFactorProvider<UmbracoMemberAppAuthenticator>(UmbracoMemberAppAuthenticator.Name);
}
}

Then you can set up a template to allow members to opt-in to two factor:

MembersArea.cshtml
@using Umbraco.Cms.Core.Services
@using Umbraco.Cms.Web.Website.Controllers
@using Umbraco.Cms.Web.Website.Models
@using My.Website @* Or whatever your namespace with the QrCodeSetupData model is *@
@inject MemberModelBuilderFactory memberModelBuilderFactory
@inject ITwoFactorLoginService twoFactorLoginService
@{
// Build a profile model to edit, by fetching the member's unique key.
var profileModel = await memberModelBuilderFactory
.CreateProfileModel()
.BuildForCurrentMemberAsync();
// Show all two factor providers
var providerNames = twoFactorLoginService.GetAllProviderNames();
if (providerNames.Any())
{
<div asp-validation-summary="All" class="text-danger"></div>
foreach (var providerName in providerNames)
{
var setupData = await twoFactorLoginService.GetSetupInfoAsync(profileModel.Key, providerName);
// If the `setupData` is `null` for the specified `providerName` it means the provider is already set up.
// In this case, a button to disable the authentication is shown.
if (setupData is null)
{
@using (Html.BeginUmbracoForm<UmbTwoFactorLoginController>(nameof(UmbTwoFactorLoginController.Disable)))
{
<input type="hidden" name="providerName" value="@providerName"/>
<button type="submit">Disable @providerName</button>
}
}
// If `setupData` is not `null` the type is checked and the UI for how to set up the App Authenticator is shown.
else if(setupData is QrCodeSetupData qrCodeSetupData)
{
@using (Html.BeginUmbracoForm<UmbTwoFactorLoginController>(nameof(UmbTwoFactorLoginController.ValidateAndSaveSetup)))
{
<h3>Setup @providerName</h3>
<img src="@qrCodeSetupData.SetupCode.QrCodeSetupImageUrl"/>
<p>Scan the code above with your authenticator app <br /> and enter the resulting code here to validate:</p>
<input type="hidden" name="providerName" value="@providerName" />
<input type="hidden" name="secret" value="@qrCodeSetupData.Secret" />
<input type="text" name="code" />
<button type="submit">Validate & save</button>
}
}
}
}
}

After this point, the docs examples fall flat as they assume you're using Umbraco's built in partial snippets and not rolling your own login and register flows.

Checking 2FA on login

Umbraco's MemberSignInManager is an abstraction on top of ASP.NET Identity and so supports 2FA out of the box; we need to be able to leverage it on logging in to check if a member needs to go through the extra authentication step.

In my existing LoginController I needed to add checks for whether 2FA was required for this login:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Post(LoginFormViewModel model)
{
if (!ModelState.IsValid)
{
return CurrentUmbracoPage();
}
var result = await _memberSignInManager.PasswordSignInAsync(model.Email!, model.Password!, false, true);
if (!result.Succeeded)
{
return CurrentUmbracoPage();
}
// This is where we add our check to see if 2FA is required for this login
if (result.RequiresTwoFactor)
{
// Pass two factor provider names to the following page
IEnumerable<string> providerNames =
await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(attemptedUser.Key);
ViewData.SetTwoFactorProviderNames(providerNames);
// Redirect to a page that handles checking a two factor code
return Redirect("/login/2fa");
}
return Redirect("/members");
}

Your login page then needs to handle two factor code validation. This is the first point where I had to build a custom controller to hook into something that's a little bit hidden inside of Umbraco.

By default, the Umbraco examples use UmbLoginController, UmbRegisterController and UmbTwoFactorLoginController to handle login, register and two factor authentication respectively. We can use some of the code from UmbTwoFactorLoginController to do what we need to do in our custom controller. This is taken almost line-for-line from it.

LoginController.cs
[HttpPost]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> Verify2FACode(Verify2FACodeModel model)
{
MemberIdentityUser? user = await _memberSignInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
return CurrentUmbracoPage();
}
if (ModelState.IsValid)
{
var result = await _memberSignInManager.TwoFactorSignInAsync(
model.Provider,
model.Code,
model.IsPersistent,
model.RememberClient);
if (result.Succeeded)
{
// Handle what happens if the user succeeds
return Redirect("/members");
}
}
// We need to set this, to ensure we show the 2fa login page
IEnumerable<string> providerNames =
await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(user.Key);
ViewData.SetTwoFactorProviderNames(providerNames);
return CurrentUmbracoPage();
}
Login.cshtml
foreach (var providerName in providerNames)
{
<div class="2fa-form">
<h4>Two factor authentication</h4>
<div asp-validation-summary="All" class="field-validation-error"></div>
@using (Html.BeginUmbracoForm("Verify2FACode", "Login", FormMethod.Post))
{
<input type="hidden" name="provider" value="@providerName" />
<div class="form-field">
<label for="code">Security code</label>
<input name="code" id="code" autocomplete="one-time-code" inputmode="numeric" pattern="[0-9]*" value="" />
</div>
<button type="submit" class="btn">Validate</button>
}
</div>
}

Once we hit that method, we are validating the code that's generated by our password manager/authenticator and then redirecting to the logged in member's area or showing an error message. So far so good!

If you were using the default login or register snippets that Umbraco provides, you will find that the Verify2FACode method is called by them anyway.

Generating recovery codes

This is where the fun begins. If you're using the built in snippets you will find that some of the steps here are skippable, and will be noted as such. If you're rolling your own membership controllers—specifically when creating members—you may need to do some of the extra steps.

Once a user is logged in, or has just set up 2FA, the normal pattern is that they should be able to see a set of recovery codes once that they save, and then these don't get shown to the user. Let's try and set this up.

We'll add some extra code to our template that allows the user to set up 2FA:

MembersArea.cshtml
@using Umbraco.Cms.Core.Services
@using Umbraco.Cms.Web.Website.Controllers
@using Umbraco.Cms.Web.Website.Models
@using My.Website @* Or whatever your namespace with the QrCodeSetupData model is *@
@inject MemberModelBuilderFactory memberModelBuilderFactory
@inject ITwoFactorLoginService twoFactorLoginService
@{
// Build a profile model to edit, by fetching the member's unique key.
var profileModel = await memberModelBuilderFactory
.CreateProfileModel()
.BuildForCurrentMemberAsync();
// Show all two factor providers
var providerNames = twoFactorLoginService.GetAllProviderNames();
if (providerNames.Any())
{
<div asp-validation-summary="All" class="text-danger"></div>
foreach (var providerName in providerNames)
{
var setupData = await twoFactorLoginService.GetSetupInfoAsync(profileModel.Key, providerName);
// If the `setupData` is `null` for the specified `providerName` it means the provider is already set up.
// In this case, a button to disable the authentication is shown.
if (setupData is null)
{
var user = await _userManager.GetUserAsync(Context.User);
var recoveryCodeCount = await _userManager.CountRecoveryCodesAsync(user);
IEnumerable<string>? recoveryCodes = null;
if (recoveryCodeCount == 0)
{
recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
}
<div class="recovery-codes-section mb-4">
<p>Recovery codes remaining: @recoveryCodeCount</p>
@if (recoveryCodes != null)
{
<div class="alert alert-warning">
<h4>Save these recovery codes</h4>
<p>These recovery codes can be used to access your account if you lose your authenticator device. Save them in a secure location.</p>
<ul>
@foreach (var code in recoveryCodes)
{
<li><code>@code</code></li>
}
</ul>
</div>
}
@if (recoveryCodeCount > 0)
{
<a href="?regenerate=true" class="btn btn-pink">Generate New Recovery Codes</a>
}
</div>
@using (Html.BeginUmbracoForm<UmbTwoFactorLoginController>(nameof(UmbTwoFactorLoginController.Disable)))
{
<input type="hidden" name="providerName" value="@providerName"/>
<button type="submit">Disable @providerName</button>
}
}
// The rest of the code for our file
...
}
}
}

And what you'll find is that just doesn't do anything 🤷‍♂️ Even if you step through the code, you'll find that the underlying error is the fact that the umbracoExternalLogin table doesn't contain an entry for the member we're trying to log in as.

Error message reads `A token was attempted to be saved for login provider [AspNetUserStore] which is not assigned to this user`

Error message reads A token was attempted to be saved for login provider [AspNetUserStore] which is not assigned to this user

Umbraco's MemberUserStore and underlying UmbracoUserManager don't appear to implement SupportsUserTwoFactorRecoveryCodes or the IUserTwoFactorRecoveryCodeStore interface, so these things aren't automatically set up for us. We need to do some manual work to generate at least the basic tokens that we need to support recovery codes.

For a site that is current in development, my suggestion would be to add this on user creation/registration. This example is an exact copy from Umbraco's UmbRegisterController augmented with a few tweaks.

RegistrationController.cs
/// <summary>
/// Registers a new member.
/// </summary>
/// <param name="model">Register member model.</param>
/// <returns>Result of registration operation.</returns>
private async Task<IdentityResult> RegisterMemberAsync(RegisterModel model)
{
using ICoreScope scope = _scopeProvider.CreateCoreScope();
if (string.IsNullOrEmpty(model.Name) && string.IsNullOrEmpty(model.Email) == false)
{
model.Name = model.Email;
}
model.Username = model.UsernameIsEmail || model.Username == null ? model.Email : model.Username;
var identityUser =
MemberIdentityUser.CreateNew(model.Username, model.Email, model.MemberTypeAlias, true, model.Name);
IdentityResult identityResult = await _memberManager.CreateAsync(
identityUser,
model.Password);
if (identityResult.Succeeded)
{
// This is the line we need to add, and it scaffolds an entry for us
// in the `umbracoExternalLogins` table
await _memberManager.AddLoginAsync(identityUser, new UserLoginInfo("[AspNetUserStore]", identityUser.Key.ToString(), "[AspNetUserStore]"));
IMember? member = _memberService.GetByKey(identityUser.Key);
if (member == null)
{
throw new InvalidOperationException($"Could not find a member with key: {member?.Key}.");
}
foreach (MemberPropertyModel property in model.MemberProperties.Where(p => p.Value != null).Where(property => member.Properties.Contains(property.Alias)))
{
member.Properties[property.Alias]?.SetValue(property.Value);
}
_memberService.Save(member);
if (model.AutomaticLogIn)
{
await _memberSignInManager.SignInAsync(identityUser, false);
}
}
scope.Complete();
return identityResult;
}

By adding this code, we can see new entries added to the umbracoExternalLogin and umbracoExternalLoginToken tables:

A screenshot of DB Browser for SQLIte showing the `umbracoExternalLogin` and `umbracoExternalLoginToken` tables populated with one row each.

A screenshot of DB Browser for SQLIte showing the umbracoExternalLogin and umbracoExternalLoginToken tables populated with one row each.

Now when we register the 2FA provider in our Members Area, we get a list of 10 recovery codes. Success! 🥳🙌

Screenshot of the Members Area showing a list of recovery codes.

Screenshot of the Members Area showing a list of recovery codes.

If your site already exists and has a large number of members, I'd suggest adding some extra code to check if this token exists on login for existing users. This example is also an exact copy from Umbraco's UmbLoginController but with a few tweaks.

LoginController.cs
public async Task<IActionResult> Login([Bind(Prefix = "loginModel")] LoginModel model)
{
if (!ModelState.IsValid)
{
return CurrentUmbracoPage();
}
Microsoft.AspNetCore.Identity.SignInResult signInResult = await _memberSignInManager.PasswordSignInAsync(model.Username, model.Password, model.RememberMe, false);
if (signInResult.Succeeded)
{
TempData["LoginSuccess"] = true;
if (!model.RedirectUrl.IsNullOrWhiteSpace())
{
return Redirect(Url.IsLocalUrl(model.RedirectUrl) ? model.RedirectUrl : CurrentPage.AncestorOrSelf(1).Url(PublishedUrlProvider));
}
return RedirectToCurrentUmbracoUrl();
}
if (signInResult.RequiresTwoFactor)
{
MemberIdentityUser? memberIdentityUser = await _memberManager.FindByNameAsync(model.Username);
if (memberIdentityUser == null)
{
return new ValidationErrorResult("No local member found for username " + model.Username);
}
var existingLogins = await _memberManager.GetLoginsAsync(memberIdentityUser);
var existing2faLogin = existingLogins.FirstOrDefault(x => x.LoginProvider == "[AspNetUserStore]");
if (existing2faLogin == null)
{
await _memberManager.AddLoginAsync(memberIdentityUser, new UserLoginInfo("[AspNetUserStore]", memberIdentityUser.Key.ToString(), "[AspNetUserStore]"));
}
IEnumerable<string> providerNames = await _twoFactorLoginService.GetEnabledTwoFactorProviderNamesAsync(memberIdentityUser.Key);
ViewData.SetTwoFactorProviderNames(providerNames);
}
else if (signInResult.IsLockedOut)
{
ModelState.AddModelError("loginModel", "Member is locked out");
}
else if (signInResult.IsNotAllowed)
{
ModelState.AddModelError("loginModel", "Member is not allowed");
}
else
{
ModelState.AddModelError("loginModel", "Invalid username or password");
}
return CurrentUmbracoPage();
}

The TL;DR version

The TL;DR version of this is that you need to add a login reference via the IMemberManager to allow recovery codes to be generated.

public class RegisterController : SurfaceController
{
private readonly IMemberManager _memberManager;
public RegisterController(
IMemberManager memberManager,
IUmbracoContextAccessor umbracoContextAccessor,
IUmbracoDatabaseFactory databaseFactory,
ServiceContext services,
AppCaches appCaches,
IProfilingLogger profilingLogger,
IPublishedUrlProvider publishedUrlProvider)
: base(umbracoContextAccessor, databaseFactory, services, appCaches, profilingLogger, publishedUrlProvider)
{
_memberManager = memberManager;
}
private async Task<IActionResult> Register(RegisterModel model)
{
//... all your other registration code
await _memberManager.AddLoginAsync(memberIdentityUser, new UserLoginInfo("[AspNetUserStore]", memberIdentityUser.Key.ToString(), "[AspNetUserStore]"));
//... the rest of your registration code
}
}

The reference to [AspNetUserStore] is really important here as it's what Umbraco's UserManager is expecting to find when looking for recovery codes. You can see exactly where this code is in the aspnetcore Identity source code.

Validating recovery codes

As a final step, we need to allow a user to use one of their recovery codes to actually log in if they can't use their authenticator. For this, we need a separate model to store the code and a redirect URL, and a new method in the LoginController to handle things for us.

We also have to do a bit of magic in our controller to get access to the MemberSignInManager underneath IMemberSignInManager as not all of the methods are set as public on the interface.

RecoveryModel.cs
public class RecoveryModel
{
[Display(Name = "Recovery Code")]
public string? RecoveryCode { get; set; }
public string? RedirectUrl { get; set; }
}
LoginController.cs
public class LoginController : SurfaceController
{
private readonly IMemberManager _memberManager;
private readonly IMemberSignInManager _memberSignInManager;
private readonly ITwoFactorLoginService _twoFactorLoginService;
public readonly MemberSignInManager _umbracoMemberSignInManager;
public LoginController(
IUmbracoContextAccessor umbracoContextAccessor,
IUmbracoDatabaseFactory databaseFactory,
ServiceContext services,
AppCaches appCaches,
IProfilingLogger profilingLogger,
IPublishedUrlProvider publishedUrlProvider,
IMemberSignInManager memberSignInManager,
IMemberManager memberManager,
ITwoFactorLoginService twoFactorLoginService,
IMemberUserStore memberUserStore,
UserManager<MemberIdentityUser> memberUserManager,
IHttpContextAccessor contextAccessor,
IUserClaimsPrincipalFactory<MemberIdentityUser> claimsFactory,
IOptions<IdentityOptions> optionsAccessor,
ILogger<SignInManager<MemberIdentityUser>> logger,
IAuthenticationSchemeProvider schemes,
IUserConfirmation<MemberIdentityUser> confirmation,
IMemberExternalLoginProviders memberExternalLoginProviders,
IEventAggregator eventAggregator,
IOptions<SecuritySettings> securitySettings,
IRequestCache requestCache)
: base(umbracoContextAccessor, databaseFactory, services, appCaches, profilingLogger, publishedUrlProvider)
{
_memberSignInManager = memberSignInManager;
_memberManager = memberManager;
_twoFactorLoginService = twoFactorLoginService;
_umbracoMemberSignInManager = new MemberSignInManager(
memberUserManager,
contextAccessor,
claimsFactory,
optionsAccessor,
logger,
schemes,
confirmation,
memberExternalLoginProviders,
eventAggregator,
securitySettings,
requestCache);
}
// Existing login code...
public async Task<IActionResult> UseRecoveryCode([Bind(Prefix = "recoveryModel")] RecoveryModel model)
{
if (!ModelState.IsValid)
{
return CurrentUmbracoPage();
}
var user = await _memberSignInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new InvalidOperationException("Unable to load two-factor authentication user.");
}
var recoveryCode = model.RecoveryCode.Replace(" ", string.Empty);
var result = await _umbracoMemberSignInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
await _memberManager.GetUserIdAsync(user);
if (result.Succeeded)
{
return LocalRedirect(model.RedirectUrl ?? Url.Content("~/"));
}
if (result.IsLockedOut)
{
return RedirectToPage("./Lockout");
}
else
{
ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
return CurrentUmbracoPage();
}
}
}

On filling out our recovery form and submitting, we will now be taken to the members area and our dashboard shows that only 9 more codes are available for recovery use. Perfect!

Summary

There we have it! A working implementation of both two factor authentication and recovery codes for members. I have yet to try this on v16 but from what I've seen of Umbraco's Member implementation there I think it should work as-is.

There's some follow up I want to look at including adding PRs to core to make some of these classes and interfaces easier to use rather than having to do some hacking about, and make it more of a first class citizen.

I've added all the working code here into a GitHub repo as a reference point for people to use and try.

Happy hacking!