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.

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

You can find the project here.

Why FIDO2

Two words. Unphishable authentication. Let’s explain a bit more. Authentication can be intercepted in a reverse proxy scenario. To get a user in a reverse proxy scenario an attacker can send an email looking like a legitimate email from a service the user is frequently using. Let’s say it’s YouTube and the user is a famous content creator. A spear-phishing campaign can be launched against YouTube creators and the user will receive an email from YouTube with a link in it as a call to action. The email is not actually from YouTube and the link will not take you to “YouTube.com” but to a similar-looking domain like “Y0uTube.com”.

An even better strategy that attackers use is to register domains with letters from another alphabet that looks pretty much the same to a human eye but it is a different character to a machine so it is a unique domain name. The user clicks on a link in the email which leads to a waiting reverse proxy that serves the YouTube page and it looks identical to the original because it is the original. The reverse proxy connects to “YouTube.com” and it serves all content back to the user. So now when using a single factor like username and password when the authentication ceremony is completed the attacker now has the user’s credentials, issued tokens or code to get the tokens and any issued web app auth cookies. The attacker has everything needed to impersonate the user and log in to the service as the legitimate user. Image the havoc the attacker could create to this user’s account. The worst thing about this kind of attack is that it works with MFA too.

If the user was using TOTP as a second factor, as explained in my previous tutorial the attack would still go through and the attacker would have user’s credentials, one-time-password, issued tokens or code to get the tokens and any issued web app auth cookies. There is little you can do against a reverse proxy. Legitimate reverse proxy scenarios like firewalls, load balancers, web servers, etc exist and are used every day. The problem is the malicious reverse proxies. Phishing brought the user to a reverse proxy and the user gave the identity away. The solution is FIDO2 as a second factor or even as a single factor aka passwordless.

The FIDO2 has two parts, the web authentication API called WebAuthn and CTAP client-to-authenticator protocol. CTAP talks over USB or NFC to your hardware authenticator (like a YubiKey 5). WebAuthn is used by JavaScript in a browser to communicate with the IdentityServer4 to authenticate. It works by a server issuing a challenge. Client, in this case, the browser, gets it, adds client data to it and sends it to a hardware authenticator to sign in. It all boils down to plain old asymmetric encryption with a pair of public and private keys. The private key never leaves the hardware authenticator and is used for signing the client data.

Signed client data is sent back to the server where the challenge is checked against the issued challenge and client data is checked for origin. If the origin in the client data is different from the server origin the authentication will fail. This makes phishing impossible as a malicious reverse proxy will have a different origin (domain) than a legitimate server. So, in this case, the only thing the attacker gets is maybe first factor credentials but because the authentication failed the tokens and auth cookie are not issued. The attacker is unable to impersonate the user. This is really hard for the attacker but really easy for the user. All the user has to do is plug in a key in the USB port and tap it. Users can leave the hardware authenticator plugged in all the time and a malicious program could automatically sign anything with it if the user action wasn’t required on a hardware authenticator itself. More expensive authenticators used in banks, for example, have a PIN, fingerprint, etc instead of just touch confirmation.

This allows for having MFA on a hardware key itself. RSA as one factor and PIN as another. FIDO2 enables passwordless authentication. This means we can use a hardware authenticator as the only factor without username and password. This is possible because having an RSA hardware authenticator as a factor is much more secure than using credentials or even TOTP. Unphishable authentication is a holy grail of the Identity industry.

Careful readers might have noticed that RSA public/private key encryption is used for FIDO2, the same as for SSL/TLS. Phishing/Reverse Proxy attack works even with SSL enabled because a browser will accept any SSL certificate as long as it’s issued by a trusted CA. That means an attacker can easily buy the SSL certificate and put it on a reverse proxy server. SSL offloading is performed on a reverse proxy and the attacker has a clear-text view of both the traffic going in and out of it. With FIDO2 in the reverse proxy scenario, the attacker can use the hardware authenticator public key to see the signed assertion but can’t modify it as it doesn’t have the private key to sign in again. If the reverse proxy signs the assertion using another private key the public key of the hardware authenticator, which is saved on the server during the MFA enrollment process, wouldn’t be able to decrypt it. There is no known way for the attacker using phishing and a malicious reverse proxy to defeat FIDO2.

Enough talking let’s start adding FIDO2 to our “Quickstart” IdentityServer4 project from a previous tutorial. We are going to add FIDO2 to ASP.NET Core Identity as it takes care of the users and authenticating the users against the user store for the IdentityServer4.

To be able to enroll and login with FIDO2 you will need a FIDO2 hardware authenticator device. I will be using YubiKey 5, it should work the same with any FIDO2 authenticator.

I will continue with the “Quickstart” solution from my previous 2FA TOTP tutorial. Open the project in Visual Studio and let’s start.

 

Libraries and resources

First, add the “Fido2” NuGet package like so:

We will add four JS scripts in total. Two scripts “mfa.login.js” and “mfa.register.js” take care of invoking WebAuthn API for logging in with FIDO2 and enrolling a FIDO2 authenticator. The other two scripts “helpers.js” and “instant.js” take care of some helper functions for data manipulation and on-screen notifications when working with FIDO2 authenticator. Let’s navigate to “wwwroot/js” and start adding scripts:

Create “mfa.login.js” like so:

document.getElementById('signin').addEventListener('submit', handleSignInSubmit);

async function handleSignInSubmit(event) {
    event.preventDefault();

    // prepare form post data
    var formData = new FormData();

    // send to server for registering
    let makeAssertionOptions;
    try {
        var res = await fetch('/assertionOptions', {
            method: 'POST', // or 'PUT'
            body: formData, // data can be `string` or {object}!
            headers: {
                'Accept': 'application/json'
            }
        });

        makeAssertionOptions = await res.json();
    } catch (e) {
        showErrorAlert("Request to server failed", e);
    }

    // console.log("Assertion Options Object", makeAssertionOptions);

    // show options error to user
    if (makeAssertionOptions.status !== "ok") {
        console.log("Error creating assertion options");
        console.log(makeAssertionOptions.errorMessage);
        showErrorAlert(makeAssertionOptions.errorMessage);
        return;
    }

    const challenge = makeAssertionOptions.challenge.replace(/-/g, "+").replace(/_/g, "/");
    makeAssertionOptions.challenge = Uint8Array.from(atob(challenge), c => c.charCodeAt(0));

    makeAssertionOptions.allowCredentials.forEach(function (listItem) {
        var fixedId = listItem.id.replace(/_/g, "/").replace(/-/g, "+");
        listItem.id = Uint8Array.from(atob(fixedId), c => c.charCodeAt(0));
    });

    // console.log("Assertion options", makeAssertionOptions);

    Swal.fire({
        title: 'Logging In...',
        text: 'Tap your security key to login.',
        imageUrl: "/images/securitykey.min.svg",
        showCancelButton: true,
        showConfirmButton: false,
        focusConfirm: false,
        focusCancel: false
    });

    // ask browser for credentials (browser will ask connected authenticators)
    let credential;
    try {
        credential = await navigator.credentials.get({ publicKey: makeAssertionOptions })
    } catch (err) {
        showErrorAlert(err.message ? err.message : err);
    }

    try {
        await verifyAssertionWithServer(credential);
    } catch (e) {
        showErrorAlert("Could not verify assertion", e);
    }
}

/**
 * Sends the credential to the the FIDO2 server for assertion
 * @param {any} assertedCredential
 */
async function verifyAssertionWithServer(assertedCredential) {

    // Move data into Arrays incase it is super long
    let authData = new Uint8Array(assertedCredential.response.authenticatorData);
    let clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON);
    let rawId = new Uint8Array(assertedCredential.rawId);
    let sig = new Uint8Array(assertedCredential.response.signature);
    const data = {
        id: assertedCredential.id,
        rawId: coerceToBase64Url(rawId),
        type: assertedCredential.type,
        extensions: assertedCredential.getClientExtensionResults(),
        response: {
            authenticatorData: coerceToBase64Url(authData),
            clientDataJson: coerceToBase64Url(clientDataJSON),
            signature: coerceToBase64Url(sig)
        }
    };

    let response;
    try {
        let res = await fetch("/makeAssertion", {
            method: 'POST', // or 'PUT'
            body: JSON.stringify(data), // data can be `string` or {object}!
            headers: {
                'Accept': 'application/json',
                'Content-Type': 'application/json'
            }
        });

        response = await res.json();
    } catch (e) {
        showErrorAlert("Request to server failed", e);
        throw e;
    }

    // console.log("Assertion Object", response);

    // show error
    if (response.status !== "ok") {
        console.log("Error doing assertion");
        console.log(response.errorMessage);
        showErrorAlert(response.errorMessage);
        return;
    }

    // show success message
    await Swal.fire({
        title: 'Logged In!',
        text: 'You're logged in successfully.',
        type: 'success',
        timer: 2000
    });

    // get redirect uri
    var urlParams = new URLSearchParams(window.location.search);
    if (urlParams.has('ReturnUrl')) {
        let redirectUri = urlParams.get('ReturnUrl');
        redirectUri = redirectUri.replace('~/', '/');
        window.location.href = redirectUri;
    } else {
        window.location.href = "/";
    }
}

Create “mfa.register.js” like so:

document.getElementById('register').addEventListener('submit', handleRegisterSubmit);

async function handleRegisterSubmit(event) {
    event.preventDefault();

    let username = this.username.value;

    // possible values: none, direct, indirect
    let attestation_type = "none";
    // possible values: <empty>, platform, cross-platform
    let authenticator_attachment = "";

    // possible values: preferred, required, discouraged
    let user_verification = "preferred";

    // possible values: true,false
    let require_resident_key = false;

    // prepare form post data
    var data = new FormData();
    data.append('username', username);
    data.append('attType', attestation_type);
    data.append('authType', authenticator_attachment);
    data.append('userVerification', user_verification);
    data.append('requireResidentKey', require_resident_key);

    // send to server for registering
    let makeCredentialOptions;
    try {
        makeCredentialOptions = await fetchMakeCredentialOptions(data);
    } catch (e) {
        console.error(e);
        let msg = "Something wen't really wrong";
        showErrorAlert(msg);
    }

    // console.log("Credential Options Object", makeCredentialOptions);

    if (makeCredentialOptions.status !== "ok") {
        console.log("Error creating credential options");
        console.log(makeCredentialOptions.errorMessage);
        showErrorAlert(makeCredentialOptions.errorMessage);
        return;
    }

    // Turn the challenge back into the accepted format of padded base64
    makeCredentialOptions.challenge = coerceToArrayBuffer(makeCredentialOptions.challenge);
    // Turn ID into a UInt8Array Buffer for some reason
    makeCredentialOptions.user.id = coerceToArrayBuffer(makeCredentialOptions.user.id);

    makeCredentialOptions.excludeCredentials = makeCredentialOptions.excludeCredentials.map((c) => {
        c.id = coerceToArrayBuffer(c.id);
        return c;
    });

    if (makeCredentialOptions.authenticatorSelection.authenticatorAttachment === null) makeCredentialOptions.authenticatorSelection.authenticatorAttachment = undefined;

    // console.log("Credential Options Formatted", makeCredentialOptions);

    Swal.fire({
        title: 'Registering...',
        text: 'Tap your security key to finish registration.',
        imageUrl: "/images/securitykey.min.svg",
        showCancelButton: true,
        showConfirmButton: false,
        focusConfirm: false,
        focusCancel: false
    });


    // console.log("Creating PublicKeyCredential...");
    // console.log(navigator);
    // console.log(navigator.credentials);
    // console.log(makeCredentialOptions);
    let newCredential;
    try {
        newCredential = await navigator.credentials.create({
            publicKey: makeCredentialOptions
        });
    } catch (e) {
        var msg = "Could not create credentials in browser. Probably because the username is already registered with your authenticator. Please change username or authenticator.";
        console.error(msg, e);
        showErrorAlert(msg, e);
    }

    // console.log("PublicKeyCredential Created", newCredential);

    try {
        registerNewCredential(newCredential);
    } catch (e) {
        showErrorAlert(err.message ? err.message : err);
    }
}

async function fetchMakeCredentialOptions(formData) {
    let response = await fetch('/makeCredentialOptions', {
        method: 'POST', // or 'PUT'
        body: formData, // data can be `string` or {object}!
        headers: {
            'Accept': 'application/json'
        }
    });

    let data = await response.json();

    return data;
}


// This should be used to verify the auth data with the server
async function registerNewCredential(newCredential) {
    // Move data into Arrays incase it is super long
    let attestationObject = new Uint8Array(newCredential.response.attestationObject);
    let clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON);
    let rawId = new Uint8Array(newCredential.rawId);

    const data = {
        id: newCredential.id,
        rawId: coerceToBase64Url(rawId),
        type: newCredential.type,
        extensions: newCredential.getClientExtensionResults(),
        response: {
            AttestationObject: coerceToBase64Url(attestationObject),
            clientDataJson: coerceToBase64Url(clientDataJSON)
        }
    };

    let response;
    try {
        response = await registerCredentialWithServer(data);
    } catch (e) {
        showErrorAlert(e);
    }

    // console.log("Credential Object", response);

    // show error
    if (response.status !== "ok") {
        console.log("Error creating credential");
        console.log(response.errorMessage);
        showErrorAlert(response.errorMessage);
        return;
    }

    // show success 
    Swal.fire({
        title: 'Registration Successful!',
        text: 'You've registered successfully.',
        type: 'success',
        timer: 2000
    });

    window.location.href = "/Identity/Account/Manage/ShowRecoveryCodes";
}

async function registerCredentialWithServer(formData) {
    let response = await fetch('/makeCredential', {
        method: 'POST', // or 'PUT'
        body: JSON.stringify(formData), // data can be `string` or {object}!
        headers: {
            'Accept': 'application/json',
            'Content-Type': 'application/json'
        }
    });

    let data = await response.json();

    return data;
}

Create “helpers.js” like so:

coerceToArrayBuffer = function (thing, name) {
    if (typeof thing === "string") {
        // base64url to base64
        thing = thing.replace(/-/g, "+").replace(/_/g, "/");

        // base64 to Uint8Array
        var str = window.atob(thing);
        var bytes = new Uint8Array(str.length);
        for (var i = 0; i < str.length; i++) {
            bytes[i] = str.charCodeAt(i);
        }
        thing = bytes;
    }

    // Array to Uint8Array
    if (Array.isArray(thing)) {
        thing = new Uint8Array(thing);
    }

    // Uint8Array to ArrayBuffer
    if (thing instanceof Uint8Array) {
        thing = thing.buffer;
    }

    // error if none of the above worked
    if (!(thing instanceof ArrayBuffer)) {
        throw new TypeError("could not coerce '" + name + "' to ArrayBuffer");
    }

    return thing;
};


coerceToBase64Url = function (thing) {
    // Array or ArrayBuffer to Uint8Array
    if (Array.isArray(thing)) {
        thing = Uint8Array.from(thing);
    }

    if (thing instanceof ArrayBuffer) {
        thing = new Uint8Array(thing);
    }

    // Uint8Array to base64
    if (thing instanceof Uint8Array) {
        var str = "";
        var len = thing.byteLength;

        for (var i = 0; i < len; i++) {
            str += String.fromCharCode(thing[i]);
        }
        thing = window.btoa(str);
    }

    if (typeof thing !== "string") {
        throw new Error("could not coerce to string");
    }

    // base64 to base64url
    // NOTE: "=" at the end of challenge is optional, strip it off here
    thing = thing.replace(/+/g, "-").replace(///g, "_").replace(/=*$/g, "");

    return thing;
};



// HELPERS

function showErrorAlert(message, error) {
    let footermsg = '';
    if (error) {
        footermsg = 'exception:' + error.toString();
    }
    Swal.fire({
        type: 'error',
        title: 'Error',
        text: message,
        footer: footermsg
        //footer: '<a href>Why do I have this issue?</a>'
    })
}

function detectFIDOSupport() {
    if (window.PublicKeyCredential === undefined ||
        typeof window.PublicKeyCredential !== "function") {
        //$('#register-button').attr("disabled", true);
        //$('#login-button').attr("disabled", true);
        var el = document.getElementById("notSupportedWarning");
        if (el) {
            el.style.display = 'block';
        }
        return;
    }
}

/**
 * 
 * Get a form value
 * @param {any} selector
 */
function value(selector) {
    var el = document.querySelector(selector);
    if (el.type === "checkbox") {
        return el.checked;
    }
    return el.value;
}

Create “instant.js” like so:

/*! instant.page v1.1.0 - (C) 2019 Alexandre Dieulot - https://instant.page/license */

let urlToPreload
let mouseoverTimer
let lastTouchTimestamp

const prefetcher = document.createElement('link')
const isSupported = prefetcher.relList && prefetcher.relList.supports && prefetcher.relList.supports('prefetch')
const allowQueryString = 'instantAllowQueryString' in document.body.dataset

if (isSupported) {
    prefetcher.rel = 'prefetch'
    document.head.appendChild(prefetcher)

    const eventListenersOptions = {
        capture: true,
        passive: true,
    }
    document.addEventListener('touchstart', touchstartListener, eventListenersOptions)
    document.addEventListener('mouseover', mouseoverListener, eventListenersOptions)
}

function touchstartListener(event) {
    /* Chrome on Android calls mouseover before touchcancel so `lastTouchTimestamp`
     * must be assigned on touchstart to be measured on mouseover. */
    lastTouchTimestamp = performance.now()

    const linkElement = event.target.closest('a')

    if (!linkElement) {
        return
    }

    if (!isPreloadable(linkElement)) {
        return
    }

    linkElement.addEventListener('touchcancel', touchendAndTouchcancelListener, { passive: true })
    linkElement.addEventListener('touchend', touchendAndTouchcancelListener, { passive: true })

    urlToPreload = linkElement.href
    preload(linkElement.href)
}

function touchendAndTouchcancelListener() {
    urlToPreload = undefined
    stopPreloading()
}

function mouseoverListener(event) {
    if (performance.now() - lastTouchTimestamp < 1100) {
        return
    }

    const linkElement = event.target.closest('a')

    if (!linkElement) {
        return
    }

    if (!isPreloadable(linkElement)) {
        return
    }

    linkElement.addEventListener('mouseout', mouseoutListener, { passive: true })

    urlToPreload = linkElement.href

    mouseoverTimer = setTimeout(() => {
        preload(linkElement.href)
        mouseoverTimer = undefined
    }, 65)
}

function mouseoutListener(event) {
    if (event.relatedTarget && event.target.closest('a') == event.relatedTarget.closest('a')) {
        return
    }

    if (mouseoverTimer) {
        clearTimeout(mouseoverTimer)
        mouseoverTimer = undefined
    }
    else {
        urlToPreload = undefined
        stopPreloading()
    }
}

function isPreloadable(linkElement) {
    if (urlToPreload == linkElement.href) {
        return
    }

    const urlObject = new URL(linkElement.href)

    if (urlObject.origin != location.origin) {
        return
    }

    if (!allowQueryString && urlObject.search && !('instant' in linkElement.dataset)) {
        return
    }

    if (urlObject.hash && urlObject.pathname + urlObject.search == location.pathname + location.search) {
        return
    }

    if ('noInstant' in linkElement.dataset) {
        return
    }

    return true
}

function preload(url) {
    prefetcher.href = url
}

function stopPreloading() {
    /* The spec says an empty string should abort the prefetching
    * but Firefox 64 interprets it as a relative URL to prefetch. */
    prefetcher.removeAttribute('href')
}

Navigate to “wwwroot/images” and add an SVG image that will be displayed to the user along with the instructions when using the FIDO2 authenticator.

Create “securitykey.min.svg” like so:

<svg xmlns="http://www.w3.org/2000/svg" width="783.478" height="321.091" viewBox="0 0 734.51033 301.02249"><g transform="translate(71.951 -314.708)"><rect width="285.714" height="122.857" x="105.714" y="403.791" ry="6" fill="#fff" stroke="#446cb3"/><path fill="#fff" stroke="#446cb3" d="M391.465 416.976h16.162v93.944h-16.162z"/><circle cx="246.429" cy="462.362" r="40" fill="#446cb3" stroke="#446cb3"/><circle r="14.603" cy="463.791" cx="125.714" fill="#fff" stroke="#446cb3"/><path fill="#f9bf3b" fill-opacity=".497" stroke="#f9bf3b" stroke-width="1.223" d="M408.861 428.802h62.991v14.471h-62.991zM408.861 484.958h62.991v14.471h-62.991z"/><path fill="#f9bf3b" fill-opacity=".497" stroke="#f9bf3b" stroke-width="1.078" d="M408.789 447.653h48.493v14.615h-48.493zM408.789 466.106h48.493v14.615h-48.493z"/><path fill="none" stroke="#446cb3" d="M407.784 417.011h77.11v93.943h-77.11z"/><path d="M236.034 451.289a10.094 10.094 0 0 0-10.093 10.094 10.094 10.094 0 0 0 10.093 10.093 10.094 10.094 0 0 0 10.097-10.093 10.094 10.094 0 0 0-10.097-10.094zm0 3.73a6.365 6.365 0 0 1 6.367 6.364 6.365 6.365 0 0 1-6.367 6.364 6.365 6.365 0 0 1-6.364-6.364 6.365 6.365 0 0 1 6.364-6.365z" fill="#fff" stroke="#446cb3" stroke-width=".691"/><path fill="#fff" d="M245.102 459.194h25.987v3.939h-25.987z"/><path fill="#fff" d="M266.719 462.841h2.669v5.463h-2.669zM259.949 462.841h2.669v4.334h-2.669z"/><circle r="40" cy="462.362" cx="246.429" fill="none" stroke="#446cb3"><animate attributeType="SVG" attributeName="r" begin="0s" dur="1.5s" repeatCount="indefinite" from="10%" to="25%"/><animate attributeType="CSS" attributeName="stroke-width" begin="0s" dur="1.5s" repeatCount="indefinite" from="1%" to="0%"/><animate attributeType="CSS" attributeName="opacity" begin="0s" dur="1.5s" repeatCount="indefinite" from="1" to="0"/></circle></g></svg>

Navigate to “Views/Shared” and open “_Layout.cshtml” view. Add styles and scripts into “head” element like so:

<head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>IdentityServer4</title>
    <link rel="icon" type="image/x-icon" href="~/favicon.ico" />
    <link rel="shortcut icon" type="image/x-icon" href="~/favicon.ico" />
    <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.css" />
    <link rel="stylesheet" href="~/css/site.css" />
    <link href="https://fonts.googleapis.com/css?family=Work+Sans" rel="stylesheet">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.11.0/umd/popper.min.js" integrity="sha384-b/U6ypiBEHpOf/4+1nzFpr53nxSS+GLCkfwBdFNTxtclqqenISfwAzpKaMNFNmj4" crossorigin="anonymous"></script>
    <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/sweetalert2"></script>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/limonte-sweetalert2/6.10.1/sweetalert2.min.css" />
    <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
</head>

We will modify the dropdown menu in navbar to add the “Manage account” option so a user can easily access it. Changes are in the “body” element like so:

<ul class="nav navbar-nav">
	<li class="dropdown">
		<a href="#" class="dropdown-toggle" data-toggle="dropdown">@name <b class="caret"></b></a>
		<ul class="dropdown-menu">
			<li><a href="~/Identity/Account/Manage">Manage account</a></li>
			@if (!string.IsNullOrWhiteSpace(name))
			{
				<li><a asp-action="Logout" asp-controller="Account">Logout</a></li>
			}
		</ul>
	</li>
</ul>

That concludes the libraries and resources we need to add at the moment. We will add razor pages later but let’s focus on database changes for a moment.

 

Database changes

Our current implementation of ASP.NET Core Identity doesn’t support FIDO2 out of the box. We need to persist the public credentials of the enrolled authenticators in the database in order to be able to validate the authenticator assertions during the authentication ceremony. We will create a new entity called “FidoStoredCredential” along with the service to easily manipulate the stored credentials. Navigate to the “Models” folder and add the “FidoStoredCredential.cs” class like so:

using Fido2NetLib.Objects;
using Newtonsoft.Json;
using System;
using System.ComponentModel.DataAnnotations.Schema;

namespace IdentityServer.Models
{
    public class FidoStoredCredential
    {
        public string Username { get; set; }
        public byte[] UserId { get; set; }
        public byte[] PublicKey { get; set; }
        public byte[] UserHandle { get; set; }
        public uint SignatureCounter { get; set; }
        public string CredType { get; set; }
        public DateTime RegDate { get; set; }
        public Guid AaGuid { get; set; }

        [NotMapped]
        public PublicKeyCredentialDescriptor Descriptor
        {
            get { return string.IsNullOrWhiteSpace(DescriptorJson) ? null : JsonConvert.DeserializeObject<PublicKeyCredentialDescriptor>(DescriptorJson); }
            set { DescriptorJson = JsonConvert.SerializeObject(value); }
        }
        public string DescriptorJson { get; set; }
    }
}

Now navigate to “Services” folder and add “Fido2Storage.cs” class like so:

using Fido2NetLib;
using Microsoft.EntityFrameworkCore;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using IdentityServer.Models;

namespace IdentityServer.Services
{
    public class Fido2Storage
    {
        private readonly IdentityDbContext _identityDbContext;

        public Fido2Storage(IdentityDbContext identityDbContext)
        {
            _identityDbContext = identityDbContext;
        }

        public async Task<List<FidoStoredCredential>> GetCredentialsByUsername(string username)
        {
            return await _identityDbContext.FidoStoredCredential.Where(c => c.Username == username).ToListAsync();
        }

        public async Task RemoveCredentialsByUsername(string username)
        {
            var item = await _identityDbContext.FidoStoredCredential.Where(c => c.Username == username).FirstOrDefaultAsync();
            if (item != null)
            {
                _identityDbContext.FidoStoredCredential.Remove(item);
                await _identityDbContext.SaveChangesAsync();
            }
        }

        public async Task<FidoStoredCredential> GetCredentialById(byte[] id)
        {
            var credentialIdString = Base64Url.Encode(id);
            //byte[] credentialIdStringByte = Base64Url.Decode(credentialIdString);

            var cred = await _identityDbContext.FidoStoredCredential
                .Where(c => c.DescriptorJson.Contains(credentialIdString)).FirstOrDefaultAsync();

            return cred;
        }

        public Task<List<FidoStoredCredential>> GetCredentialsByUserHandleAsync(byte[] userHandle)
        {
            return Task.FromResult(_identityDbContext.FidoStoredCredential.Where(c => c.UserHandle.SequenceEqual(userHandle)).ToList());
        }

        public async Task UpdateCounter(byte[] credentialId, uint counter)
        {
            var credentialIdString = Base64Url.Encode(credentialId);
            //byte[] credentialIdStringByte = Base64Url.Decode(credentialIdString);

            var cred = await _identityDbContext.FidoStoredCredential
                .Where(c => c.DescriptorJson.Contains(credentialIdString)).FirstOrDefaultAsync();

            cred.SignatureCounter = counter;
            await _identityDbContext.SaveChangesAsync();
        }

        public async Task AddCredentialToUser(Fido2User user, FidoStoredCredential credential)
        {
            credential.UserId = user.Id;
            _identityDbContext.FidoStoredCredential.Add(credential);
            await _identityDbContext.SaveChangesAsync();
        }

        public async Task<List<Fido2User>> GetUsersByCredentialIdAsync(byte[] credentialId)
        {
            var credentialIdString = Base64Url.Encode(credentialId);
            //byte[] credentialIdStringByte = Base64Url.Decode(credentialIdString);

            var cred = await _identityDbContext.FidoStoredCredential
                .Where(c => c.DescriptorJson.Contains(credentialIdString)).FirstOrDefaultAsync();

            if (cred == null)
            {
                return new List<Fido2User>();
            }

            return await _identityDbContext.Users
                    .Where(u => Encoding.UTF8.GetBytes(u.UserName)
                    .SequenceEqual(cred.UserId))
                    .Select(u => new Fido2User
                    {
                        DisplayName = u.UserName,
                        Name = u.UserName,
                        Id = Encoding.UTF8.GetBytes(u.UserName) // byte representation of userID is required
                    }).ToListAsync();
        }
    }
}

You will notice that our “IdentityDbContext” doesn’t contain a definition for “FidoStoredCredential” as “Fido2Storage” underlines errors in red. Let’s fix that. Navigate to “Data” folder and open “IdentityDbContext.cs”. Let’s add “FidoStoredCredentials” entity like so:

using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;

namespace IdentityServer.Models
{
    public class IdentityDbContext : IdentityDbContext<ApplicationUser>
    {
        public IdentityDbContext(DbContextOptions<IdentityDbContext> options)
            : base(options)
        {
        }

        public DbSet<FidoStoredCredential> FidoStoredCredential { get; set; }

        protected override void OnModelCreating(ModelBuilder builder)
        {
            builder.Entity<FidoStoredCredential>().HasKey(m => m.Username);
            base.OnModelCreating(builder);
        }
    }
}

Let’s build to the solution to verify there are no more errors so we can do the code-first migration and update the database with the new table for FIDO2 stored credentials. Open the Package Manager Console and create the migration and update the database like so:

Add-Migration FidoStoredCredentialAdded -c IdentityDbContext -o Data/Migrations/AspNetIdentity/AspNetIdentityDb
Update-Database -Context IdentityDbContext

This concludes the database changes. Now we have a new entity for FIDO2 credentials, we have a service for easily working with FIDO2 data and a new table in the database called “dbo.FidoStoredCredential” to persist FIDO2 data.

Let’s start working on the razor pages next. Remember, ASP.NET Core Identity doesn’t come with out-of-the-box support for FIDO2 so same as we needed to extend the data store to support it, we need to extend the code to add missing features too.

 

Continue to PART 2 of this tutorial.

 

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.

07. IdentityServer4 MFA – TOTP .NET Core 3.1

You can find the project here.

Why MFA

Multi-factor authentication or MFA requires multiple factors to authenticate a user. Two-factor authentication (2FA) is an MFA with two factors. There is no real limit on how many factors we can add but it’s not practical for a user to use many factors as it hurts usability of the application if the login process is too long and complicated. Adding a second factor is usually enough to stop the brute-force and dictionary attacks.

Not all MFA is created equal. Some factors are still vulnerable to reverse proxy scenarios where a phishing attack is launched to trick users into clicking a malicious link that will mimic a real application look and feel using a reverse proxy and will allow attackers to capture username, password and any type of code entered as a second factor. Other factors like hardware-based authenticators (think YubiKey 5 for example) using FIDO2 will protect you against reverse proxy man-in-the-middle attack scenarios. It all boils down to making trade-offs between usability, price, and security. Adding a second factor, even if it’s not an optimal one, is still better than having a single factor. There are only 3 categories of things that can be used to authenticate you:

  1. Something you know (password, PIN, gesture, etc.)
  2. Something you have (a card, key, one-time password, some kind of device, etc.)
  3. Something you are (face, fingerprint, retina, typing style, etc.)

Good authentication flow will use multiple factors from multiple categories. It might use a password and a one-time password (2FA with TOTP Google Authenticator for example), card and a PIN (ATM for example), face and password (on your phone for example).

Legacy ways of implementing MFA by sending codes using email or SMS are not recommended anymore as they are considered not secure enough as those channels can be easily intercepted. Time-based One-time Password (TOTP) is considered more secure with code being automatically generated every 30 seconds without the server and TOTP app talking to each other. It is based on a timestamp and TOTP algorithm. TOTP is generally accepted as a minimum these days to implement MFA.

An even better way of doing it is using FIDO2 hardware authenticators like YubiKey 5, SoloKeys, etc based on RSA encryption with public/private key pairs. There is currently no known way to bypass hardware authenticators with phishing or man-in-the-middle attacks or using social engineering approach. The only way would be for someone to steal your key (it is a small device like a USB dongle).

IdentityServer4, as we previously learned, has nothing to do with users and doesn’t care much about them. That is why we implemented the ASP.NET Core Identity as our user store. Along with user data storage, we got a handful of useful methods to deal with registering users, setting the password and adding additional factors. Our focus is to actually extend the ASP.NET Core Identity to work with the desired factors. We will only use factors that are considered secure in 2019 and avoid any legacy ways of doing MFA.

The first way we will implement MFA is using TOTP with Google Authenticator (or any other standard TOTP authenticator app) and the second way is using FIDO2 with YubiKey 5 (we will add FIDO2 in my next tutorial). Let’s get to it.

 

Code changes

This tutorial is based on a project from a previous tutorial. Scaffolded ASP.NET Core Identity comes with TOTP support out of the box. We just need to add some missing bits and pieces to our IdentityServer4 “Quickstart” to make it work properly.

The easiest way to connect to the TOTP authenticator app (Google Authenticator for example) is by scanning the QR code. We need to add the JavaScript library that will generate the QR code for us. Download the qrcode.js library from here https://davidshimjs.github.io/qrcodejs/ and put “qrcode.min.js” into “wwwroot/lib/qrcode” folder like so:

Now let’s open “Areas/Identity/Pages/Account/Manage/EnableAuthenticator.cshtml” and modify the “Scripts” section at the end of the file like so:

@section Scripts {
    <partial name="_ValidationScriptsPartial" />
    <script type="text/javascript" src="~/lib/qrcode/qrcode.min.js"></script>
    <script type="text/javascript">
        new QRCode(document.getElementById("qrCode"),
            {
                text: "@Html.Raw(Model.AuthenticatorUri)",
                width: 150,
                height: 150
            });
    </script>
}

Comment out (or remove) the section of the page that shows how to implement the QR code as we already implemented it like so:

@*<div class="alert alert-info">To enable QR code generation please read our <a href="https://go.microsoft.com/fwlink/?Linkid=852423">documentation</a>.</div>*@

In order for local login to allow user to enter TOTP we need to modify the response after successful first factor authentication with username and password. Open “Quickstart/Account/AccountController.cs” and locate HttpPost “Login” method. We will not get the “result.Succeeded” result anymore but “result.RequiresTwoFactor” result after the 2FA is enabled for a user. Let’s modify the code to handle this scenario like so:

if (result.Succeeded)
{
	(existing code - do not change)
}
else if (result.RequiresTwoFactor)
{
	string 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("~/")));
	}
}

That is all that is pretty much needed to get the TOTP to work and show with QR code. To get the full experience however we need to make a couple of changes to get the scaffolded ASP.NET Core Identity to work with us. To prove my point, start the application and navigate to http://localhost:5000/Identity/Account/Manage.

You will see an error like so:

ASP.NET Core Identity needs the registered implementation of IEmailSender to resolve the missing service. I will use SendGrid as an example, you can change it to whatever suits you.

First, install the “Sendgrid” NuGet package like so:

Next in the “Services” folder add a new class and name it “EmailSender”. It will implement the “IEmailSender” interface like so:

using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.Extensions.Configuration;
using SendGrid;
using SendGrid.Helpers.Mail;
using System.Threading.Tasks;

namespace IdentityServer.Services
{
    public class EmailSender : IEmailSender
    {
        private readonly IConfiguration _configuration;

        public EmailSender(IConfiguration configuration)
        {
            _configuration = configuration;
        }

        public Task SendEmailAsync(string email, string subject, string message)
        {
            string key = _configuration.GetSection("SendGrid").GetValue<string>("ApiKey");
            return Execute(key, subject, message, email);
        }

        public Task Execute(string apiKey, string subject, string message, string email)
        {
            var client = new SendGridClient(apiKey);
            string fromEmail = _configuration.GetSection("SendGrid").GetValue<string>("FromEmail");
            string fromName = _configuration.GetSection("SendGrid").GetValue<string>("FromName");
            
            var msg = new SendGridMessage()
            {
                From = new EmailAddress(fromEmail, fromName),
                Subject = subject,
                PlainTextContent = message,
                HtmlContent = message
            };
            msg.AddTo(new EmailAddress(email));

            // Disable click tracking.
            // See https://sendgrid.com/docs/User_Guide/Settings/tracking.html
            msg.SetClickTracking(false, false);

            return client.SendEmailAsync(msg);
        }
    }
}

Let’s add configuration for the SendGrid in “appsettings.json” like so:

"SendGrid": {
	"ApiKey": "xxxxxxxx",
	"FromEmail": "identity@server.com",
	"FromName": "Identity Server"
}

Note: Change the SendGrid settings in “appsettings.json” to reflect your SendGrid config.

Let’s register the service in “Startup.cs”. Find the “ConfigureServices” method and at the end of the method add

services.AddTransient<IEmailSender, EmailSender>();

To make our lives a bit easier later let’s add these razor page options to the start of the “ConfigureServices” method like so:

services.AddControllersWithViews();
            services.AddRazorPages()
                .AddRazorPagesOptions(options => 
                    {                        
                        options.Conventions.AuthorizeAreaFolder("Identity", "/Account/Manage");
                    });

Add the missing “using” directive:

using Microsoft.AspNetCore.Identity.UI.Services;

Also in “Startup.cs” find Configure method and change this:

app.UseEndpoints(endopoints =>
{
    endpoints.MapDefaultControllerRoute();
});

to this:

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute(
        name: "default",
        pattern: "{controller=Home}/{action=Index}/{id?}");
    endpoints.MapRazorPages();
});

This code will allow application to show Razor pages.

 

Now we have the “EmailSender” registered and ready to send email using SendGrid.

Let’s do some cosmetic changes to align the ASP.NET Core Identity layout with the IdentityServer4 layout.

Open “Areas/Identity/Pages/Account/Manage/_Layout.cshtml” and change the master layout like so.

@{ 
    Layout = "/Views/Shared/_Layout.cshtml";
}

Final change is to create “_ViewStart.cshtml” in same folder “Areas/Identity/Pages/Account/Manage” like so:

@{
    Layout = "_Layout.cshtml";
}

Done.

 

TOTP Enrollment process

Launch IdentityServer4 application and navigate to http://localhost:5000/Identity/Account/Manage. You should see it now working in full glory

Click on “Two-factor authentication”.

Click on the “Add authenticator app”. You should now see the generated QR code.

On your mobile device install the “Google Authenticator” application (or any other TOTP authenticator app). Start the app and tap on “Scan a barcode”

You should get the code generated on your screen like so:

Enter the code on the page and click “Verify”. An authenticator is now verified. You can see the recovery codes. Save them as a backup in case you lose your phone or else you will not be able to authenticate.

Tap on “Add account” on the authenticator app to confirm you want to add the account.

Done.

 

Take a look at the user tables

Take a look at these tables “dbo.AspNetUsers” and “dbo.AspNetUserTokens”. You will see that the “TwoFactorEnabled” flag is turned on for user “alice” (that is the user I used). You will also notice that two users tokens got created for the same user “alice”. One is the authenticator key itself and another one is for the recovery codes.

Play with it

Sign out, sign in, reset authenticator app, disable 2FA. Play with it and get to know it good as we are going to extend this part of the ASP.NET Core Identity to add FIDO2 in my next tutorial. Because we want all them factors!

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.

06. IdentityServer4 External Providers .NET Core 3.1

You can find the project here.

Standard Protocols

All Identity Providers are supported using standard protocols like OpenID Connect, OAuth2, SAML2 and WS-Federation. This could be Okta, it could be Auth0, could be proprietary IdP of a client, could be another IdentityServer4. Take a look at the list of out-of-the-box extensions for “AuthenticationBuilder” for big providers like Azure AD, Microsoft Account, Google, Facebook, Twitter, etc here https://docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.authenticationbuilder?view=aspnetcore-2.2

Setting up the usual OpenID Connect OIDC middleware is enough for most of the providers to get you going. Almost all providers nowadays provide OIDC, some as a second option alongside SAML2 and/or WS-Fed.

As IdentityServer4 is OIDC Identity Provider you can actually set up one IdentityServer4 instance to be an external provider for another IdentityServer4 instance using OIDC middleware. As long as there is a single root node, all Identity Servers connected this way can achieve SSO.

 

Azure AD Example

I will continue from my last tutorial. Open the “Quickstart” solution in Visual Studio.

Open the “Startup.cs” in project root and navigate right above the “AddIdentityServer” service registration. Add the authentication middleware for AzureAD like so:

services.AddAuthentication()
	.AddOpenIdConnect("azuread", "Azure AD", options => Configuration.Bind("AzureAd", options));

services.Configure<OpenIdConnectOptions>("azuread", options =>
            {
                options.Scope.Add("openid");
                options.Scope.Add("profile");
                options.Events = new OpenIdConnectEvents()
                {
                    OnRedirectToIdentityProviderForSignOut = context =>
                    {
                        context.HandleResponse();
                        context.Response.Redirect("/Account/Logout");
                        return Task.FromResult(0);
                    }
                };
            });

Now open the “appsettings.json” in project root and modify it to add the Azure AD configuration we are using and binding in “Startup” like so:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=IdentityServerQuickstart.NetCore3.1;Trusted_Connection=True;MultipleActiveResultSets=true"
  },
  "AzureAd": {
    // Authority/MetadataAddress format (https://{instance}/{tenantId}/...
    "Authority": "https://login.microsoftonline.com/0366c849-xxxx-xxxx-xxxx-adcc0ccf2170/oauth2/v2.0/",
    "MetadataAddress": "https://login.microsoftonline.com/0366c849-xxxx-xxxx-xxxx-adcc0ccf2170/.well-known/openid-configuration",
    "ClientId": "7adeb3b0-xxxx-xxxx-xxxx-a6bc5aa756da",
    "CallbackPath": "/signin-oidc"
  }
}

Note: You must get your “TenantId” and “ClientId” (aka “ApplicationId”) from the Azure portal. Here are the official docs on how to create an app in Azure AD https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app

Tip: You will need the ReturnUrl for app registration. For this demo, the return URL is http://localhost:5000/signin-oidc

 

Okta Example

Open the “Startup.cs” in project root and navigate right below the “AddAzureAD” and add:

.AddOpenIdConnect("okta", "Okta", options => Configuration.Bind("Okta", options));

Also add the OpenIdConnectOptions service configuration like so:

services.Configure<OpenIdConnectOptions>("okta", options =>
            {
                options.Scope.Add("openid");
                options.Scope.Add("profile");
                options.Events = new OpenIdConnectEvents()
                {
                    OnRedirectToIdentityProviderForSignOut = context =>
                    {
                        context.HandleResponse();
                        context.Response.Redirect("/Account/Logout");
                        return Task.FromResult(0);
                    }
                };
            });

So full code including Azure Ad and Okta looks like so:

services.AddAuthentication()
                .AddOpenIdConnect("azuread", "Azure AD", options => Configuration.Bind("AzureAd", options))
                .AddOpenIdConnect("okta", "Okta", options => Configuration.Bind("Okta", options));
            services.Configure<OpenIdConnectOptions>("azuread", options =>
            {
                options.Scope.Add("openid");
                options.Scope.Add("profile");
                options.Events = new OpenIdConnectEvents()
                {
                    OnRedirectToIdentityProviderForSignOut = context =>
                    {
                        context.HandleResponse();
                        context.Response.Redirect("/Account/Logout");
                        return Task.FromResult(0);
                    }
                };
            });
            services.Configure<OpenIdConnectOptions>("okta", options =>
            {
                options.Scope.Add("openid");
                options.Scope.Add("profile");
                options.Events = new OpenIdConnectEvents()
                {
                    OnRedirectToIdentityProviderForSignOut = context =>
                    {
                        context.HandleResponse();
                        context.Response.Redirect("/Account/Logout");
                        return Task.FromResult(0);
                    }
                };
            });

Now open the “appsettings.json” in project root and modify it to add the Okta configuration we are using and binding in “Startup” like so:

"Okta": {
	"Authority": "https://dev-xxxxxx-admin.oktapreview.com",
	"ClientId": "0oakhxxxxxxxxxxaX0h7",
	"CallbackPath": "/signin-oidc-okta"
}

Note: You must get your “Authority” and “ClientId” from Okta. Here are the official docs how to create an Okta app https://developer.okta.com/docs/guides/add-an-external-idp/microsoft/register-app-in-okta/

Tip: You will need the ReturnUrl for app registration. For this demo, the return URL is http://localhost:5000/signin-oidc-okta

 

Modify the user auto-provisioning process

Because we added the “IsEnable” custom property in the previous tutorial the auto-provisioned user will by default have value “false” (disabled user) and the external provider login will fail. We need to slightly modify the automatic user creation process for external providers to set the “IsEnabled” flag to “true”. Navigate to “Quickstart/Account/ExternalController.cs” and open it.

Find the “AutoProvisionUserAsync” method and modify the line that instantiates new user. We need to modify it to set the “IsEnabled” user property to “true” like so:

var user = new ApplicationUser
{
	UserName = Guid.NewGuid().ToString(),
	Email = email,
	IsEnabled = true
};

Now run the IdentityServer4 and try to sign in with Azure AD or Okta. If the local user exists with the same username or email as the external user (from Azure AD or Okta in our example) the matching process will link the external user with local user and the new local user will not be created. For other scenarios (no match) the auto-provisioning process will create a new local user and link it with the external user. I logged in using Okta and the new local user was auto-provisioned. Notice that my name was automatically populated from the claims provided by Okta. These are the claims of the external user now set to the local user.

Too easy

Now that was super easy, wasn’t it? Adding any standard Identity Provider shouldn’t pose any challenge as the method is pretty much the same. In my next tutorial I will start tackling one of the important features which are Multi-Factor Authentication MFA aka 2FA if there are two factors. Stay fresh!

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.

05. IdentityServer4 Adding custom properties to User .NET Core 3.1

You can find the project here.

Custom User properties vs claims

Initial user properties are set by ASP.NET Core Identity. These properties like “Username”, “Email”, “AccessFailedCount” etc are defined for each user. Claims could be used to add additional user information in tokens for a specified identity scope. But there are scenarios where adding claims is not optimal. For example, adding the “IsEnabled” property to a “User” model makes sense, but adding the “IsEnabled” claim is kind of weird. “IsEnabled” property should be much closer to the user. It should be stored in the same table where the user information is stored. “IsEnabled” flag should be available to us in login flows where we validate user information. For example, if the user is disabled we don’t want a successful login result.

 

Adding custom properties to User

I will continue from my previous tutorial where we migrated user store to a Sql database. Open the “Quickstart” solution in Visual Studio. Navigate to “Models/ApplicationUser.cs”. “ApplicationUser” class is currently empty but it inherits from the “IdentityUser” class. Let’s add two properties, the “IsEnabled” and the “EmployeeId” property like so

public class ApplicationUser : IdentityUser
{
	public bool IsEnabled { get; set; }
	public string EmployeeId { get; set; }
}

We need to create and execute a data migration to create the “IsEnabled” and “EmployeeId” columns in the SQL table “dbo.AspNetUsers”. We are actually expanding the user store and adding a custom property to a “User” model and backing data store. Each time we grab a “User” model from the database we will grab the “IsEnabled” and “EmployeeId” properties along. This is extremely helpful if we want to control the login flow with these properties.

Before moving forward let’s create and execute the data migration. Open the Package Manager Console and type in:

Add-Migration IdentityUserCustomProperties -c IdentityDbCOntext -o Data/Migrations/AspNetIdentity/AspNetIdentityDb
Update-Database -Context IdentityDbContext

Successful migration should produce the output like so:

If you now check the “dbo.AspNetUsers” table you will see two new columns corresponding to the specified custom properties.

 

Modify login flow to check the IsEnabled property

Let’s try to use the “IsEnabled” property for something useful. We will check the “IsEnabled” property during the local and external login process to stop the login if the user is disabled. Open the “Quickstart/Account/AccountController.cs” and find “Login” action method that handles the POST request. We don’t want to add any code prior to input model validation. After the model validation we can try to find the user and check the “IsEnabled” flag like so: 

if (ModelState.IsValid)
{
	var user = await _userManager.FindByNameAsync(model.Username);
	if (user != null && user.IsEnabled)
	{
		var result = await _signInManager.PasswordSignInAsync(model.Username, model.Password, model.RememberLogin, lockoutOnFailure: true);
		if (result.Succeeded)
		{
			await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id, user.UserName));

			if (context != null)
			{
				if (await _clientStore.IsPkceClientAsync(context.ClientId))
				{
					// if the client is PKCE then we assume it's native, so this change in how to
					// return the response is for better UX for the end user.
					return View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl });
				}

				// we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
				return Redirect(model.ReturnUrl);
			}

			// request for a local page
			if (Url.IsLocalUrl(model.ReturnUrl))
			{
				return Redirect(model.ReturnUrl);
			}
			else if (string.IsNullOrEmpty(model.ReturnUrl))
			{
				return Redirect("~/");
			}
			else
			{
				// user might have clicked on a malicious link - should be logged
				throw new Exception("invalid return URL");
			}
		}
	}

	await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials"));
	ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage);
}

We can also modify the “InvalidCredentialsErrorMessage” to include the message that the user is disabled to avoid user confusion as a user might provide correct credentials but still can’t sign in. Open the “AccountOptions.cs” in same folder and modify public static string “InvalidCredentialsErrorMessage” like so:

public static string InvalidCredentialsErrorMessage = "Invalid credentials or disabled user";

This handles the local login flow. We also need to modify the external login flow to not allow disabled users to sign in. Open the “Quickstart/Account/ExternalController.cs” and find the “Callback” method. Once we find (or provision) the local user based on the external login we can check the “IsEnabled” flag like so:

// lookup our user and external provider info
var (user, provider, providerUserId, claims) = await FindUserFromExternalProviderAsync(result);
if (user == null)
{
	// this might be where you might initiate a custom workflow for user registration
	// in this sample we don't show how that would be done, as our sample implementation
	// simply auto-provisions new external user
	user = await AutoProvisionUserAsync(provider, providerUserId, claims);
}

if (!user.IsEnabled)
{
	throw new Exception("External authentication error. Local user is disabled.");
}

That is it. Now we can enable or disable a user based on the custom “IsEnabled” flag which can easily be set in “dbo.AspNetUsers” table. Run the IdentityServer4 and try to login http://localhost:5000/Account/Login with user “alice” and password “My long 123$ password”. You will get an error because the “IsEnabled” flag is set to 0 (false).

 

Update the “IsEnabled” flag in database to 1 (true) to enable the user login for the user “alice” like so:

UPDATE [dbo].[AspNetUsers] SET [IsEnabled] = 1 WHERE [UserName] = 'alice'

 

Expose custom User property in tokens

Adding custom properties to a “User” model doesn’t mean that these custom properties are going to get exposed in id or access tokens. To expose a custom user property in tokens we need to create the “IProfileService” implementation. In the project root create a new folder called “Services” and add a new class named “ProfileService”. Open the “ProfileService.cs” and modify it like so:

using IdentityServer.Models;
using IdentityServer4.Extensions;
using IdentityServer4.Models;
using IdentityServer4.Services;
using Microsoft.AspNetCore.Identity;
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;

namespace IdentityServer.Services
{
    public class ProfileService : IProfileService
    {
        private readonly IUserClaimsPrincipalFactory<ApplicationUser> _claimsFactory;
        private readonly UserManager<ApplicationUser> _userManager;

        public ProfileService(UserManager<ApplicationUser> userManager, IUserClaimsPrincipalFactory<ApplicationUser> claimsFactory)
        {
            _userManager = userManager;
            _claimsFactory = claimsFactory;
        }

        public async Task GetProfileDataAsync(ProfileDataRequestContext context)
        {
            var sub = context.Subject.GetSubjectId();
            var user = await _userManager.FindByIdAsync(sub);
            var principal = await _claimsFactory.CreateAsync(user);

            var claims = principal.Claims.ToList();
            claims = claims.Where(claim => context.RequestedClaimTypes.Contains(claim.Type)).ToList();

            // Add custom claims in token here based on user properties or any other source
            claims.Add(new Claim("employee_id", user.EmployeeId ?? string.Empty));

            context.IssuedClaims = claims;
        }

        public async Task IsActiveAsync(IsActiveContext context)
        {
            var sub = context.Subject.GetSubjectId();
            var user = await _userManager.FindByIdAsync(sub);
            context.IsActive = user != null;
        }
    }
}

All that is left to do is to add a profile service dependency injection. Open “Startup.cs” and add a scoped service at the end of the “ConfigureServices” method like so:

services.AddScoped<IProfileService, ProfileService>();

Add missing “using” directives like so:

using IdentityServer4.Services;
using IdentityServer.Services;

Done. Now when the user logs in the “employee_id” claim will be present in the token:

{
  "sid": "420f3d324b47c392d30d5b02aa699324",
  "sub": "6649e236-ae2f-41ec-89e1-0285377b8284",
  "auth_time": 1569521858,
  "idp": "local",
  "amr": [
    "pwd"
  ],
  "name": "Alice Smith",
  "given_name": "Alice",
  "family_name": "Smith",
  "website": "http://alice.com",
  "preferred_username": "alice",
  "employee_id": ""
}

Note: the same effect could be achieved by adding the claim to a user and expose it in a token. The custom user property approach has the advantage of keeping the custom property value directly in the “dbo.AspNetUsers” table which simplifies maintenance, migration and value modification. Adding custom properties to a user is not a preferred way of adding claims to a token. Sometimes it makes much more sense to store it alongside the user data.

 

Wrap up

It was pretty easy, wasn’t it? You successfully extended the “IdentityUser” model, modified the login flow based on a custom “IsEnabled” property and exposed the “EmployeeId” property in tokens for clients to use. In the next tutorial we are going to do something completely different. We are going to work on adding external providers like Azure AD and Okta to connect to the world. Stay funky!

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.

04. PART 3 IdentityServer4 ASP.NET Core Identity .NET Core 3.1

You can find the project here.

Test data

In order to start playing with the IdentityServer4, later on, we must populate test config and user data into our database tables. Without it, we can’t start using the IdentityServer4. Changes are pretty straight forward and require adding the seed data in code and adding a setting in appsettings. When seeding is enabled the application will start, create the database, run the migrations to create both IdentityServer4 and ASP.NET Core Identity tables and populate the test data for configuration (resources and clients) and user data (users and claims).

Add test configuration and users

I will continue with the “Quickstart” solution from the previous tutorial. Open the solution and navigate to the “Data” folder. Open “IdentityDbContext.cs” and below “OnModelCreating” method create “UserSeed” method like so:

private void UsersSeed(ModelBuilder builder)
{
    var password = "My long 123$ password";

    var alice = new ApplicationUser
    {
        Id = "1",
        UserName = "alice",
        NormalizedUserName = "ALICE",
        Email = "AliceSmith@email.com",
        NormalizedEmail = "AliceSmith@email.com".ToUpper(),
        EmailConfirmed = true
    };
    alice.PasswordHash = new PasswordHasher<ApplicationUser>().HashPassword(alice, password);

    var bob = new ApplicationUser
    {
        Id = "2",
        UserName = "bob",
        NormalizedUserName = "BOB",
        Email = "BobSmith@email.com",
        NormalizedEmail = "bobsmith@email.com".ToUpper(),
        EmailConfirmed = true,
    };
    bob.PasswordHash = new PasswordHasher<ApplicationUser>().HashPassword(bob, password);

    builder.Entity<ApplicationUser>()
        .HasData(alice, bob);


    builder.Entity<IdentityUserClaim<string>>()
        .HasData(
            new IdentityUserClaim<string>
            {
                Id = 1,
                UserId = "1",
                ClaimType = "name",
                ClaimValue = "Alice Smith"
            },
            new IdentityUserClaim<string>
            {
                Id = 2,
                UserId = "1",
                ClaimType = "given_name",
                ClaimValue = "Alice"
            },
            new IdentityUserClaim<string>
            {
                Id = 3,
                UserId = "1",
                ClaimType = "family_name",
                ClaimValue = "Smith"
            },
            new IdentityUserClaim<string>
            {
                Id = 4,
                UserId = "1",
                ClaimType = "email",
                ClaimValue = "AliceSmith@email.com"
            },
            new IdentityUserClaim<string>
            {
                Id = 5,
                UserId = "1",
                ClaimType = "website",
                ClaimValue = "http://alice.com"
            },
            new IdentityUserClaim<string>
            {
                Id = 6,
                UserId = "2",
                ClaimType = "name",
                ClaimValue = "Bob Smith"
            },
            new IdentityUserClaim<string>
            {
                Id = 7,
                UserId = "2",
                ClaimType = "given_name",
                ClaimValue = "Bob"
            },
            new IdentityUserClaim<string>
            {
                Id = 8,
                UserId = "2",
                ClaimType = "family_name",
                ClaimValue = "Smith"
            },
            new IdentityUserClaim<string>
            {
                Id = 9,
                UserId = "2",
                ClaimType = "email",
                ClaimValue = "BobSmith@email.com"
            },
            new IdentityUserClaim<string>
            {
                Id = 10,
                UserId = "2",
                ClaimType = "website",
                ClaimValue = "http://bob.com"
            },
            new IdentityUserClaim<string>
            {
                Id = 11,
                UserId = "1",
                ClaimType = "email_verified",
                ClaimValue = true.ToString()
            },
            new IdentityUserClaim<string>
            {
                Id = 12,
                UserId = "2",
                ClaimType = "email_verified",
                ClaimValue = true.ToString()
            },
            new IdentityUserClaim<string>
            {
                Id = 13,
                UserId = "1",
                ClaimType = "address",
                ClaimValue = @"{ 'street_address': 'One Hacker Way', 'locality': 'Heidelberg', 'postal_code': 69118, 'country': 'Germany' }"
            },
            new IdentityUserClaim<string>
            {
                Id = 14,
                UserId = "2",
                ClaimType = "address",
                ClaimValue = @"{ 'street_address': 'One Hacker Way', 'locality': 'Heidelberg', 'postal_code': 69118, 'country': 'Germany' }"
            },
            new IdentityUserClaim<string>
            {
                Id = 15,
                UserId = "1",
                ClaimType = "location",
                ClaimValue = "somewhere"
            });
}

 

After this we need to call “UserSeed” method in “OnModelCreating” like so:

protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
//seed data
UsersSeed(builder);
}

and also we need to add migration:

Add-Migration SeedIdentityDbMigration -c IdentityDbContext -o Data/Migrations/AspNetIdentity/AspNetIdentityDb

 

For Client data seed we need to create our own class that will inherit from IdentityServer4 “ConfigurationDbContext” class. Navigate to “Data” folder and add new class called “ConfigurationDbContext” like :

using IdentityModel;
using IdentityServer4.EntityFramework.Entities;
using IdentityServer4.EntityFramework.Extensions;
using IdentityServer4.EntityFramework.Options;
using Microsoft.EntityFrameworkCore;
using System;

namespace IdentityServer.Data
{
    public class ConfigurationDbContext : IdentityServer4.EntityFramework.DbContexts.ConfigurationDbContext<ConfigurationDbContext>    
    {
        private readonly ConfigurationStoreOptions _storeOptions;

        public ConfigurationDbContext(DbContextOptions<ConfigurationDbContext> options, ConfigurationStoreOptions storeOptions) : base(options, storeOptions)
        {
            _storeOptions = storeOptions;
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.ConfigureClientContext(_storeOptions);
            modelBuilder.ConfigureResourcesContext(_storeOptions);

            base.OnModelCreating(modelBuilder);
        }
    }
}

After this we need to register our “ConfigurationDbContext” in “Startup.cs”. Add this below “IdentityDbContext” registration :

services.AddDbContext<Data.ConfigurationDbContext>(options => options.UseSqlServer(connectionString, sql => sql.MigrationsAssembly(migrationsAssembly)));

 

Because we will use our child class “ConfigurationDbContext” instead of parent one coming from IdentityServer4 directly we need to drop the existing local database “IdentityServerQuickstart.NetCore3.1” to avoid possible table collisions. We also need to navigate to “Data/Migrations/IdentityServer” and delete “ConfigurationDb” folder.  Next step is to add new configuration migration using our “ConfigurationDbContext” class like so:

 Add-Migration InitialConfigurationMigration -c IdentityServer.Data.ConfigurationDbContext -o Data/Migrations/IdentityServer/ConfigurationDb

Next step is to add “ClientSeed” method. In “ConfigurationDbContext.cs” file right below “OnModelCreating” method add new method “ClientSeed” like:

private void ClientSeed(ModelBuilder builder)
{
    builder.Entity<ApiResource>()
        .HasData(
            new ApiResource
            {
                Id = 1,
                Name = "web_api",
                DisplayName = "My Web API"
            }
        );

    builder.Entity<ApiScope>()
        .HasData(
            new ApiScope
            {
                Id = 1,
                Name = "web_api",
                DisplayName = "web_api",
                Description = null,
                Required = false,
                Emphasize = false,
                ShowInDiscoveryDocument = true,
                ApiResourceId = 1
            }
        );

    builder.Entity<IdentityResource>().HasData
        (
            new IdentityResource()
            {
                Id = 1,
                Enabled = true,
                Name = "openid",
                DisplayName = "Your user identifier",
                Description = null,
                Required = true,
                Emphasize = false,
                ShowInDiscoveryDocument = true,
                Created = DateTime.UtcNow,
                Updated = null,
                NonEditable = false
            },
            new IdentityResource()
            {
                Id = 2,
                Enabled = true,
                Name = "profile",
                DisplayName = "User profile",
                Description = "Your user profile information (first name, last name, etc.)",
                Required = false,
                Emphasize = true,
                ShowInDiscoveryDocument = true,
                Created = DateTime.UtcNow,
                Updated = null,
                NonEditable = false
            });

    builder.Entity<IdentityClaim>()
        .HasData(
            new IdentityClaim
            {
                Id = 1,
                IdentityResourceId = 1,
                Type = "sub"
            },
            new IdentityClaim
            {
                Id = 2,
                IdentityResourceId = 2,
                Type = "email"
            },
            new IdentityClaim
            {
                Id = 3,
                IdentityResourceId = 2,
                Type = "website"
            },
            new IdentityClaim
            {
                Id = 4,
                IdentityResourceId = 2,
                Type = "given_name"
            },
            new IdentityClaim
            {
                Id = 5,
                IdentityResourceId = 2,
                Type = "family_name"
            },
            new IdentityClaim
            {
                Id = 6,
                IdentityResourceId = 2,
                Type = "name"
            });

    builder.Entity<Client>()
        .HasData(
            new Client
            {
                Id = 1,
                Enabled = true,
                ClientId = "client",
                ProtocolType = "oidc",
                RequireClientSecret = true,
                RequireConsent = true,
                ClientName = null,
                Description = null,
                AllowRememberConsent = true,
                AlwaysIncludeUserClaimsInIdToken = false,
                RequirePkce = false,
                AllowAccessTokensViaBrowser = false,
                AllowOfflineAccess = false
            },
            new Client
            {
                Id = 2,
                Enabled = true,
                ClientId = "ro.client",
                ProtocolType = "oidc",
                RequireClientSecret = true,
                RequireConsent = true,
                ClientName = null,
                Description = null,
                AllowRememberConsent = true,
                AlwaysIncludeUserClaimsInIdToken = false,
                RequirePkce = false,
                AllowAccessTokensViaBrowser = false,
                AllowOfflineAccess = false
            },
            new Client
            {
                Id = 3,
                Enabled = true,
                ClientId = "mvc",
                ProtocolType = "oidc",
                RequireClientSecret = true,
                RequireConsent = true,
                ClientName = "MVC Client",
                Description = null,
                AllowRememberConsent = true,
                AlwaysIncludeUserClaimsInIdToken = false,
                RequirePkce = false,
                AllowAccessTokensViaBrowser = false,
                AllowOfflineAccess = true
            },
            new Client
            {
                Id = 4,
                Enabled = true,
                ClientId = "js",
                ProtocolType = "oidc",
                RequireClientSecret = false,
                RequireConsent = true,
                ClientName = "JavaScript client",
                Description = null,
                AllowRememberConsent = true,
                AlwaysIncludeUserClaimsInIdToken = false,
                RequirePkce = true,
                AllowAccessTokensViaBrowser = false,
                AllowOfflineAccess = false
            });

    builder.Entity<ClientGrantType>()
        .HasData(
            new ClientGrantType
            {
                Id = 1,
                GrantType = "client_credentials",
                ClientId = 1
            },
            new ClientGrantType
            {
                Id = 2,
                GrantType = "password",
                ClientId = 2
            },
            new ClientGrantType
            {
                Id = 3,
                GrantType = "hybrid",
                ClientId = 3
            },
            new ClientGrantType
            {
                Id = 4,
                GrantType = "authorization_code",
                ClientId = 4
            });

    builder.Entity<ClientScope>()
        .HasData(
            new ClientScope
            {
                Id = 1,
                Scope = "profile",
                ClientId = 3
            },
            new ClientScope
            {
                Id = 2,
                Scope = "profile",
                ClientId = 4
            },
            new ClientScope
            {
                Id = 3,
                Scope = "openid",
                ClientId = 3
            },
            new ClientScope
            {
                Id = 4,
                Scope = "openid",
                ClientId = 4
            },
            new ClientScope
            {
                Id = 5,
                Scope = "web_api",
                ClientId = 1
            }
            ,
            new ClientScope
            {
                Id = 6,
                Scope = "web_api",
                ClientId = 2
            }
            ,
            new ClientScope
            {
                Id = 7,
                Scope = "web_api",
                ClientId = 3
            }
            ,
            new ClientScope
            {
                Id = 8,
                Scope = "web_api",
                ClientId = 4
            });

    builder.Entity<ClientSecret>()
        .HasData(
                new ClientSecret
                {
                    Id = 1,
                    Value = "secret".ToSha256(),
                    Type = "SharedSecret",
                    ClientId = 1
                },
                new ClientSecret
                {
                    Id = 2,
                    Value = "secret".ToSha256(),
                    Type = "SharedSecret",
                    ClientId = 2
                },
                new ClientSecret
                {
                    Id = 3,
                    Value = "secret".ToSha256(),
                    Type = "SharedSecret",
                    ClientId = 3
                });

    builder.Entity<ClientPostLogoutRedirectUri>()
        .HasData(
        new ClientPostLogoutRedirectUri
        {
            Id = 1,
            PostLogoutRedirectUri = "http://localhost:5002/signout-callback-oidc",
            ClientId = 3
        },
        new ClientPostLogoutRedirectUri
        {
            Id = 2,
            PostLogoutRedirectUri = "http://localhost:5003/index.html",
            ClientId = 4
        });

    builder.Entity<ClientRedirectUri>()
        .HasData(
        new ClientRedirectUri
        {
            Id = 1,
            RedirectUri = "http://localhost:5002/signin-oidc",
            ClientId = 3
        },
        new ClientRedirectUri
        {
            Id = 2,
            RedirectUri = "http://localhost:5003/callback.html",
            ClientId = 4
        });

    builder.Entity<ClientCorsOrigin>()
        .HasData(
        new ClientCorsOrigin
        {
            Id = 1,
            Origin = "http://localhost:5003",
            ClientId = 4
        });
}

and call “ClientSeed” method in “OnModelCreating” :

protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.ConfigureClientContext(_storeOptions);
            modelBuilder.ConfigureResourcesContext(_storeOptions);

            base.OnModelCreating(modelBuilder);

            ClientSeed(modelBuilder);
        }

We need one more step to finish our client seed.

Add-Migration SeedConfigurationMigration -c IdentityServer.Data.ConfigurationDbContext -o Data/Migrations/IdentityServer/ConfigurationDb

The database was dropped in previous steps so we need to re-create all tables and re-seed data for all db contexts

Update-Database -Context IdentityServer.Data.ConfigurationDbContext
Update-Database -Context PersistedGrantDbContext
Update-Database -Context IdentityDbContext

Run the application to log in

Run the IdentityServer4 and navigate to http://localhost:5000/Account/Login

You should be able to login now with user “alice” and password “My long 123$ password”. After login, you should be redirected back to the IdentityServer4 home page with the name “Alice Smith” displayed in the upper left corner.

Explore the database

I encourage you to take a look at the “IdentityServerQuickstart.NetCore3.1” database. You will see it is re-created will all the tables, migrations and data. Explore the data in tables and compare it to data in “Users.cs” and “Config.cs” in the “Data/Seed” folder to learn how and where the data is stored. In the next tutorial we will take a look at the custom properties we can add to extend the “User” model.

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.

04. PART 2 IdentityServer4 ASP.NET Core Identity .NET Core 3.1

You can find the project here.

If you got up to this point I congratulate you for being persistent. Most of the “hard” steps are pretty much done. We just need to create database migrations for ASP.NET Core Identity to create the database tables. I will help you to understand new identity tables, similar as we did with IdentityServer4 tables. In the next tutorial we will start with adding custom properties to the user to start extending the functionality.

Database code-first migration

In Visual Studio open Package Manager Console. First we need to add the migration for ASP.NET Core Identity database context (IdentityDbContext). Like so

Add-Migration InitialIdentityDbMigration -c IdentityDbContext -o Data/Migrations/AspNetIdentity/AspNetIdentityDb

This will add database migration “InitialIdentityDbMigration” to “Data/Migrations/AspNetIdentity” folder right next to the IdentityServer4 migrations for configuration and persisted grants. Let’s update the database structure with ASP.NET Core Identity tables. In Package Manager Console execute update database command like so

Update-Database -Context IdentityDbContext

That’s it! We previously successfully migrated all temporary in-memory configuration to the database, now we also migrated the user store. Wow. Take a moment and relax now. Moment gone. Let’s see new tables we got to play with.

PS. Feel free to delete the “ScaffoldingReadme.txt” file from the project root. This readme file was automatically added when we did the ASP.NET Core Identity scaffolding.

ASP.NET Core Identity tables

I used the MSSQL database in this example but it’s pretty much the same for PostgreSQL. Here is the list of tables that we have in the “IdentityServerQuickstart” database. Seven tables that start with the “AspNet” prefix are the ASP.NET Core Identity tables that hold user store (users, claims, roles, logins, and user tokens).

Let’s see the relationship between ASP.NET Core Identity tables in a diagram

  • “dbo.AspNetRoleClaims” table is holding claims assigned to a specific role.
  • “dbo.AspNetRoles” table is holding a list of roles. It is a lookup table of all possible roles that exist and can be assigned to a user.
  • “dbo.AspNetUserClaims” table is holding claims assigned to a user. A claim is different from a role because a claim is a key-value pair. You can have a role or not have a role. Claim also provides a value for a specified claim. In a way, it is like an optional property assigned to a user.
  • “dbo.AspNetUserLogins” table is connecting external users to local users. All users specified in “dbo.AspNetUsers” table are local users. Say you want to login with Google and you want to link your Google account with your local account. This table holds that link so once you are linked you don’t have to go through the linking process again.
  • “dbo.AspNetUserRoles” table is a many-to-many relationship table that connects users with assigned roles.
  • “dbo.AspNetUsers” table is holding users. All of the user properties like username, email, password are stored here. We can also add custom user properties here to extend the user.
  • “dbo.AspNetUserTokens” table is holding external authentication tokens. This table is also used for keeping TOTP authenticator keys and recovery codes for user.

Recap

We added migration for ASP.NET Core Identity, updated the database with new tables and learned about each table. I explained the rest of the tables (the non “AspNet” prefix tables) in my previous tutorial.

In my next tutorial we will start adding custom attributes to the user. “Hard” stuff is pretty much over, we are now off to customization and adding new features.

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.

04. PART 1 IdentityServer4 ASP.NET Core Identity .NET Core 3.1

You can find the project here.

What is ASP.NET Core Identity

The official explanation from Microsoft docs is: “ASP.NET Core Identity is a membership system that adds login functionality to ASP.NET Core apps. Users can create an account with the login information stored in Identity or they can use an external login provider.” and “Identity can be configured using a SQL Server database to store user names, passwords, and profile data.”.

So it is a membership system that takes care of members (another word for users) and it is used by ASP.NET Core apps (what we are building). Users can create local accounts stored in Identity (another name for user store) or can use any external provider like Google, Okta, Microsoft, Facebook, etc. User data can be persisted to a standard SQL database (which we already have). ASP.NET Identity standardizes user store with structure (tables) and methods to manipulate the store. For example to create a new user account, just invoke a method. It will validate the data and store it in a database. To log in, just invoke a login method, it will validate the password (for a local login) and return a valid response.

To set or change the password, just invoke a method and the password will get hashed and stored in the database. So a user store is a set of business logic contained in the methods that operate on data storage. The data structure of user tables is standardized so one day when ASP.NET Core is updated it will be easy to reuse existing tables or run a migration to another storage if needed.

This is opposite to a custom user store where the data structure is custom so manual migration must be performed. Sometimes migration is not even possible because the password hashing algorithm used in the old and new store is different and there is no way to extract the plain text password from hashes and re-hash it. Business logic contained in methods for the ASP.NET Identity user store is tested, security standards are validated and are pretty much stable and secure as you can get it. It doesn’t cover all scenarios but it’s really easy to extend it as shown in one of my next tutorials where we will add a custom property to a user.

 

Why do we even need ASP.NET Core Identity?

We need it because IdentityServer4 doesn’t care about the users. It handles token generation, token endpoints, discovery endpoint, OAuth2 and OIDC protocols, clients, scopes, all the important bits except for the users. In order to get our Identity Server to start caring about the users (local and external), we should provide it with a user store.

ASP.NET Identity is a good match as it’s a mature system for user management that is used by all ASP.NET applications, .net core and .net framework. You can use ASP.NET Core Identity without IdentityServer4 to authenticate single application but you lose the ability to create an Identity Provider (IdP) which is a whole point of these tutorials to have an SSO provider for all apps and not redo the auth for each app individually.

 

Code changes in order to implement ASP.NET Core Identity

Note: I will continue on the previous “02. IdentityServer4 EntityFramework” tutorial using the MSSQL server. For good people following the PostgreSQL tutorial all the steps that will be laid out in this tutorial are pretty much the same except you will use “UseNpgsql” instead of “UseSqlServer” extension method.

Note 2: When we start doing scaffolding some CSS styles are going to be overwritten that will make IdentityServer4 look a bit weird and out of place. I suggest keeping a copy of “wwwroot/css” folder so you can overwrite the changes later on. Specific files that we need are “site.less”, “site.css” and “site.min.css”.

 

Let’s start by adding a NuGet package for IdentityServer4 ASP.NET Core Identity support. The package name is “IdentityServer4.AspNetIdentity”.

 

Now we will add the ApplicationUser class that will inherit from the IdentityUser class. The reason for this is to be able to extend IdentityUser in the future (add additional properties to the user entity). In future tutorials, I will add an example of how to add custom user properties. For now, we will just create a new folder called “Models” and add “ApplicationUser.cs” file to hold the class definition like so

using Microsoft.AspNetCore.Identity;

namespace IdentityServer.Models
{
	public class ApplicationUser : IdentityUser
	{
	
	}
}

 

The next step will scaffold MVC controllers and views for ASP.NET Identity. This step is not mandatory as the same functionality can be obtained by just using the NuGet package for ASP.NET Core Identity (“Microsoft.AspNetCore.Identity”). The difference is that scaffolding these resources in our project directly will allow for easy modification of the look and feel of ASP.NET Core Identity.

 

In Solution Explorer right-click on “Identity Server” project → Add → New Scaffolded Item

 

The “Add Scaffold” dialog should pop-up, select “Identity” and click “Add”

 

The “Add Identity” dialog will pop-up. Select an existing Layout.cshtml file, in our project the location is “~/Views/Shared/_Layout.cshtml”. Click on “Override all files” checkbox to select all files and than uncheck “Account\ConfirmEmailChange” (have error and will not scafold item with that). We need to create a new data context class, let’s call it “IdentityServer.Models.IdentityDbContext”. We will also specify the user class, so let’s use the “ApplicationUser” we added in the previous step.

 

Wait a bit for Visual Studio to do its magic and you should see a new folder called “Areas” containing all juicy ASP.NET Core views and controllers for us to play with.

 

Let’s just reorganize a bit for easier maintenance in the future. Move (drag and drop) “IdentityDbContext.cs” file from “Areas/Identity/Data” into the “Data” folder in the project root. You can now delete the “Areas/Identity/Data” folder.

We will also simplify lambda expression in “Areas/Identity/IdentityHostingStartup.cs” as we will move that configuration into our project’s Startup in the next step. Better to keep all config as visible as possible and in one place. So open up “Areas/Identity/IdentityHostingStartup.cs” and leave only the Configure method like so

using Microsoft.AspNetCore.Hosting;
 
[assembly: HostingStartup(typeof(IdentityServer.Areas.Identity.IdentityHostingStartup))]

namespace IdentityServer.Areas.Identity
{
	public class IdentityHostingStartup : IHostingStartup
	{
		public void Configure(IWebHostBuilder builder)
		{
			builder.ConfigureServices((context, services) => {
			
			});
		}
	}
}

 

Last thing to remove is “Quickstart/TestUsers.cs” file that contains TestUsers class that initializes dummy users. We don’t need it anymore as when we finish all code changes the users will come from user store. Feel free to delete the “Quickstart/TestUsers.cs” file now.

Refactoring time. Open “Startup.cs” to add missing bits for ASP.NET Core Identity.

Add missing “using” directives

using IdentityServer.Models;
using Microsoft.AspNetCore.Identity;

 

Add code snippet for IdentityDbContext and ASP.NET Core Identity service (add it above the IdentityServer service configuration, above the “AddIdentityServer” extension method)

services.AddDbContext<IdentityDbContext>(options =>
	options.UseSqlServer(connectionString, sql => sql.MigrationsAssembly(migrationsAssembly))
);

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

 

Last thing to add in Startup is to add ASP.NET Core Identity to IdentityService service configuration (after the “AddOperationStore”)

.AddAspNetIdentity<ApplicationUser>();

 

This is the complete ConfigureServices method body with ASP.NET Core Identity changes from above

public void ConfigureServices(IServiceCollection services)
{
    string connectionString = Configuration.GetConnectionString("DefaultConnection");
    var migrationsAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;

    // uncomment, if you want to add an MVC-based UI
    services.AddControllersWithViews();

    services.AddDbContext<IdentityDbContext>(options => options.UseSqlServer(connectionString, sql => sql.MigrationsAssembly(migrationsAssembly)));
    services.AddIdentity<ApplicationUser, IdentityRole>(options =>
    {
        options.SignIn.RequireConfirmedEmail = true;
    })
        .AddEntityFrameworkStores<IdentityDbContext>()
        .AddDefaultTokenProviders();

    var builder = services.AddIdentityServer(options =>
    {
        options.Events.RaiseErrorEvents = true;
        options.Events.RaiseInformationEvents = true;
        options.Events.RaiseFailureEvents = true;
        options.Events.RaiseSuccessEvents = true;
        options.UserInteraction.LoginUrl = "/Account/Login";
        options.UserInteraction.LogoutUrl = "/Account/Logout";
        options.Authentication = new AuthenticationOptions()
        {
            CookieLifetime = TimeSpan.FromHours(10), // ID server cookie timeout set to 10 hours
            CookieSlidingExpiration = true
        };
    })
    .AddConfigurationStore(options =>
    {
        options.ConfigureDbContext = b => b.UseSqlServer(connectionString, sql => sql.MigrationsAssembly(migrationsAssembly));
    })
    .AddOperationalStore(options =>
    {
        options.ConfigureDbContext = b => b.UseSqlServer(connectionString, sql => sql.MigrationsAssembly(migrationsAssembly));
        options.EnableTokenCleanup = true;
    })
    .AddAspNetIdentity<ApplicationUser>();

    // not recommended for production - you need to store your key material somewhere secure
    builder.AddDeveloperSigningCredential();
}

 

If you try to build the solution now you will get two errors one in “AccountController” and another in “ExternalController”. This is because we removed the “TestUsers” class and we need to update local login and external login controllers to use the ASP.NET Core Identity user store.

“Quickstart/Account/AccountController.cs” changes

Remove obsolete “using” directive

using IdentityServer4.Test;

 

Add missing “using” directives

using IdentityServer.Models;
using Microsoft.AspNetCore.Identity;

 

Remove obsolete variable

private readonly TestUserStore _users;

 

Add private variables to hold injected references of UserManager and SignInManager

private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;

 

Replace the constructor with code below

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

 

Replace second “Login” method with code below. This change will remove code that used the “TestUsers” class and use UserManager and SignInManager instead.

public async Task<IActionResult> Login(LoginInputModel model, string button)
{
    // check if we are in the context of an authorization request
    var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl);

    // the user clicked the "cancel" button
    if (button != "login")
    {
        if (context != null)
        {
            // if the user cancels, send a result back into IdentityServer as if they 
            // denied the consent (even if this client does not require consent).
            // this will send back an access denied OIDC error response to the client.
            await _interaction.GrantConsentAsync(context, ConsentResponse.Denied);

            // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
            if (await _clientStore.IsPkceClientAsync(context.ClientId))
            {
                // if the client is PKCE then we assume it's native, so this change in how to
                // return the response is for better UX for the end user.
                return View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl });
            }

            return Redirect(model.ReturnUrl);
        }
        else
        {
            // since we don't have a valid context, then we just go back to the home page
            return Redirect("~/");
        }
    }

    if (ModelState.IsValid)
    {
        var result = await _signInManager.PasswordSignInAsync(model.Username, model.Password, model.RememberLogin, lockoutOnFailure: true);
        if (result.Succeeded)
        {
            var user = await _userManager.FindByNameAsync(model.Username);
            await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id, user.UserName));

            if (context != null)
            {
                if (await _clientStore.IsPkceClientAsync(context.ClientId))
                {
                    // if the client is PKCE then we assume it's native, so this change in how to
                    // return the response is for better UX for the end user.
                    return View("Redirect", new RedirectViewModel { RedirectUrl = model.ReturnUrl });
                }

                // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
                return Redirect(model.ReturnUrl);
            }

            // request for a local page
            if (Url.IsLocalUrl(model.ReturnUrl))
            {
                return Redirect(model.ReturnUrl);
            }
            else if (string.IsNullOrEmpty(model.ReturnUrl))
            {
                return Redirect("~/");
            }
            else
            {
                // user might have clicked on a malicious link - should be logged
                throw new Exception("invalid return URL");
            }
        }

        await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials"));
        ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage);
    }

    // something went wrong, show form with error
    var vm = await BuildLoginViewModelAsync(model);
    return View(vm);
}

 

Replace the second “Logout” method with code below. This change will use SignInManager for logout.

public async Task<IActionResult> Logout(LogoutInputModel model)
{
    // build a model so the logged out page knows what to display
    var vm = await BuildLoggedOutViewModelAsync(model.LogoutId);

    if (User?.Identity.IsAuthenticated == true)
    {
        // delete local authentication cookie
        await _signInManager.SignOutAsync();

        // raise the logout event
        await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName()));
    }

    // check if we need to trigger sign-out at an upstream identity provider
    if (vm.TriggerExternalSignout)
    {
        // build a return URL so the upstream provider will redirect back
        // to us after the user has logged out. this allows us to then
        // complete our single sign-out processing.
        string url = Url.Action("Logout", new { logoutId = vm.LogoutId });

        // this triggers a redirect to the external provider for sign-out
        return SignOut(new AuthenticationProperties { RedirectUri = url }, vm.ExternalAuthenticationScheme);
    }

    return View("LoggedOut", vm);
}

 

Last change in AccountController is to replace the “BuildLoginViewModelAsync” with the code below.

private async Task<LoginViewModel> BuildLoginViewModelAsync(string returnUrl)
{
	var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
	if (context?.IdP != null)
	{
		// this is meant to short circuit the UI and only trigger the one external IdP
		return new LoginViewModel
		{
			EnableLocalLogin = false,
			ReturnUrl = returnUrl,
			Username = context?.LoginHint,
			ExternalProviders = new ExternalProvider[] { new ExternalProvider { AuthenticationScheme = context.IdP } }
		};
	}

	var schemes = await _schemeProvider.GetAllSchemesAsync();

	var providers = schemes
		.Where(x => x.DisplayName != null ||
					(x.Name.Equals(AccountOptions.WindowsAuthenticationSchemeName, StringComparison.OrdinalIgnoreCase))
		)
		.Select(x => new ExternalProvider
		{
			DisplayName = x.DisplayName,
			AuthenticationScheme = x.Name
		}).ToList();

	var allowLocal = true;
	if (context?.ClientId != null)
	{
		var client = await _clientStore.FindEnabledClientByIdAsync(context.ClientId);
		if (client != null)
		{
			allowLocal = client.EnableLocalLogin;

			if (client.IdentityProviderRestrictions != null && client.IdentityProviderRestrictions.Any())
			{
				providers = providers.Where(provider => client.IdentityProviderRestrictions.Contains(provider.AuthenticationScheme)).ToList();
			}
		}
	}

	return new LoginViewModel
	{
		AllowRememberLogin = AccountOptions.AllowRememberLogin,
		EnableLocalLogin = allowLocal && AccountOptions.AllowLocalLogin,
		ReturnUrl = returnUrl,
		Username = context?.LoginHint,
		ExternalProviders = providers.ToArray()
	};
}

 

AccountController is done. This will take care of the local login to use the ASP.NET Core Identity user store. Let’s work on the ExternalController now. An external controller handles external logins (from Okta, Google, Azure AD, etc.)

“Quickstart/Account/ExternalController.cs” changes

Remove obsolete “using” directive

using IdentityServer4.Test;

 

Add missing “using” directives

using IdentityServer.Models;
using Microsoft.AspNetCore.Identity;

 

Remove obsolete variable

private readonly TestUserStore _users;

 

Add private variables to hold injected references of UserManager and SignInManager

private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;

 

Replace the constructor with code below

public ExternalController(
	UserManager<ApplicationUser> userManager,
	SignInManager<ApplicationUser> signInManager,
	IIdentityServerInteractionService interaction,
	IClientStore clientStore,
	IEventService events)
{
	_userManager = userManager;
	_signInManager = signInManager;
	_interaction = interaction;
	_clientStore = clientStore;
	_events = events;
}

 

Replace the “Callback” method with code below. This will help us split up functionality across several methods.

public async Task<IActionResult> Callback()
{
	// read external identity from the temporary cookie
	var result = await HttpContext.AuthenticateAsync(IdentityConstants.ExternalScheme);
	if (result?.Succeeded != true)
	{
		throw new Exception("External authentication error");
	}

	// lookup our user and external provider info
	var (user, provider, providerUserId, claims) = await FindUserFromExternalProviderAsync(result);
	if (user == null)
	{
		// this might be where you might initiate a custom workflow for user registration
		// in this sample we don't show how that would be done, as our sample implementation
		// simply auto-provisions new external user
		user = await AutoProvisionUserAsync(provider, providerUserId, claims);
	}

	// this allows us to collect any additonal claims or properties
	// for the specific prtotocols used and store them in the local auth cookie.
	// this is typically used to store data needed for signout from those protocols.
	var additionalLocalClaims = new List<Claim>();
	var localSignInProps = new AuthenticationProperties();
	ProcessLoginCallbackForOidc(result, additionalLocalClaims, localSignInProps);
	ProcessLoginCallbackForWsFed(result, additionalLocalClaims, localSignInProps);
	ProcessLoginCallbackForSaml2p(result, additionalLocalClaims, localSignInProps);

	// issue authentication cookie for user
	// we must issue the cookie maually, and can't use the SignInManager because
	// it doesn't expose an API to issue additional claims from the login workflow
	var principal = await _signInManager.CreateUserPrincipalAsync(user);
	additionalLocalClaims.AddRange(principal.Claims);
	var name = principal.FindFirst(JwtClaimTypes.Name)?.Value ?? user.Id;
	await _events.RaiseAsync(new UserLoginSuccessEvent(provider, providerUserId, user.Id, name));
	await HttpContext.SignInAsync(user.Id, name, provider, localSignInProps, additionalLocalClaims.ToArray());

	// delete temporary cookie used during external authentication
	await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);

	// validate return URL and redirect back to authorization endpoint or a local page
	var returnUrl = result.Properties.Items["returnUrl"];
	if (_interaction.IsValidReturnUrl(returnUrl) || Url.IsLocalUrl(returnUrl))
	{
		return Redirect(returnUrl);
	}

	return Redirect("~/");
}

 

Replace the “FindUserFromExternalProviderAsync” method with code below. It will add couple more ways to match local user with the external user using different claims like name, email, preferred name.

private async Task<(ApplicationUser user, string provider, string providerUserId, IEnumerable<Claim> claims)>
	FindUserFromExternalProviderAsync(AuthenticateResult result)
{
	var externalUser = result.Principal;

	// try to determine the unique id of the external user (issued by the provider)
	// the most common claim type for that are the sub claim and the NameIdentifier
	// depending on the external provider, some other claim type might be used
	var userIdClaim = externalUser.FindFirst(JwtClaimTypes.Subject) ??
					  externalUser.FindFirst(ClaimTypes.NameIdentifier) ??
					  throw new Exception("Unknown userid");

	// remove the user id claim so we don't include it as an extra claim if/when we provision the user
	var claims = externalUser.Claims.ToList();
	claims.Remove(userIdClaim);

	var provider = result.Properties.Items["scheme"];
	var providerUserId = userIdClaim.Value;

	// find external user
	var user = await _userManager.FindByLoginAsync(provider, providerUserId);

	// try to find user by name and/or email
	if (user == null)
	{
		var name = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Name)?.Value ?? claims.FirstOrDefault(x => x.Type == ClaimTypes.Name)?.Value;
		if (name != null)
		{
			user = await _userManager.FindByNameAsync(name);
		}
		if (user == null)
		{
			var prefname = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.PreferredUserName)?.Value;
			if (prefname != null)
			{
				user = await _userManager.FindByNameAsync(prefname);
			}
		}
		if (user == null)
		{
			var email = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Email)?.Value ?? claims.FirstOrDefault(x => x.Type == ClaimTypes.Email)?.Value;
			if (email != null)
			{
				user = await _userManager.FindByEmailAsync(email);
			}
		}
		if (user != null)
		{
			var identityResult = await _userManager.AddLoginAsync(user, new UserLoginInfo(provider, providerUserId, provider));
			if (!identityResult.Succeeded) throw new Exception(identityResult.Errors.First().Description);
		}
	}

	return (user, provider, providerUserId, claims);
}

 

Finally, the last change is to replace the “AutoProvisionUser” method with the code below. This method is in charge of creating a new local user for the external user login if the local user couldn’t be found in the local user store.

private async Task<ApplicationUser> AutoProvisionUserAsync(string provider, string providerUserId, IEnumerable<Claim> claims)
{
	// create a list of claims that we want to transfer into our store
	var filtered = new List<Claim>();

	// user's display name
	var name = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Name)?.Value ??
		claims.FirstOrDefault(x => x.Type == ClaimTypes.Name)?.Value;
	if (name != null)
	{
		filtered.Add(new Claim(JwtClaimTypes.Name, name));
	}
	else
	{
		var first = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.GivenName)?.Value ??
			claims.FirstOrDefault(x => x.Type == ClaimTypes.GivenName)?.Value;
		var last = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.FamilyName)?.Value ??
			claims.FirstOrDefault(x => x.Type == ClaimTypes.Surname)?.Value;
		if (first != null && last != null)
		{
			filtered.Add(new Claim(JwtClaimTypes.Name, first + " " + last));
		}
		else if (first != null)
		{
			filtered.Add(new Claim(JwtClaimTypes.Name, first));
		}
		else if (last != null)
		{
			filtered.Add(new Claim(JwtClaimTypes.Name, last));
		}
	}

	// email
	var email = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.Email)?.Value ??
	   claims.FirstOrDefault(x => x.Type == ClaimTypes.Email)?.Value;
	if (email != null)
	{
		filtered.Add(new Claim(JwtClaimTypes.Email, email));
	}

	var user = new ApplicationUser
	{
		UserName = Guid.NewGuid().ToString(),
	};
	var identityResult = await _userManager.CreateAsync(user);
	if (!identityResult.Succeeded) throw new Exception(identityResult.Errors.First().Description);

	if (filtered.Any())
	{
		identityResult = await _userManager.AddClaimsAsync(user, filtered);
		if (!identityResult.Succeeded) throw new Exception(identityResult.Errors.First().Description);
	}

	identityResult = await _userManager.AddLoginAsync(user, new UserLoginInfo(provider, providerUserId, provider));
	if (!identityResult.Succeeded) throw new Exception(identityResult.Errors.First().Description);

	return user;
}

 

Phew. That was quite a load of work to do. ExternalController is done. The project should be able to build now as we removed all references to the “TestUsers” class.

 

More details

Let’s recap. So we removed the “TestUsers” class that contained hardcoded users. We added NuGet packages for ASP.NET Core Identity. We scaffolded views and controllers for ASP.NET Core Identity that will allow us to modify them in the future. They are responsible for a password reset, 2FA, user registration, etc so it’s a good thing to have them in solution ready for any modification needed. One important thing to notice is that we will not be using Login and Logout controller/view from ASP.NET Core Identity because we will use those provided by IdentityServer4. We actually specified this in Startup

 

options.UserInteraction.LoginUrl = "/Account/Login";
options.UserInteraction.LogoutUrl = "/Account/Logout";

 

After we scaffolded the ASP.NET Core Identity views and controllers we modified the AccountController and ExternalController for local login and external login to use ASP.NET Core instead of the hardcoded “TestUsers”. We added the service configuration needed for ASP.NET Core Identity in Startup so it will use the SQL server to store the users. In one of the future tutorials, I will show you how to extend the “ApplicationUser” and add custom properties to the user.

If you kept a copy of “wwwroot/css” folder you can now overwrite the files “site.less”, “site.css” and “site.min.css” with your backup copy to get them to the previous version. This will make IdentityServer4 look good (you might notice some weird header issues after scaffolding).

This was a PART 1 of ASP.NET Core Identity tutorial. In PART 2 we will create database migrations, run the migrations to create the database tables and explain each table, similar as we did for the IdentityServer4 but this time for ASP.NET Core Identity.

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.

03. IdentityServer4 EF With PostgreSQL .NET Core 3.1

You can find the project here.

Remove existing MSSQL migrations

If you followed my previous tutorial we were adding migrations for operational and configuration stores in MSSQL database. We will now say bye-bye to the MSSQL database and switch to PostgreSQL which is a really good open-source and free relational database engine. We will leave most of the code changes but need to remove the migrations as we will need to re-run the commands to create new migrations for PostgreSQL.

I strongly suggest following the tutorials in the order they were laid out as this is the third one and will much more sense after the first and second one. However, if you feel like to want to jump right in you can do so by grabbing the code from the previous tutorial here. Let’s start by deleting the “Data” folder and all its content from the project root folder which will effectively remove the migrations from the project.

PostgreSQL code changes

In order to connect to PostgreSQL, we need to add the PostgreSQL NuGet packages for Entity Framework. Open the NuGet Package Manager and search for “Npgsql.EntityFrameworkCore.PostgreSQL”. I used version 3.1.0. Install the package and it should automatically include all dependencies.

Now lets open the “Startup.cs” as we need to change some code in ConfigureServices method. We need to update database context options as “UseSqlServer” extension method is for MSSQL server. We need to replace it with the extension method from the installed PostgreSQL NuGet package.

        .AddConfigurationStore(options =>
	{
		options.ConfigureDbContext = b => b.UseNpgsql(connectionString, sql => sql.MigrationsAssembly(migrationsAssembly));
	})
	.AddOperationalStore(options =>
	{
		options.ConfigureDbContext = b => b.UseNpgsql(connectionString, sql => sql.MigrationsAssembly(migrationsAssembly));
		options.EnableTokenCleanup = true;
	});

Last code change we need to do is the connection string which is in “appsettings.json” file in the root folder of the project. Open it and modify the “DefaultConnection” value to your PostgreSQL configuration. I have a local PostgreSQL server instance on port default port “5432” with database name “IdentityServerQuickstart” so it would look something like this

{
	"ConnectionStrings": {
		"DefaultConnection": "Server=localhost;Port=5432;Uid=postgres;Pwd=spitfire123;Database=IdentityServerQuickstart"
	}
}

Migrations for PostgreSQL

Open the Package Manager Console in Visual Studio. You can easily do that by typing “package manager console” in Visual Studio search box at the top. Execute these two commands to create migrations for Operation store and Configuration store

Add-Migration InitialPersistedGrantDbMigration -c PersistedGrantDbContext -o Data/Migrations/IdentityServer/PersistedGrantDb
Add-Migration InitialConfigurationDbMigration -c ConfigurationDbContext -o Data/Migrations/IdentityServer/ConfigurationDb

Now we again have folders with structure “Data\Migrations\IdentityServer” holding the migrations for the Identity Server stores for the PostgreSQL database. Remember, we still didn’t migrate to the user store. We will do that in the future tutorial and the migration for user store will also go into the “Data\Migrations” folder but not under the “IdentityServer” folder.

Let’s update the database with the newly created migrations. Execute the commands in the Package Manager Console

Update-Database -Context PersistedGrantDbContext
Update-Database -Context ConfigurationDbContext

Congratulations! You just successfully migrated the IdentityServer4 stores to a PostgreSQL database.

Seed data

In the previous tutorial, I explained why we need seed data. It is not important at the moment and we will do it in one of the future tutorials. OpenID Connect protocol requires one default Identity Resource called “sub” or subject which is a unique identifier for the user. We will also add other commonly used resources.

Wrap up

In conclusion, it was really easy to switch from using the MSSQL database to an open-source and free solution aka PostgreSQL. Hold your pants as the next tutorial is all about migrating the users to the ASP.NET Identity user store. We are getting there!

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.

02. IdentityServer4 EntityFramework .NET Core 3.1

You can find the project here.

IdentityServer4 EntityFramework is the second post in my IdentityServer4 tutorial series. I highly recommend starting with IdentityServer4 Quickstart as it will make things much easier to follow. We will continue where we left of with the project created in the quickstart. You can find the quickstart project source code here.

Currently, our project is using in-memory storage for configuration data, operational data and user store. Let’s migrate everything but the user store (we will migrate that too but just not now) to permanent storage. The user store is not a feature of IdentityServer4. For IdentityServer4 we will migrate configuration store (client store, api and identity resource store, CORS policy store), operational store (persisted grants store for tokens, codes and consents) but for user store, we need to look elsewhere.

The most used user store in .Net world is ASP.NET Identity and we will use it in one of the future tutorials. We can utilize the EntityFramework code-first approach with migrations to create table structure automatically. All we need to do is provide it with an empty database and run migration commands. This example will use the local MSSQL database but I will show you how to use PostgreSQL in my next tutorial so you can keep everything open-source and free.

 

From in-memory to SQL

First things first. Open the “Quickstart” solution in Visual Studio. We need to install the required NuGet package. The easiest way is to right-click the “IdentityServer” project and click “Manage NuGet Packages” to open NuGet Package Manager. Click on the “Browse” tab and type in “IdentityServer4.EntityFramework”.

 

Click the “Install” button.

Tip: If you have issues installing the package try to update other packages first by clicking the “Updates” tab, delete the search query (“IdentityServer4.EntityFramework”) to see all packages, select all packages for update and click “Update”. Now go back to the “Browse” tab and repeat steps above to install “IdentityServer4.EntityFramework” package.

After installing “IdentityServer4.EntityFramework” we need to install two more packages. First is “Microsoft.EntityFrameworkCore.SqlServer”

 

and second package is “Microsoft.EntityFrameworkCore.Tools”

 

Once the packages are installed you can close the NuGet Package Manager and build the solution to make sure it builds before doing any other changes.
Now we can open the “Startup.cs” file (in the root folder of the project) and take a look at the “ConfigureServices” method. We will remove the service descriptors for IdentityServer4

var builder = services.AddIdentityServer()
                .AddInMemoryIdentityResources(Config.GetIdentityResources())
                .AddInMemoryApiResources(Config.GetApis())
                .AddInMemoryClients(Config.GetClients());

 

and replace it with IdentityServer4 service configuration that uses SQL server like so

string connectionString = Configuration.GetConnectionString("DefaultConnection");

var migrationsAssembly = typeof(Startup).GetTypeInfo().Assembly.GetName().Name;

var builder = services.AddIdentityServer(options =>
{
	options.Events.RaiseErrorEvents = true;
	options.Events.RaiseInformationEvents = true;
	options.Events.RaiseFailureEvents = true;
	options.Events.RaiseSuccessEvents = true;
	options.UserInteraction.LoginUrl = "/Account/Login";
	options.UserInteraction.LogoutUrl = "/Account/Logout";
	options.Authentication = new AuthenticationOptions()
	{
		CookieLifetime = TimeSpan.FromHours(10), // ID server cookie timeout set to 10 hours
		CookieSlidingExpiration = true
	};
})
.AddConfigurationStore(options =>
{
	options.ConfigureDbContext = b => b.UseSqlServer(connectionString, sql => sql.MigrationsAssembly(migrationsAssembly));
})
.AddOperationalStore(options =>
{
	options.ConfigureDbContext = b => b.UseSqlServer(connectionString, sql => sql.MigrationsAssembly(migrationsAssembly));
	options.EnableTokenCleanup = true;
});

 

There will be some errors so let’s add missing using directives at the start of the “Startup.cs” file like so

using System;
using System.Reflection;
using IdentityServer4.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;

 

We will also modify the constructor to accept the injected IConfiguration reference (and create a property to hold it) so we can access the database connection string

public IWebHostEnvironment Environment { get; }
public IConfiguration Configuration { get; }

public Startup(IWebHostEnvironment environment, IConfiguration configuration)
{
    Environment = environment;
    Configuration = configuration;
}

 

You might notice that we are expecting “DefaultConnection” connection string but we don’t even have an “appsettings.json” file created yet. So let’s add it by right-clicking the “IdentityServer” project → Add → New Item… and select “App Settings File”.

 

 

This will conveniently create an “appsettings.json” file in the root folder of our project with the “DefaultConnection” already specified. We need to change the database name to something that makes a bit more sense like so

{
	"ConnectionStrings": {
		"DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=IdentityServerQuickstart.NetCore3.1;Trusted_Connection=True;MultipleActiveResultSets=true"
	}
}


 

Tip: Try to connect to the local database manually to verify the connectivity. I prefer using MSSMS (Microsoft SQL Server Management Studio) but you can use any SQL client you want, even Visual Studio. We will use Windows Authentication to connect to the database, the same as our quickstart connection string.

 

 

After a successful login creates a new empty database called “IdentityServerQuickstart.NetCore3.1” as specified in the connection string.

 

 

Alright, we are now all set to add code-first migrations to the IdentityServer4 Quickstart project and let it create a database structure (tables) needed for the Operation store and Configuration store.

 

Migrations

Open the Package Manager Console in Visual Studio. You can easily do that by typing “package manager console” in Visual Studio search box at the top. Execute these two commands to create migrations for Operation store and Configuration store

Add-Migration InitialPersistedGrantDbMigration -c PersistedGrantDbContext -o Data/Migrations/IdentityServer/PersistedGrantDb
Add-Migration InitialConfigurationDbMigration -c ConfigurationDbContext -o Data/Migrations/IdentityServer/ConfigurationDb

 

Now we have new folders with structure “Data\Migrations\IdentityServer” holding the migrations for the Identity Server stores. Remember, we still didn’t migrate to the user store. We will do that in the future tutorial and the migration for user store will also go into the “Data\Migrations” folder but not under the “IdentityServer” folder.

 

 

The last step to create the tables is to update the database with the newly created migrations. Execute the commands in the Package Manager Console

Update-Database -Context PersistedGrantDbContext
Update-Database -Context ConfigurationDbContext

 

Congratulations! You just successfully migrated the IdentityServer4 stores to a database.

 

Tables

Let’s take a look at the tables and talk about each one a bit. We didn’t specify a custom schema so all tables are created in the default “dbo” schema. This can be easily updated later but is out of the scope of this tutorial.

 

 

  • “dbo.__EFMigrationsHistory” table is keeping track of the history of the code-first migrations and is not related to IdentityServer4.

 

  • “dbo.ApiClaims” table is holding claim types for user claims that will be included in the access token for a specific API resource.
  • “dbo.ApiProperties” table is holding additional custom key-value pairs related to the specific API resource.
  • “dbo.ApiResources” table is holding resources that represent APIs that need to be protected.
  • “dbo.ApiScopeClaims” table is defining which user claims will be included in the access token for a specified scope.
  • “dbo.ApiScopes” table is holding possible scopes for a specific API resource.
  • “dbo.ApiSecrets” table is holding API resource secrets used by the introspection endpoint (used when using access token as reference token as opposed to JWT)

 

  • “dbo.ClientClaims” table is holding additional claims that will be included in the access token for a specific client.
  • “dbo.ClientCorsOrigins” table is holding allowed origins for a specific client
  • “dbo.ClientGrantTypes” table is holding allowed grant types for a specific client. Grant types specify which flows and endpoints can be used for authentication and/or authorization.
  • “dbo.ClientIdPRestrictions” table specifies which external providers can be used for a specific client. If not specified the user will see all possible external providers on the login page but if the external provider is specified only those specified (whitelisted) will be available/visible to the user.
  • “dbo.ClientPostLogoutRedirectUris” table specifies which URIs are allowed for redirect after logout for a specific client.
  • “dbo.ClientProperties” table is holding additional custom key-value pairs related to the specific client.
  • “dbo.ClientRedirectUris” table specifies which URIs are allowed to redirect to after successful login for a specific client.
  • “dbo.Clients” table is holding clients. Clients in this context are actually applications (web, desktop, native, SPA, etc.)
  • “dbo.ClientScopes” table holds allowed scopes (identity and resource scopes) for a specific client.
  • “dbo.ClientSecrets” table is holding secrets for a specific client. Client Id and Client Secret is usually used for machine-to-machine authorization to obtain access token but it can be used with other flows too. The client secret is not used with apps that can’t keep a secret like native apps for example where PKCE is preferred.
  • “dbo.DeviceCodes” table is holding the (usually numeric) user codes issued for the device authorization
  • “dbo.IdentityClaims” table is holding claim types for the user claims that will be included in the access token for a specific identity resource.
  • “dbo.IdentityProperties” table is holding additional custom key-value pairs related to the specific identity resource.
  • “dbo.IdentityResources” table specifies user resources like user id, email, name, etc. Some of them are standard like “openid” which specifies the “sub” subject claim is required for OpenID Connect.
  • “dbo.PersistedGrants” table is holding permissions (grants) given to clients (apps) by the users

 

Required identity resource for OpenID Connect

In order for OpenID Connect to work at least one claim, the unique identifier claim aka subject claim “sub” must be provided in the access token. Usually, there are more identity resources specified like “name”, “profile”, “email” etc. In one of the next tutorials we will add data seeding to allow us to start playing with IdentityServer4. 

 

Wrap up

We can now remove the “Config.cs” file from the project root as that file was used for in-memory configuration of identity resources, API resources and clients, all now configurable from database.

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.