Requiring MFA for Admin Pages in an ASP.NET Core Identity application

This article shows how MFA could be forced on users to access sensitive pages within an ASP.NET Core Identity application. This could be useful for applications where different levels of access exist for the different identities. For example, users might be able to view the profile data using a password login, but an administrator would be required to use MFA to access the admin pages.

Code: https://github.com/damienbod/AspNetCoreHybridFlowWithApi

Blogs in this series

History

2020-12-11 Updated to .NET 5

Extending the Login with a MFA claim

The application is setup using ASP.NET Core with Identity and Razor Pages. In this demo, the SQL Server was replaced with SQLite, and the nuget packages were updated. The AddIdentity method is used instead of AddDefaultIdentity one, so we can add an IUserClaimsPrincipalFactory implementation to add claims to the identity after a successful login.

public void ConfigureServices(IServiceCollection services)
{
	services.AddDbContext<ApplicationDbContext>(options =>
		options.UseSqlite(
			Configuration.GetConnectionString("DefaultConnection")));

	//services.AddDefaultIdentity<IdentityUser>(
	//    options => options.SignIn.RequireConfirmedAccount = true)
	//    .AddEntityFrameworkStores<ApplicationDbContext>();

	services.AddIdentity<IdentityUser, IdentityRole>(
		options => options.SignIn.RequireConfirmedAccount = false)
	 .AddEntityFrameworkStores<ApplicationDbContext>()
	 .AddDefaultTokenProviders();

	services.AddSingleton<IEmailSender, EmailSender>();
	services.AddScoped<IUserClaimsPrincipalFactory<IdentityUser>, AdditionalUserClaimsPrincipalFactory>();

	services.AddAuthorization(options =>
	{
		options.AddPolicy("TwoFactorEnabled",
			x => x.RequireClaim("TwoFactorEnabled", "true" )
		) ;
	});

	services.AddRazorPages();
}

The AdditionalUserClaimsPrincipalFactory adds the TwoFactorEnabled claim to the user claims after a successful login. This is only added after a login. The value is read from the database. This is added here because the user should only access the higher protected view, if the identity has logged in with MFA. If the database view was read from the database directly instead of using the claim, it would be possible to access the view without MFA directly after activating the MFA.

using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;

namespace IdentityStandaloneMfa
{
    public class AdditionalUserClaimsPrincipalFactory : UserClaimsPrincipalFactory<IdentityUser, IdentityRole>
    {
        public AdditionalUserClaimsPrincipalFactory( 
            UserManager<IdentityUser> userManager,
            RoleManager<IdentityRole> roleManager, 
            IOptions<IdentityOptions> optionsAccessor) 
            : base(userManager, roleManager, optionsAccessor)
        {
        }

        public async override Task<ClaimsPrincipal> CreateAsync(IdentityUser user)
        {
            var principal = await base.CreateAsync(user);
            var identity = (ClaimsIdentity)principal.Identity;

            var claims = new List<Claim>();

            if (user.TwoFactorEnabled)
            {
                claims.Add(new Claim("TwoFactorEnabled", "true"));
            }
            else
            {
                claims.Add(new Claim("TwoFactorEnabled", "false")); ;
            }

            identity.AddClaims(claims);
            return principal;
        }
    }
}

Because we changed the Identity service setup in the Startup class, the layouts of the Identity need to be updated. Scaffold the Identity pages into the application. Define the layout in the Identity/Account/Manage/_Layout.cshtml file.

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

Also add the _layout for all the manage pages from the Identity Pages.

@{
    Layout = "_Layout.cshtml";
}

Validation the MFA requirement in the Admin Page

The admin Razor Page validates that the user has logged in using MFA. In the OnGet method, the Identity is used to access the user claims. The TwoFactorEnabled claim is checked for the value true. If the user has not this claim, the page will redirect to the Enable MFA page. This is possible because the user has logged in already, but without MFA.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace IdentityStandaloneMfa
{
    public class AdminModel : PageModel
    {
        public IActionResult OnGet()
        {
            var claimTwoFactorEnabled = User.Claims.FirstOrDefault(t => t.Type == "TwoFactorEnabled");

            if (claimTwoFactorEnabled != null && "true".Equals(claimTwoFactorEnabled.Value))
            {
                // You logged in with MFA, do the admin stuff
            }
            else
            {
                return Redirect("/Identity/Account/Manage/TwoFactorAuthentication");
            }

            return Page();
        }
    }
}

UI logic to show hide information about the user login

An Authorization policy was added in the startup which requires the TwoFactorEnabled claim with the value true.

services.AddAuthorization(options =>
{
	options.AddPolicy("TwoFactorEnabled",
		x => x.RequireClaim("TwoFactorEnabled", "true" )
	) ;
});

This policy can then be used in the _Layout view to show or hide the Admin menu with the warning.

@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Identity
@inject SignInManager<IdentityUser> SignInManager
@inject UserManager<IdentityUser> UserManager
@inject IAuthorizationService AuthorizationService

If the identity has logged using MFA, then the Admin menu will be displayed without the warning. If the user has logged without the MFA, the font awesome icon will be displayed, and the tooltip which informs the user, explaining the warning.

@if (SignInManager.IsSignedIn(User))
{
	@if ((AuthorizationService.AuthorizeAsync(User, "TwoFactorEnabled")).Result.Succeeded)
	{
		<li class="nav-item">
			<a class="nav-link text-dark" asp-area="" asp-page="/Admin">Admin</a>
		</li>
	}
	else
	{
		<li class="nav-item">
			<a class="nav-link text-dark" asp-area="" asp-page="/Admin" 
			   id="tooltip-demo"  
			   data-toggle="tooltip" 
			   data-placement="bottom" 
			   title="MFA is NOT enabled. This is required for the Admin Page. If you have activated MFA, then logout, login again.">
				<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
				Admin
			</a>

		</li>
	}
}

If the user logins without MFA , then the warning is displayed.

And when the user clicks the admin link, then the user is redirected to the MFA enable view.

Links:

https://tools.ietf.org/html/draft-ietf-oauth-amr-values-04

https://openid.net/specs/openid-connect-core-1_0.html

https://hajekj.net/2017/03/06/forcing-reauthentication-with-azure-ad/

https://docs.microsoft.com/en-us/azure/active-directory/authentication/concept-mfa-howitworks

https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc

4 comments

  1. […] Requiring MFA for Admin Pages in an ASP.NET Core Identity application (Damien Bowden) […]

  2. Reblogged this on Neel Bhatt and commented:
    Nice article from Damien.

  3. […] Requiring MFA for Admin Pages in an ASP.NET Core Identity application – Damien Bowden […]

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.