fbpx

08. PART 2 IdentityServer4 MFA – FIDO2 (YubiKey 5) .NET Core 3.1

You can find the project here.

 

This is PART 2 of the IdentityServer4 MFA – FIDO2 (YubiKey 5). We will pick up where we left off in PART 1.

 

New pages and controllers

We will need two pages and two controllers. Those two pages will use two JS scripts we added earlier for enrollment and login with FIDO2. Let’s start with the registration part, the enrollment page. Navigate to “Area/Identity/Pages/Account/Manage” and add new razor page “Fido2Mfa.cshtml” like so:

@page "/Fido2Mfa/{handler?}"
@using Microsoft.AspNetCore.Identity
@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
@model IdentityServer.Areas.Identity.Pages.Account.Manage.Fido2MfaModel
@{
    Layout = "_Layout.cshtml";
    ViewData["Title"] = "Two-factor authentication (2FA)";
    ViewData["ActivePage"] = ManageNavPages.Fido2Mfa;
}

<h4>@ViewData["Title"]</h4>
<p>Enroll your Fido2 authenticator to setup 2FA. Please plug in your authenticator device now.</p>
<div class="notification is-danger" style="display:none">
    Please note: Your browser does not seem to support WebAuthn yet. <a href="https://caniuse.com/#search=webauthn" target="_blank">Supported browsers</a>
</div>
<div class="columns">
    <div class="column is-4">
        <form action="/Fido2Mfa" method="post" id="register">
            <input class="form-control" type="hidden" readonly placeholder="email" value="@User.Claims.Where(x=> x.Type == "sub").First().Value" name="username" required>
            <div class="field">
                <div class="control">
                    <button class="btn btn-primary">Add Fido2 MFA</button>
                </div>
            </div>
        </form>
    </div>
</div>

<script src="~/js/helpers.js"></script>
<script src="~/js/instant.js"></script>
<script src="~/js/mfa.register.js"></script>

Notice how the “mfa.register.js” is referenced along with the helper scripts. This is where we use them (and the login page below). Let’s add the controller that will handle the backend for the JS calls. Navigate to “Quickstart/Account” and create new empty MVC controller “RegisterFido2Controller.cs” and add two methods like so:

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Fido2NetLib.Objects;
using Fido2NetLib;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using static Fido2NetLib.Fido2;
using System.IO;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Caching.Distributed;
using System.Linq;
using IdentityServer.Services;
using IdentityServer.Models;

namespace IdentityServer
{

    [Route("api/[controller]")]
    public class RegisterFido2Controller : Controller
    {
        [TempData]
        public string UniqueId { get; set; }
        [TempData]
        public string[] RecoveryCodes { get; set; }

        private Fido2 _lib;
        public static IMetadataService _mds;
        private string _origin;
        private readonly Fido2Storage _fido2Storage;
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly IDistributedCache _distributedCache;

        public RegisterFido2Controller(IConfiguration config, Fido2Storage fido2Storage, UserManager<ApplicationUser> userManager, IDistributedCache distributedCache)
        {
            _userManager = userManager;
            _fido2Storage = fido2Storage;
            _distributedCache = distributedCache;
            var MDSAccessKey = config["fido2:MDSAccessKey"];
            var MDSCacheDirPath = config["fido2:MDSCacheDirPath"] ?? Path.Combine(Path.GetTempPath(), "fido2mdscache");
            _mds = string.IsNullOrEmpty(MDSAccessKey) ? null : MDSMetadata.Instance(MDSAccessKey, MDSCacheDirPath);
            if (null != _mds)
            {
                if (false == _mds.IsInitialized())
                    _mds.Initialize().Wait();
            }
            _origin = config["fido2:origin"];
            if (_origin == null)
            {
                _origin = "https://localhost:44388";
            }

            var domain = config["fido2:serverDomain"];
            if (domain == null)
            {
                domain = "localhost";
            }

            _lib = new Fido2(new Fido2Configuration()
            {
                ServerDomain = domain,
                ServerName = "Fido2IdentityMfa",
                Origin = _origin,
                // Only create and use Metadataservice if we have an acesskey
                MetadataService = _mds,
                TimestampDriftTolerance = config.GetValue<int>("fido2:TimestampDriftTolerance")
            });
        }

        private string FormatException(Exception e)
        {
            return string.Format("{0}{1}", e.Message, e.InnerException != null ? " (" + e.InnerException.Message + ")" : "");
        }

        [HttpPost]
        [Route("/makeCredentialOptions")]
        public async Task<JsonResult> MakeCredentialOptions([FromForm] string username, [FromForm] string displayName, [FromForm] string attType, [FromForm] string authType, [FromForm] bool requireResidentKey, [FromForm] string userVerification)
        {
            try
            {
                if (string.IsNullOrEmpty(username))
                {
                    username = $"{displayName} (Usernameless user created at {DateTime.UtcNow})";
                }

                var identityUser = await _userManager.FindByIdAsync(username);
                var user = new Fido2User
                {
                    DisplayName = identityUser.UserName,
                    Name = identityUser.UserName,
                    Id = Encoding.UTF8.GetBytes(identityUser.UserName) // byte representation of userID is required
                };

                // 2. Get user existing keys by username
                var items = await _fido2Storage.GetCredentialsByUsername(identityUser.UserName);
                var existingKeys = new List<PublicKeyCredentialDescriptor>();
                foreach (var publicKeyCredentialDescriptor in items)
                {
                    existingKeys.Add(publicKeyCredentialDescriptor.Descriptor);
                }

                // 3. Create options
                var authenticatorSelection = new AuthenticatorSelection
                {
                    RequireResidentKey = requireResidentKey,
                    UserVerification = userVerification.ToEnum<UserVerificationRequirement>()
                };

                if (!string.IsNullOrEmpty(authType))
                    authenticatorSelection.AuthenticatorAttachment = authType.ToEnum<AuthenticatorAttachment>();

                var exts = new AuthenticationExtensionsClientInputs() { Extensions = true, UserVerificationIndex = true, Location = true, UserVerificationMethod = true, BiometricAuthenticatorPerformanceBounds = new AuthenticatorBiometricPerfBounds { FAR = float.MaxValue, FRR = float.MaxValue } };

                var options = _lib.RequestNewCredential(user, existingKeys, authenticatorSelection, attType.ToEnum<AttestationConveyancePreference>(), exts);

                // 4. Temporarily store options, session/in-memory cache/redis/db
                //HttpContext.Session.SetString("fido2.attestationOptions", options.ToJson());
                var uniqueId = Guid.NewGuid().ToString();
                UniqueId = uniqueId;
                await _distributedCache.SetStringAsync(uniqueId, options.ToJson());

                // 5. return options to client
                return Json(options);
            }
            catch (Exception e)
            {
                return Json(new CredentialCreateOptions { Status = "error", ErrorMessage = FormatException(e) });
            }
        }

        [HttpPost]
        [Route("/makeCredential")]
        public async Task<JsonResult> MakeCredential([FromBody] AuthenticatorAttestationRawResponse attestationResponse)
        {
            try
            {
                // 1. get the options we sent the client
                //var jsonOptions = HttpContext.Session.GetString("fido2.attestationOptions");
                var jsonOptions = await _distributedCache.GetStringAsync(UniqueId);
                if (string.IsNullOrEmpty(jsonOptions))
                {
                    throw new Exception("Can't get CredentialOptions from cache");
                }
                var options = CredentialCreateOptions.FromJson(jsonOptions);

                // 2. Create callback so that lib can verify credential id is unique to this user
                IsCredentialIdUniqueToUserAsyncDelegate callback = async (IsCredentialIdUniqueToUserParams args) =>
                {
                    var users = await _fido2Storage.GetUsersByCredentialIdAsync(args.CredentialId);
                    if (users.Count > 0) return false;

                    return true;
                };

                // 2. Verify and make the credentials
                var success = await _lib.MakeNewCredentialAsync(attestationResponse, options, callback);

                // 3. Store the credentials in db
                await _fido2Storage.AddCredentialToUser(options.User, new FidoStoredCredential
                {
                    Username = options.User.Name,
                    Descriptor = new PublicKeyCredentialDescriptor(success.Result.CredentialId),
                    PublicKey = success.Result.PublicKey,
                    UserHandle = success.Result.User.Id,
                    SignatureCounter = success.Result.Counter,
                    CredType = success.Result.CredType,
                    RegDate = DateTime.Now,
                    AaGuid = success.Result.Aaguid
                });

                // 4. return "ok" to the client
                var user = await _userManager.GetUserAsync(User);
                if (user == null)
                {
                    return Json(new CredentialMakeResult { Status = "error", ErrorMessage = $"Unable to load user with ID '{_userManager.GetUserId(User)}'." });
                }

                await _userManager.SetTwoFactorEnabledAsync(user, true);
                if (await _userManager.CountRecoveryCodesAsync(user) == 0)
                {
                    var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
                    RecoveryCodes = recoveryCodes.ToArray();
                }

                return Json(success);
            }
            catch (Exception e)
            {
                return Json(new CredentialMakeResult { Status = "error", ErrorMessage = FormatException(e) });
            }
        }
    }
}

Let’s do the login page. It is done in a similar fashion. Navigate to “Area/Identity/Pages/Account” and add new razor page “LoginFido2Mfa.cshtml” like so:

@page
@using Microsoft.AspNetCore.Identity
@inject SignInManager<ApplicationUser> SignInManager
@inject UserManager<ApplicationUser> UserManager
@model IdentityServer.Areas.Identity.Pages.Account.LoginFido2MfaModel
@{
    ViewData["Title"] = "Login with Fido2 MFA";
}

<h2>@ViewData["Title"]</h2>
<hr />
<p>Your login is protected with a Fido2 authenticator. Please plug in your authenticator device now.</p>
<div class="notification is-danger" style="display:none">
    Please note: Your browser does not seem to support WebAuthn yet. <a href="https://caniuse.com/#search=webauthn" target="_blank">Supported browsers</a>
</div>
<form action="/LoginFido2Mfa" method="post" id="signin">
    <div class="field">
        <div class="control">
            <button class="btn btn-primary">2FA with Fido2 device</button>
        </div>
    </div>
</form>

<script src="~/js/helpers.js"></script>
<script src="~/js/instant.js"></script>
<script src="~/js/mfa.login.js"></script>

Navigate to “Quickstart/Account” and create new empty MVC controller “LoginFido2Controller.cs” and add two methods like so:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Fido2NetLib.Objects;
using Fido2NetLib;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using static Fido2NetLib.Fido2;
using System.IO;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Caching.Distributed;
using IdentityServer.Services;
using IdentityServer.Models;

namespace IdentityServer
{

    [Route("api/[controller]")]
    public class LoginFido2Controller : Controller
    {
        [TempData]
        public string UniqueId { get; set; }

        private Fido2 _lib;
        public static IMetadataService _mds;
        private string _origin;
        private readonly Fido2Storage _fido2Storage;
        private readonly UserManager<ApplicationUser> _userManager;
        private readonly SignInManager<ApplicationUser> _signInManager;
        private readonly IDistributedCache _distributedCache;

        public LoginFido2Controller(IConfiguration config,
            Fido2Storage fido2Storage,
            UserManager<ApplicationUser> userManager,
            SignInManager<ApplicationUser> signInManager,
            IDistributedCache distributedCache)
        {
            _signInManager = signInManager;
            _userManager = userManager;
            _fido2Storage = fido2Storage;
            _distributedCache = distributedCache;
            var MDSAccessKey = config["fido2:MDSAccessKey"];
            var MDSCacheDirPath = config["fido2:MDSCacheDirPath"] ?? Path.Combine(Path.GetTempPath(), "fido2mdscache");
            _mds = string.IsNullOrEmpty(MDSAccessKey) ? null : MDSMetadata.Instance(MDSAccessKey, MDSCacheDirPath);
            if (null != _mds)
            {
                if (false == _mds.IsInitialized())
                    _mds.Initialize().Wait();
            }
            _origin = config["fido2:origin"];
            _lib = new Fido2(new Fido2Configuration()
            {
                ServerDomain = config["fido2:serverDomain"],
                ServerName = "Fido2 test",
                Origin = _origin,
                // Only create and use Metadataservice if we have an acesskey
                MetadataService = _mds,
                TimestampDriftTolerance = config.GetValue<int>("fido2:TimestampDriftTolerance")
            });
        }

        private string FormatException(Exception e)
        {
            return string.Format("{0}{1}", e.Message, e.InnerException != null ? " (" + e.InnerException.Message + ")" : "");
        }

        [HttpPost]
        [Route("/assertionOptions")]
        public async Task<ActionResult> AssertionOptionsPost([FromForm] string username, [FromForm] string userVerification)
        {
            try
            {
                var identityUser = await _signInManager.GetTwoFactorAuthenticationUserAsync();
                if (identityUser == null)
                {
                    throw new InvalidOperationException($"Unable to load two-factor authentication user.");
                }

                var existingCredentials = new List<PublicKeyCredentialDescriptor>();

                if (!string.IsNullOrEmpty(identityUser.UserName))
                {

                    var user = new Fido2User
                    {
                        DisplayName = identityUser.UserName,
                        Name = identityUser.UserName,
                        Id = Encoding.UTF8.GetBytes(identityUser.UserName) // byte representation of userID is required
                    };

                    if (user == null) throw new ArgumentException("Username was not registered");

                    // 2. Get registered credentials from database
                    var items = await _fido2Storage.GetCredentialsByUsername(identityUser.UserName);
                    existingCredentials = items.Select(c => c.Descriptor).ToList();
                }

                var exts = new AuthenticationExtensionsClientInputs() { SimpleTransactionAuthorization = "FIDO", GenericTransactionAuthorization = new TxAuthGenericArg { ContentType = "text/plain", Content = new byte[] { 0x46, 0x49, 0x44, 0x4F } }, UserVerificationIndex = true, Location = true, UserVerificationMethod = true };

                // 3. Create options
                var uv = string.IsNullOrEmpty(userVerification) ? UserVerificationRequirement.Discouraged : userVerification.ToEnum<UserVerificationRequirement>();
                var options = _lib.GetAssertionOptions(
                    existingCredentials,
                    uv,
                    exts
                );

                // 4. Temporarily store options, session/in-memory cache/redis/db
                // HttpContext.Session.SetString("fido2.assertionOptions", options.ToJson());
                var uniqueId = Guid.NewGuid().ToString();
                UniqueId = uniqueId;
                await _distributedCache.SetStringAsync(uniqueId, options.ToJson());

                // 5. Return options to client
                return Json(options);
            }

            catch (Exception e)
            {
                return Json(new AssertionOptions { Status = "error", ErrorMessage = FormatException(e) });
            }
        }

        [HttpPost]
        [Route("/makeAssertion")]
        public async Task<JsonResult> MakeAssertion([FromBody] AuthenticatorAssertionRawResponse clientResponse)
        {
            try
            {
                // 1. Get the assertion options we sent the client
                //var jsonOptions = HttpContext.Session.GetString("fido2.assertionOptions");
                var jsonOptions = await _distributedCache.GetStringAsync(UniqueId);
                if (string.IsNullOrEmpty(jsonOptions))
                {
                    throw new Exception("Can't get AssertionOptions from cache");
                }
                var options = AssertionOptions.FromJson(jsonOptions);

                // 2. Get registered credential from database
                var creds = await _fido2Storage.GetCredentialById(clientResponse.Id);

                if (creds == null)
                {
                    throw new Exception("Unknown credentials");
                }

                // 3. Get credential counter from database
                var storedCounter = creds.SignatureCounter;

                // 4. Create callback to check if userhandle owns the credentialId
                IsUserHandleOwnerOfCredentialIdAsync callback = async (args) =>
                {
                    var storedCreds = await _fido2Storage.GetCredentialsByUserHandleAsync(args.UserHandle);
                    return storedCreds.Exists(c => c.Descriptor.Id.SequenceEqual(args.CredentialId));
                };

                // 5. Make the assertion
                var res = await _lib.MakeAssertionAsync(clientResponse, options, creds.PublicKey, storedCounter, callback);

                // 6. Store the updated counter
                await _fido2Storage.UpdateCounter(res.CredentialId, res.Counter);

                // complete sign-in
                var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
                if (user == null)
                {
                    throw new InvalidOperationException($"Unable to load two-factor authentication user.");
                }

                var result = await _signInManager.TwoFactorSignInAsync("FIDO2", string.Empty, false, false);

                // 7. return OK to client
                return Json(res);
            }
            catch (Exception e)
            {
                return Json(new AssertionVerificationResult { Status = "error", ErrorMessage = FormatException(e) });
            }
        }
    }
}

Done. Let’s move on to code changes and config to wrap it up.

 

Code changes and config

If you try to build the solution now you will notice one error related to “ManageNavPages.cs” missing some properties. Let’s fix that first so we again have a solution we can build. Navigate to “Area/Identity/Pages/Account/Manage” and open “ManageNavPages.cs” class. We need to add one property and one method like so:

public static string Fido2Mfa => "Fido2Mfa";
public static string Fido2MfaNavClass(ViewContext viewContext) => PageNavClass(viewContext, Fido2Mfa);

What we are doing at the moment is pretty much integrating FIDO2 MFA into ASP.NET Core Identity alongside the TOTP we already implemented in my previous tutorial.

Navigate to “Quickstart/Account” and open “AccountController.cs” our old friend. Find the “Login” method with “HttpPost” attribute on it and modify the second factor redirect like so:

else if (result.RequiresTwoFactor)
{
	string twoFactorUrl;
	var fido2ItemExistsForUser = await _fido2Storage.GetCredentialsByUsername(model.Username);
	if (fido2ItemExistsForUser.Count > 0)
	{
		twoFactorUrl = "~/Identity/Account/LoginFido2Mfa?ReturnUrl={0}";
	}
	else
	{
		twoFactorUrl = "~/Identity/Account/LoginWith2fa?ReturnUrl={0}";
	}

	if (context != null || Url.IsLocalUrl(model.ReturnUrl))
	{
		return Redirect(string.Format(twoFactorUrl, HttpUtility.UrlEncode(model.ReturnUrl)));
	}
	else
	{
		return Redirect(string.Format(twoFactorUrl, HttpUtility.UrlEncode("~/")));
	}
}

We also need to add missing “Fido2Storage” service injection and property like so:

/// <summary>
/// </summary>
[SecurityHeaders]
[AllowAnonymous]
public class AccountController : Controller
{
	private readonly UserManager<ApplicationUser> _userManager;
	private readonly SignInManager<ApplicationUser> _signInManager;
	private readonly IIdentityServerInteractionService _interaction;
	private readonly IClientStore _clientStore;
	private readonly IAuthenticationSchemeProvider _schemeProvider;
	private readonly IEventService _events;
	private readonly Fido2Storage _fido2Storage;

	public AccountController(
		UserManager<ApplicationUser> userManager,
		SignInManager<ApplicationUser> signInManager,
		IIdentityServerInteractionService interaction,
		IClientStore clientStore,
		IAuthenticationSchemeProvider schemeProvider,
		IEventService events,
		Fido2Storage fido2Storage)
	{
		_userManager = userManager;
		_signInManager = signInManager;
		_interaction = interaction;
		_clientStore = clientStore;
		_schemeProvider = schemeProvider;
		_events = events;
		_fido2Storage = fido2Storage;
	}

And add the missing “using” directive like so:

using IdentityServer.Services;

We need to update the “Disable2fa” page in order to remove FIDO2 credentials correctly when user disables MFA. Navigate to “Area/Identity/Pages/Account/Manage” and open “Disable2fa.cshtml.cs”. Modify “OnPostAsync” method to remove FIDO2 like so:

public async Task<IActionResult> OnPostAsync()
{
	var user = await _userManager.GetUserAsync(User);
	if (user == null)
	{
		return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
	}

	// remove Fido2 MFA if it exists
	await _fido2Storage.RemoveCredentialsByUsername(user.UserName);

	var disable2faResult = await _userManager.SetTwoFactorEnabledAsync(user, false);
	if (!disable2faResult.Succeeded)
	{
		throw new InvalidOperationException($"Unexpected error occurred disabling 2FA for user with ID '{_userManager.GetUserId(User)}'.");
	}

	_logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", _userManager.GetUserId(User));
	StatusMessage = "2fa has been disabled. You can reenable 2fa when you setup an authenticator app";
	return RedirectToPage("./TwoFactorAuthentication");
}

Add the “Fido2Storage” service injection like so:

private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<Disable2faModel> _logger;
private readonly Fido2Storage _fido2Storage;

public Disable2faModel(
	UserManager<ApplicationUser> userManager,
	ILogger<Disable2faModel> logger,
	Fido2Storage fido2Storage)
{
	_userManager = userManager;
	_logger = logger;
	_fido2Storage = fido2Storage;
}

And the missing “using” directive:

using IdentityServer.Services;

The last thing we need to change before we start with configuration is to add a button so the user can select TOTP or FIDO2 MFA when doing the second-factor enrollment. Open “TwoFactorAuthentication.cshtml” and add anchor element after all existing content on the page but before scripts section like so:

<a id="enable-fido2mfa" asp-page="./Fido2Mfa" class="btn btn-primary">Add Fido2 MFA</a>

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

Almost done. All that is left to do is some basic configuration. First open “appsettings.json” and add “fido2” section like so:

"fido2": {
	"serverDomain": "localhost",
	"origin": "http://localhost:5000"
}

Now open “Startup.cs”. The obvious thing we need to do is service registration so let’s do that first. Add “Fido2Storage” service and IDistributedCache registrations at the end of the “ConfigureServices” method like so:

services.AddScoped<Fido2Storage>();
services.AddDistributedMemoryCache();

Note: I used IDistributedCache with the default in-memory store implementation. This is fine for playing with a single instance but if you have multiple instances of your application running you need to change the IDistributedCache store to Redis or SQL implementation in order to share the cache among instances. It is really easy to configure in “Startup.cs”. Just Google it.

Not so obvious thing that we need to do in “ConfigureServices” method in “Startup.cs” is to register FIDO2 token provider with ASP.NET Core Identity. We are pretty much handling all the auth flows with added FIDO2 pages and JS scripts so this is just a dummy token provider. Modify Identity configuration like so:

services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
	options.SignIn.RequireConfirmedEmail = true;
})
.AddEntityFrameworkStores<IdentityDbContext>()
.AddDefaultTokenProviders()
.AddTokenProvider<Fido2TokenProvider>("FIDO2");

Create a new folder in the project root and name it “Providers”. Add new class “Fido2TokenProvider.cs” like so:

using IdentityServer.Models;
using Microsoft.AspNetCore.Identity;
using System.Threading.Tasks;

namespace IdentityServer.Providers
{
    public class Fido2TokenProvider : IUserTwoFactorTokenProvider<ApplicationUser>
    {
        public Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<ApplicationUser> manager, ApplicationUser user)
        {
            return Task.FromResult(true);
        }

        public Task<string> GenerateAsync(string purpose, UserManager<ApplicationUser> manager, ApplicationUser user)
        {
            return Task.FromResult("fido2");
        }

        public Task<bool> ValidateAsync(string purpose, string token, UserManager<ApplicationUser> manager, ApplicationUser user)
        {
            return Task.FromResult(true);
        }
    }
}

Go back to “Startup.cs” and add a missing ”using” directive like so:

using IdentityServer.Providers;

To make FIDO2 works properly we need to install NuGet package “Microsoft.AspNetCore.Mvc.NewtonsoftJson”

and include it in “Startup.cs” in ConfigureServices method like this:

services.AddControllersWithViews().AddNewtonsoftJson();

Note:  We need “microsoft.AspNetCore.Mvc.NewtonsoftJosn” package because of how Enum is serialized into jSon.

 

Done. Congratulations. You are now in possession of the latest and greatest unphishable authentication solution. Let’s play with it!

 

Test it

Start the application. Let’s click on the “Manage account”.

In order to access user account management, you must authenticate to provide your identity to the IdentityServer4. Let’s authenticate with the local account user “alice”, password “My long 123$ password”.

After a successful login, you will be able to access the user account management page

Click on two-factor authentication. You will notice that now we have two options. We can use the authenticator app (like a Google Authenticator for TOTP) or use Fido2 Authenticator device (like a YubiKey 5)

Click on “Add Fido2 MFA” and you will get to a new page we created for the Fido2 enrollment

If you haven’t already, plug in your hardware authenticator and click the “Add Fido2 MFA” button. You will see a pop-up that we created with an SVG image we added and also on Windows you will see a “Windows Security” pop-up telling you pretty much the same thing to tap your device. Windows might ask you for a PIN when you are enrolling the key (it will not ask for a PIN later on when doing login)

Once the hardware authenticator is successfully enrolled you will get redirected to the “Recovery codes” page. You should save these recovery codes in case you lose your authenticator device.

Click on “Alice Smith” in the navigation menu at the top and logout.

Click “Yes”

Ok, we are now logged out. Let’s click on the “Manage account” again. You will be redirected to a login page. Login with user “alice”. Now you will actually see the second razor page we added for Fido2 login. The user is still not authenticated as the second factor needs to be provided.

Click on the “2FA with Fido2 device” button. You will see a pop-up asking you to tap the authenticator.

Once you touch it to confirm your presence, the authentication will complete and the user will be logged in and redirected to a page or client application where it came from, same as when using a single factor without Fido2. We kept pretty much the same flow for the user and only added a need for a user to tap the key. It is really simple for the user but pretty much impossible for hackers to get around at this point in time.

 

Play it hard

Try to disable 2FA on the manage account page, try adding more than one Fido2 authenticator device, try with different browsers, set breakpoints in FIDO2 controllers we added to see what is going on. Customize the solution to suit your case better. Even implement passwordless, why not? The sky is the limit. FIDO2 is awesome stuff!

 

Thank you

I was able to create this tutorial because I was enabled and inspired by the published code and tutorials from Anders Åberg (abergs) and Damien Bowden (damienbod). Thank you guys for being awesome and your contributions to the community.

https://github.com/abergs/fido2-net-lib

https://damienbod.com/2019/08/06/asp-net-core-identity-with-fido2-webauthn-mfa

You can find the project here.

Support

For direct assistance schedule a technical meeting with Ivan to talk about your requirements.
For a general overview of our services and a live demo schedule a meeting with Maja.

Comments
  • Hay says:

    services.AddControllersWithViews();
    JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
    services.AddAuthentication(option =>
    {
    option.DefaultScheme = “Cookies”;
    option.DefaultChallengeScheme = “oidc”;
    })
    .AddCookie(“Cookies”)
    .AddOpenIdConnect(“oidc”, option =>
    {
    option.SignInScheme = “Cookies”;
    option.Authority = “http://localhost:5000”;
    option.RequireHttpsMetadata = false;
    option.ClientId = “mvc”;
    option.ClientSecret = “secret”;
    option.Scope.Add(“openid”);
    });

    Error
    Sorry, there was an error : unauthorized_client
    Unknown client or client not enabled
    Request Id: 8000003c-0000-fd00-b63f-84710c7967bb

    why?

    • deblokt says:

      Hi,
      Thank you for noticing. The error was in the data seed for “mvc” client. The client secret wasn’t set properly (it was set to incorrect client). Please get the latest code from GitHub, drop the local database and run the migration to re-seed properly. Thanks.

  • Karen Bench says:

    I am able to add a YubiKey5 for “alice”. It accepts the touch and creates it in the fido table.
    If I then logout, and login as alice, It prompts for the following,
    “Login with Fido2 MFA
    Your login is protected with a Fido2 authenticator. Please plug in your authenticator device now.

    button: 2FA with Fido2 device
    but doesn’t accept pressing on the Yubikey or give any error indication, it just doesn’t log in.
    It does not get the popup box asking to touch the Yubikey like I got when I stored it initially.
    Any idea where to look? I have re-compared my application with yours and I don’t see why.

  • David says:

    Thank you for all these. This helped a lot!

  • PapaT says:

    Hi thanks for great series of articles! When can we expect the next ones you are really great at explaining the tools!

  • Michael says:

    Hi!
    Loving your article, great work! As far as i understand the user is currently limited to having only one fido2 token registered. Am i right, that i just have to add an Id to the FidoStoredCredential or is there something special im overlooking. Everything else seems to work perfectly with multiple stored credentials – except the RemoveCredentialsByUsername, but that’s not to much of a change 🙂

    • deblokt says:

      Hi. Thanks. I already responded to another comment that you asked on the same topic so please take a look.

  • Michael says:

    Hi!
    Loving your articles, great work! As far as i understand, the user is limited to only one registered fido2 device. I’m i right, that i just need to add a ID to the FidoStoredCredential and change the remove function in the fidostorage? Everything else seems to work perfectly with these changes, but i’m not 100% sure if there is anything i’m maybe overlooking.

    • deblokt says:

      Maybe the current flow is limiting the user to a single FIDO2 auth device but with minimal changes it should be able to support any number of authenticators.

  • Felipe Pragana says:

    Hi, nice tutorial, congratulations.
    when i access “http://localhost:5000/Account/Login?ReturnUrl=%2Fgrants” and try make a logon, the system redirect to http://localhost:5000/grants with 302, but not create logon cookie, and redirect again to logon screen “http://localhost:5000/Account/Login?ReturnUrl=%2Fgrants”

    • deblokt says:

      Try using HTTPS in localhost as recent browser changes might reject the cookie because it’s not using HTTPS.

  • Philip Clement says:

    Hello!
    Just wanted to thank you all for this great tutorial. As someone who had little experience with both C# and SQL but an interest in FIDO2, this tutorial was everything I needed. Thanks again!

Comments are closed.