ASP.NET Core Identity Series – Two Factor Authentication – chsakell’s Blog

Two-Factor Authentication is an additional security layer used to address the vulnerabilities of a standard password-only approach. All popular websites such as Facebook, Twitter, LinkedIn or DropBox recommend their users to enable the feature and prevent unauthorized access to their accounts or at least minimize the probability of compromising them. How does it work? In a nutshell, after authenticating using the standard username-password or email-password credentials, the user is asked to provide a code that only he/she has access to. This code is generated usually by a Time-based One-time Password Algorithm running on the user’s smartphone’s authenticator app, which means that is valid for only a small time of period. So 2FA is a Multi-Factor Authentication model that requires a combination of something you have and something you know in order to access your account. One question arising is how the authenticator knows to generate valid time-based codes. As we will explain later in the post the web app and the authenticator app usually share a key, the authenticator key, which is used to generate the tokens. When you decide to enable 2FA in a website, you will be asked to enter this shared key to your smartphone’s authenticator app. This key can be either manually typed or shared via a QR Code and automatically added to your app. ASP.NET Core Identity totally supports 2FA Time-based One-time Password Algorithm (TOTP) and this is what this post is all about. We will implement all the available 2FA steps one by one and also explain how it works behind the scenes. After understanding its behavior we will override some default implementations to enhance the security level that 2FA provides. Let’s see the contents of the post in detail:

  • Implement all Two-Factor Authentication related tasks:
    • Enable/Disable 2FA – QR Code included
    • Generate/Reset recovery tokens
    • Reset authenticator app
  • Explore the 2FA code and database schema
  • Enhance the security level of 2FA by overriding the default implementation
    • Encrypt authenticator key
    • Encrypt recovery tokens

The source code for the series is available here. Each part has a related branch on the repository. To follow along with this part clone the repository and checkout the two-factor-authentication branch as follow:

git clone https://github.com/chsakell/aspnet-core-identity.git
cd .aspnet-core-identity
git checkout two-factor-authentication

Keep in mind that master branch has been updated with .NET Core 3 & Angular 8!

This post is part of the ASP.NET Core Identity Series:

Enable Two-Factor Authentication

All 2FA features have been added to the AspNetCoreIdentity web app project and a new manage/account route contains all the related UI logic. There are two tabs, one to display the current status of the 2FA and another one to configure it.

There are 3 properties on the account that we are interested in, the 2FA Enabled the Has Authenticator and the 2FA Client remember statuses. All the 2FA backend implementation logic exists in the TwoFactorAuthenticationController and when the manage/account route is activated a call to its Details action is made.

[HttpGet]
[Authorize]
public async Task<AccountDetailsVM> Details()
{
    var user = await _userManager.GetUserAsync(User);
    var logins = await _userManager.GetLoginsAsync(user);

    return new AccountDetailsVM
    {
        Username = user.UserName,
        Email = user.Email,
        EmailConfirmed = user.EmailConfirmed,
        PhoneNumber = user.PhoneNumber,
        ExternalLogins = logins.Select(login => login.ProviderDisplayName).ToList(),
        TwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user),
        HasAuthenticator = await _userManager.GetAuthenticatorKeyAsync(user) != null,
        TwoFactorClientRemembered = await _signInManager.IsTwoFactorClientRememberedAsync(user),
        RecoveryCodesLeft = await _userManager.CountRecoveryCodesAsync(user)
    };
}

_userManager.GetTwoFactorEnabledAsync(user) checks the TwoFactorEnabled column in the AspNetUsers table because we use the Entity’s Framework implementation. It’s just a flag defining either if the 2FA is enabled or not.

If you want to override where this value comes from, all you have to do is provide a custom implementation for the IUserTwoFactorStore interface.

public virtual async Task<bool> GetTwoFactorEnabledAsync(TUser user)
{
    ThrowIfDisposed();
    var store = GetUserTwoFactorStore();
    if (user == null)
    {
        throw new ArgumentNullException(nameof(user));
    }
    return await store.GetTwoFactorEnabledAsync(user, CancellationToken);
}     

The _userManager.GetAuthenticatorKeyAsync(user) returns the authenticator key (if exists) that is used to generate valid time-based tokens. This defines if the user has configured an authenticator app or not and it’s different than the previous one which tells if 2FA is enabled. This means that you may have setup an authenticator but at the same time you can also have disabled the 2FA. To setup an authenticator in our app, select the 2 Factor Authentication menu item in the Security tab.

Next click the Setup authenticator button.

This will present you a key to enter in your authenticator app and a QR Code in case you prefer to scan it instead of typing it.

What changed in the database is that a User Token record has been added for your user.

Notice that the Value column is the exact key generated and asked to type in your authenticator app. If you get back in the Profile tab you will see that Has Authenticator column is now true. This means that _userManager.GetAuthenticatorKeyAsync(user) checks if there’s a record in the AspNetUserTokens table for the logged in user that has LoginProvider = [AspNetUserStore].

public virtual Task<string> GetAuthenticatorKeyAsync(TUser user)
{
    ThrowIfDisposed();
    var store = GetAuthenticatorKeyStore();
    if (user == null)
    {
        throw new ArgumentNullException(nameof(user));
    }
    return store.GetAuthenticatorKeyAsync(user, CancellationToken);
}

InMemoryUserStore and the abstract UserStoreBase classes provide implementations for the IUserAuthenticatorKeyStore interface. As shown below, both of them use the same values for the LoginProvider name, the AuthenticatorKeyTokenName and the RecoveryCodeTokenName.

private const string AuthenticatorStoreLoginProvider = "[AspNetAuthenticatorStore]";
private const string AuthenticatorKeyTokenName = "AuthenticatorKey";
private const string RecoveryCodeTokenName = "RecoveryCodes";

Two-Factor Authentication functionality is also available with the In-Memory database provider. To test it in our app, use “InMemoryProvider”: true in appsettings.json

We haven’t seen yet anything about recovery codes, but you can already guess that when created, a new record in the AspNetUserTokens table will be added, having the same LoginProvider value but RecoveryCodes as the Name value. Let’s pause for a little and understand what happened when you clicked the Setup authenticator button. A GET request made to the SetupAuthenticator action.

[HttpGet]
[Authorize]
public async Task<AuthenticatorDetailsVM> SetupAuthenticator()
{
    var user = await _userManager.GetUserAsync(User);
    var authenticatorDetails = await GetAuthenticatorDetailsAsync(user);

    return authenticatorDetails;
}

private async Task<AuthenticatorDetailsVM> GetAuthenticatorDetailsAsync(IdentityUser user)
{
    // Load the authenticator key & QR code URI to display on the form
    var unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
    if (string.IsNullOrEmpty(unformattedKey))
    {
        await _userManager.ResetAuthenticatorKeyAsync(user);
        unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
    }

    var email = await _userManager.GetEmailAsync(user);

    return new AuthenticatorDetailsVM
    {
        SharedKey = FormatKey(unformattedKey),
        AuthenticatorUri = GenerateQrCodeUri(email, unformattedKey)
    };
}

If no authenticator key found we call the _userManager.ResetAuthenticatorKeyAsync(user) method to create one. This method can also be used to Reset authenticator app which simply changes the key value on the store. The FormatKey method just adds a space every 4 letters so it can more readable to user. To create a QR Code you need a valid authenticator app Uri that contains all the required information for your authenticator app to work properly.

private string GenerateQrCodeUri(string email, string unformattedKey)
{
    const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";

    return string.Format(
        AuthenticatorUriFormat,
        _urlEncoder.Encode("ASP.NET Core Identity"),
        _urlEncoder.Encode(email),
        unformattedKey);
}

If you hover on the QR Code generated you can see this URI.

The most important is that contains the key to be used for generating 6 digits authentication tokens using the Time-based One-time Password Algorithm (TOTP). In javascript, a qr code library used to paint the QR Code. As you can see you can configure several properties to match your requirements.

self.generatedQRCode = new QRCode(document.getElementById("genQrCode"),
    {
        text: self.authenticatorDetails.authenticatorUri,
        width: 150,
        height: 150,
        colorDark: "#000",
        colorLight: "#ffffff",
        correctLevel: QRCode.CorrectLevel.H
    });

Proceed with either scanning the QR Code on your authenticator app in your smartphone or by manually typing it. Google Authenticator and AUTHY are the most popular authenticator apps. When you click the Verify button, 2FA will be enabled for your account and you will also get 10 recovery codes.

Make sure to copy and save these codes. Let’s see the VefiryAuthenticator action in code.

public async Task<ResultVM> VerifyAuthenticator([FromBody] VefiryAuthenticatorVM verifyAuthenticator)
{
    // code omitted

    var verificationCode = verifyAuthenticator.VerificationCode.Replace(" ", string.Empty).Replace("-", string.Empty);

    var is2FaTokenValid = await _userManager.VerifyTwoFactorTokenAsync(
        user, _userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);

    await _userManager.SetTwoFactorEnabledAsync(user, true);

    var result = new ResultVM
    {
        Status = Status.Success,
        Message = "Your authenticator app has been verified",
    };

    if (await _userManager.CountRecoveryCodesAsync(user) != 0) return result;

    var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
    result.Data = new { recoveryCodes };
    return result;
}

The _userManager.VerifyTwoFactorTokenAsync is the method that knows to verify an authenticator token.

public virtual async Task<bool> VerifyTwoFactorTokenAsync(TUser user, string tokenProvider, string token)
{
    // Make sure the token is valid
    var result = await _tokenProviders[tokenProvider].ValidateAsync("TwoFactor", token, this, user);
    if (!result)
    {
        Logger.LogWarning(10, $"{nameof(VerifyTwoFactorTokenAsync)}() failed for user {await GetUserIdAsync(user)}.");
    }
    return result;
}

The ValidateAsync method exists in an AuthenticatorTokenProvider class that implements the IUserTwoFactorTokenProvider interface.

public virtual async Task<bool> ValidateAsync(string purpose, string token, UserManager<TUser> manager, TUser user)
{
    var key = await manager.GetAuthenticatorKeyAsync(user);
    int code;
    if (!int.TryParse(token, out code))
    {
        return false;
    }

    var hash = new HMACSHA1(Base32.FromBase32(key));
    var unixTimestamp = Convert.ToInt64(Math.Round((DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds));
    var timestep = Convert.ToInt64(unixTimestamp / 30);
    // Allow codes from 90s in each direction (we could make this configurable?)
    for (int i = -2; i <= 2; i++)
    {
        var expectedCode = Rfc6238AuthenticationService.ComputeTotp(hash, (ulong)(timestep + i), modifier: null);
        if (expectedCode == code)
        {
            return true;
        }
    }
    return false;
}

Here is where the generated authenticator’s app token is being validated. Back to the VerifyAuthenticator action the code checks if there any recovery tokens exist and if not, creates 10 of them using the userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10) method. The default implementation will add the generated codes semicolon seperated as shown in the database screenshot below.

Authenticating with 2FA

At this point, you should have 2FA configured and enabled for your account. Logout and try to sign in, you should be asked to enter either the 6-digit code generated by the authenticator app configured on your phone or use a recovery code generated before.

The login action will end up in the TwoFaLogin private method.

private async Task<ResultVM> TwoFaLogin(string code, bool isRecoveryCode, bool rememberMachine = false)
{
    SignInResult result = null;

    var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();

    var authenticatorCode = code.Replace(" ", string.Empty).Replace("-", string.Empty);

    if (!isRecoveryCode)
    {
        result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, true,
            rememberMachine);
    }
    else
    {
        result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(authenticatorCode);
    }

    // Code omitted
}

The code is self explanatory. First it needs to know which user tried to login and presented with the 2FA form. Next, depending on whether the user chose to login with an generated or a recovery code calls the _signInManager.TwoFactorAuthenticatorSignInAsync or _signInManager.TwoFactorRecoveryCodeSignInAsync respectively. On the first case the SignInManager uses the UserManager VerifyTwoFactorTokenAsync we described before and if the token is valid signs in the user.

public virtual async Task<SignInResult> TwoFactorAuthenticatorSignInAsync(string code, bool isPersistent, bool rememberClient)
    {
        // code omitted
        var user = await UserManager.FindByIdAsync(twoFactorInfo.UserId);
    
        if (await UserManager.VerifyTwoFactorTokenAsync(user, Options.Tokens.AuthenticatorTokenProvider, code))
        {
            await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent, rememberClient);
            return SignInResult.Success;
        }
        // If the token is incorrect, record the failure which also may cause the user to be locked out
        await UserManager.AccessFailedAsync(user);
        return SignInResult.Failed;
    }

If the Remember machine checkbox is checked then an Identity.TwoFactorRememberMe cookie will be saved in your browser and you wont be asked again to type an authenticator code on the browser you used.

In the second case with the recovery code, it uses the UserManager.RedeemTwoFactorRecoveryCodeAsync method to check that the recovery code provided is valid.

public virtual async Task<SignInResult> TwoFactorRecoveryCodeSignInAsync(string recoveryCode)
{
    // code omitted
    var user = await UserManager.FindByIdAsync(twoFactorInfo.UserId);

    var result = await UserManager.RedeemTwoFactorRecoveryCodeAsync(user, recoveryCode);
    if (result.Succeeded)
    {
        await DoTwoFactorSignInAsync(user, twoFactorInfo, isPersistent: false, rememberClient: false);
        return SignInResult.Success;
    }

    // We don't protect against brute force attacks since codes are expected to be random.
    return SignInResult.Failed;
}

Here’s the entire flow using a GIF (click it to view it in better quality..).

Reset recovery codes

After enabling 2FA, two buttons will be enabled in the app, the Reset recovery codes and the Disable 2FA.

Clicking the Reset recovery codes button will hit the GenerateRecoveryCodes action in the TwoFactorAuthenticationController.

[HttpPost]
[Authorize]
public async Task<ResultVM> GenerateRecoveryCodes()
{
    var user = await _userManager.GetUserAsync(User);

    var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user);

    // code omitted

    var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);

    return new ResultVM
    {
        Status = Status.Success,
        Message = "You have generated new recovery codes",
        Data = new { recoveryCodes }
    };
}

The new codes are generated with the same way generated on the first time and replace the existing ones using the ReplaceCodesAsync method.

public virtual async Task<IEnumerable<string>> GenerateNewTwoFactorRecoveryCodesAsync(TUser user, int number)
{
    var store = GetRecoveryCodeStore();

    var newCodes = new List<string>(number);
    for (var i = 0; i < number; i++)
    {
        newCodes.Add(CreateTwoFactorRecoveryCode());
    }

    await store.ReplaceCodesAsync(user, newCodes.Distinct(), CancellationToken);
    var update = await UpdateAsync(user);
    if (update.Succeeded)
    {
        return newCodes;
    }
    return null;
}

Always save the recovery tokens somewhere secure, maybe on the cloud. In case you lose your phone you will need them to sign in and reset or disable 2FA on your account

Reset authenticator

Clicking the Disable 2FA button will hit the Disable2FA action which simply updates the TwoFactorEnabled property on the user to False.

[HttpPost]
[Authorize]
public async Task<ResultVM> Disable2FA()
{
    var user = await _userManager.GetUserAsync(User);

    if (!await _userManager.GetTwoFactorEnabledAsync(user))
    {
        return new ResultVM
        {
            Status = Status.Error,
            Message = "Cannot disable 2FA as it's not currently enabled"
        };
    }

    var result = await _userManager.SetTwoFactorEnabledAsync(user, false);

    return new ResultVM
    {
        Status = result.Succeeded ? Status.Success : Status.Error,
        Message = result.Succeeded ? "2FA has been successfully disabled" : $"Failed to disable 2FA {result.Errors.FirstOrDefault()?.Description}"
    };
}

Remember: This won’t affect your authenticator app configuration in your phone because nothing happens at the AspNetUserTokens table. To re-enable it, click the Re-enable 2FA button which is actually the same Setup authenticator button.

In case you haven’t reset the authenticator before disabling the 2FA, you can use the tokens being generating by your authenticator app, they will be still valid because the authenticator key remains the same in the database. On the other hand, if you reset the authenticator, the key changes so your authenticator app gets useless and its tokens will be invalid. In this case, you need to re-configured (remove and then add) the authenticator key in your app.

Secure authenticator key and recovery tokens

From what we have said so far it should be clear that the web app and the authenticator app in our smartphones share the authenticator key. Leaving the default implementation for generating authenticator keys and recovery tokens can be dangerous and expose your accounts to potential hackers. The reason is that everyone that gets access to the authenticator key in the database can create and use valid codes, hence access your account. Databases can be compromised in many ways such as SQL Injection. First, let’s confirm that we can generate valid authenticator codes without the help of the authenticator app in our smartphones. Check the Show possible verification codes checkbox in the QR Code screen and see what happens.

You can click on one of the codes returned and confirm that verification will pass successfully. Moreover, the most interesting thing is that your authenticator app in your phone will always display one of the codes you see in green…
When the checkbox is checked, a polling to the ValidAutheticatorCodes action method will start.

[HttpGet]
[Authorize]
public async Task<List<int>> ValidAutheticatorCodes()
{
    List<int> validCodes = new List<int>();

    var user = await _userManager.GetUserAsync(User);

    var key = await _userManager.GetAuthenticatorKeyAsync(user);

    var hash = new HMACSHA1(Infrastructure.Identity.Internals.Base32.FromBase32(key));
    var unixTimestamp = Convert.ToInt64(Math.Round((DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds));
    var timestep = Convert.ToInt64(unixTimestamp / 30);
    // Allow codes from 90s in each direction (we could make this configurable?)
    for (int i = -2; i <= 2; i++)
    {
        var expectedCode = Infrastructure.Identity.Internals.Rfc6238AuthenticationService.ComputeTotp(hash, (ulong)(timestep + i), modifier: null);
        validCodes.Add(expectedCode);
    }

    return validCodes;
}

Do you remember this code? That’s the exact code used in the ValidateAsync method we described before! Of course Base32 and Rfc6238AuthenticationService classes were internal so I fetched them to our app for demonstration reasons. To be honest, I already feel sick knowing that even if I have 2FA enabled my account could be compromised that easy. So can we do something about it? The answer is yes.

Encrypting Authenticator key

The first thing to enhance 2FA security is to encrypt the authenticator key stored in the database. So when a new authenticator key is created, instead of this…

.. we wish to have this!

Now the value is encrypted and if even if revealed to hackers they are useless because they have no clue on how to get the original value. Let’s get to the implementation for securing the authenticator key. First, you will find a TwoFactorAuthentication:EncryptionEnabled property in the appsettings.json which when equals True, tokens will be encrypted.

"TwoFactorAuthentication:EncryptionEnabled":  true 

Encryption – Decryption Disclaimer

We won’t be doing the Ninja with the encryption & decryption here but instead we will use an open-source library named NETCore.Encrypt for simplicity. We will use an AES algorithm which is a symmetric-key algorithm, meaning the same key is used for both encrypting and decrypting the data. You are free though to use your own super secure encryption/decryption logic.
AES needs a symmetric-key to setup and to get one, fire the app and navigate to http://localhost:5000/api/twofactorauthentication/aeskey. Copy the code, open a terminal, cd to the AspNetCoreIdentity folder and run the following command to set it as a secret key.

    dotnet user-secrets set "TwoFactorAuthentication:EncryptionKey" "<eas-key-here>"
    

To encrypt the authenticator key we need to override two methods in the UserManager which means we have to create our own implementation of the UserManager and use it in ASP.NET Core Identity. In our case we created the AppUserManager.

In the Startup configuration we have to tell Identity to use our UserManager implementation.

services.AddIdentity<IdentityUser, IdentityRole>(config => { config.SignIn.RequireConfirmedEmail = true; })
    .AddEntityFrameworkStores<IdentityDbContext>()
    .AddUserManager<AppUserManager>()
    .AddDefaultTokenProviders();

To understand which methods needs to be overridden we need to trace the ResetAuthenticatorKeyAsync which generates the authenticator tokens.

public virtual async Task<IdentityResult> ResetAuthenticatorKeyAsync(TUser user)
{
    ThrowIfDisposed();
    var store = GetAuthenticatorKeyStore();
    if (user == null)
    {
        throw new ArgumentNullException(nameof(user));
    }
    await store.SetAuthenticatorKeyAsync(user, GenerateNewAuthenticatorKey(), CancellationToken);
    await UpdateSecurityStampInternal(user);
    return await UpdateAsync(user);
}

The GenerateNewAuthenticatorKey is the one that does the job..

public virtual string GenerateNewAuthenticatorKey()
            => NewSecurityStamp();

private static string NewSecurityStamp()
{
    byte[] bytes = new byte[20];
    _rng.GetBytes(bytes);
    return Base32.ToBase32(bytes);
}

So all we have to do to encrypt the authenticator key is to provide a new implementation for the GenerateNewAuthenticatorKey method. Back to the AppUserManager..

public override string GenerateNewAuthenticatorKey()
{
    var originalAuthenticatorKey = base.GenerateNewAuthenticatorKey();

    // var aesKey = EncryptProvider.CreateAesKey();

    bool.TryParse(_configuration["TwoFactorAuthentication:EncryptionEnabled"], out bool encryptionEnabled);

    var encryptedKey = encryptionEnabled
        ? EncryptProvider.AESEncrypt(originalAuthenticatorKey, _configuration["TwoFactorAuthentication:EncryptionKey"])
        : originalAuthenticatorKey;

    return encryptedKey;
}

The code first creates the default key and then if encryption is enabled, encrypts it. This is not the only thing required to secure the authenticator key. If you leave it as is you won’t be able to generate or verify tokens because the app at this point doesn’t know how to deal with the encrypted value. The next step is to provide an override for reading the encrypted authenticator key. We have already mentioned the ValidateAsync method that validates the tokens being created by the authenticator app.

public virtual async Task<bool> ValidateAsync(string purpose, string token, UserManager<TUser> manager, TUser user)
{
    var key = await manager.GetAuthenticatorKeyAsync(user);
    int code;
    if (!int.TryParse(token, out code))
    {
        return false;
    }

    var hash = new HMACSHA1(Base32.FromBase32(key));
    var unixTimestamp = Convert.ToInt64(Math.Round((DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds));
    var timestep = Convert.ToInt64(unixTimestamp / 30);
    // Allow codes from 90s in each direction (we could make this configurable?)
    for (int i = -2; i <= 2; i++)
    {
        var expectedCode = Rfc6238AuthenticationService.ComputeTotp(hash, (ulong)(timestep + i), modifier: null);
        if (expectedCode == code)
        {
            return true;
        }
    }
    return false;
}

All we have to do is override the UserManager.GetAuthenticatorKeyAsync method. Back to the AppUserManager

public override async Task<string> GetAuthenticatorKeyAsync(IdentityUser user)
{
    var databaseKey = await base.GetAuthenticatorKeyAsync(user);

    if (databaseKey == null)
    {
        return null;
    }

    // Decryption
    bool.TryParse(_configuration["TwoFactorAuthentication:EncryptionEnabled"], out bool encryptionEnabled);

    var originalAuthenticatorKey = encryptionEnabled
        ? EncryptProvider.AESDecrypt(databaseKey, _configuration["TwoFactorAuthentication:EncryptionKey"])
        : databaseKey;

    return originalAuthenticatorKey;
}

The code gets the authenticator key existing in the store and if encryption is enabled returns the decrypted one. That’s all you have to do to support encrypted authentication keys.

Encrypting recovery codes

Encrypting the recovery codes follows almost the same logic. Instead of having hard coded the codes in the store we wish to end up in an encrypted text as follow.

We override the way each recovery token is created, not all tokens together by providing an implementation for the CreateTwoFactorRecoveryCode in the AppUserManager.

protected override string CreateTwoFactorRecoveryCode()
{
    var originalRecoveryCode = base.CreateTwoFactorRecoveryCode();

    bool.TryParse(_configuration["TwoFactorAuthentication:EncryptionEnabled"], out bool encryptionEnabled);

    var encryptedRecoveryCode = encryptionEnabled
        ? EncryptProvider.AESEncrypt(originalRecoveryCode, _configuration["TwoFactorAuthentication:EncryptionKey"])
        : originalRecoveryCode;

    return encryptedRecoveryCode;
}

First we create the recovery token in the way it used to and then if encryption is enabled we encrypt it. The next step is to re-implement the GenerateNewTwoFactorRecoveryCodesAsync which returns the plain-text list of recovery tokens generated. We want this one because each recovery token in the database is now encrypted.

public override async Task<IEnumerable<string>> GenerateNewTwoFactorRecoveryCodesAsync(IdentityUser user, int number)
{
    var tokens = await base.GenerateNewTwoFactorRecoveryCodesAsync(user, number);

    var generatedTokens = tokens as string[] ?? tokens.ToArray();
    if (!generatedTokens.Any())
    {
        return generatedTokens;
    }

    bool.TryParse(_configuration["TwoFactorAuthentication:EncryptionEnabled"], out bool encryptionEnabled);

    return encryptionEnabled
        ? generatedTokens
            .Select(token =>
                EncryptProvider.AESDecrypt(token, _configuration["TwoFactorAuthentication:EncryptionKey"]))
        : generatedTokens;

}

The last step for completing the recovery token encryption is to override the RedeemTwoFactorRecoveryCodeAsync method in the AppUserManager. This method verifies a recovery token provided by the user. The logic here is slightly different than the authenticator key meaning that the recovery code provided by the user must be encrypted (not decrypted) in order to be matched with one existing in the store.

public override Task<IdentityResult> RedeemTwoFactorRecoveryCodeAsync(IdentityUser user, string code)
{
    bool.TryParse(_configuration["TwoFactorAuthentication:EncryptionEnabled"], out bool encryptionEnabled);

    if (encryptionEnabled && !string.IsNullOrEmpty(code))
    {
        code = EncryptProvider.AESEncrypt(code, _configuration["TwoFactorAuthentication:EncryptionKey"]);
    }

    return base.RedeemTwoFactorRecoveryCodeAsync(user, code);
}

Discussion

You must be aware that switching encryption from enabled to disabled or vice versa will break the two-factor authentication feature in your app. For example, if an authenticator key and the recovery tokens were created when the encryption was enabled and you decide to disable the encryption, then the user won’t be able to sign in. It means that this is a one-time decision to be made rather than switching it any time you want. My recommendation is always use the encryption and in case your app already supports 2FA with its default implementation, migrate to the encryption by running any required scripts in your database.

You probably noticed that when you checked the Show possible verification codes 5 valid codes are always displayed and every few seconds a new one is added. This sliding behavior is explained in a comment at the original code..

public virtual async Task<bool> ValidateAsync(string purpose, string token, UserManager<TUser> manager, TUser user)
{
    var key = await manager.GetAuthenticatorKeyAsync(user);
    int code;
    if (!int.TryParse(token, out code))
    {
        return false;
    }
 
    var hash = new HMACSHA1(Base32.FromBase32(key));
    var unixTimestamp = Convert.ToInt64(Math.Round((DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds));
    var timestep = Convert.ToInt64(unixTimestamp / 30);
    // Allow codes from 90s in each direction (we could make this configurable?)
    for (int i = -2; i <= 2; i++)
    {
        var expectedCode = Rfc6238AuthenticationService.ComputeTotp(hash, (ulong)(timestep + i), modifier: null);
        if (expectedCode == code)
        {
            return true;
        }
    }
    return false;
}

I really enjoyed the question “we could make this configurable?”.. Of course, the reason you get 5 valid codes is that there are 5 iterations from -2 to 2. If we could change this to -1 to 1 then we would get only 3 valid codes at a time which is kind of more strict. Anyway, the reason I am mentioning this is that sometimes while typing the code generated by the authenticator on your phone, you might believe it won’t work cause it seems to be expired on the app right before you have actually managed to enter it. You will be surprised though that most of the times it actually works due to this 90s in each direction.. If the code you see in your app is at the middle of the list presented above then you probably have more time than you think to use it. If though the code is the one to be replaced on the next iteration then make sure to enter it as long as you see it in the authenticator.

Another thing to keep in mind is the white area around the QR Code. This is not for our eyes, it’s a requirement for the authenticator apps to work and detect the QR Code properly. Always remember to leave some extra white pixels around the QR Code

That’s it, we have finished! We have implemented all the 2FA related tasks in our app and explored what happens at the store level. Last but not least we saw how to enhance 2FA security by encrypting the authenticator tokens and recovery codes being generating in the store.

In case you find my blog’s content interesting, register your email to receive notifications of new posts and follow chsakell’s Blog on its Facebook or Twitter accounts.

Facebook Twitter
.NET Web Application Development by Chris S.
facebook twitter-small
twitter-small

Categories: asp.net core

Tags: ,




Source link