Force ASP.NET Core OpenID Connect client to require MFA

This article shows how an ASP.NET Core Razor Page application which uses OpenID Connect to sign in, can require that users have authenticated using MFA (multi factor authentication).

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

Blogs in this series

History

2020-12-11 Updated to .NET 5

To validate the MFA requirement, an IAuthorizationRequirement requirement is created. This will be added to the pages using a policy which require MFA.

using Microsoft.AspNetCore.Authorization;
 
namespace AspNetCoreRequireMfaOidc
{
    public class RequireMfa : IAuthorizationRequirement{}
}

An AuthorizationHandler is implemented which will use the amr claim and check that it has the value mfa. The amr is returned in the id_token of a successful authentication and can have many different values as defined in the following specification:

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

The specification values can be implemented in a class as static strings.

namespace AspNetCoreRequireMfaOidc
{
    /// <summary>
    /// https://tools.ietf.org/html/draft-ietf-oauth-amr-values-04
    /// </summary>
    public static class Amr
    {
        /// <summary>
        /// Jones, et al.Expires May 17, 2017
        /// Internet-Draft Authentication Method Reference Values November 2016
        /// Facial recognition
        /// </summary>
        public static string Face = "face";

        /// <summary>
        /// Fingerprint biometric
        /// </summary>
        public static string Fpt = "fpt";

        /// <summary>
        /// Use of geolocation information
        /// </summary>
        public static string Geo = "geo";

        /// <summary>
        /// Proof-of-possession(PoP) of a hardware-secured key.See
        /// Appendix C of[RFC4211] for a discussion on PoP.
        /// </summary>
        public static string Hwk = "hwk";

        /// <summary>
        /// Iris scan biometric
        /// </summary>
        public static string Iris = "iris";

        /// <summary>
        /// Knowledge-based authentication [NIST.800-63-2] [ISO29115]
        /// </summary>
        public static string Kba = "kba";

        /// <summary>
        /// Multiple-channel authentication.  The authentication involves
        /// communication over more than one distinct communication channel.
        /// For instance, a multiple-channel authentication might involve both
        /// entering information into a workstation's browser and providing
        /// information on a telephone call to a pre-registered number.
        /// </summary>
        public static string Mca = "mca";

        /// <summary>
        /// Multiple-factor authentication [NIST.800-63-2]  [ISO29115].  When 
        /// this is present, specific authentication methods used may also be
        /// included.
        /// </summary>
        public static string Mfa = "mfa";

        /// <summary>
        /// One-time password.  One-time password specifications that this
        /// authentication method applies to include[RFC4226] and[RFC6238].
        /// </summary>
        public static string Otp = "otp";

        /// <summary>
        /// Personal Identification Number or pattern (not restricted to
        /// containing only numbers) that a user enters to unlock a key on the
        /// device.This mechanism should have a way to deter an attacker
        /// from obtaining the PIN by trying repeated guesses.
        /// </summary>
        public static string Pin = "pin";

        /// <summary>
        /// Password-based authentication
        /// </summary>
        public static string Pwd = "pwd";

        /// <summary>
        /// Risk-based authentication [JECM]
        /// </summary>
        public static string Rba = "rba";

        /// <summary>
        /// Retina scan biometric Jones, et al.Expires May 17, 2017
        /// Internet-Draft Authentication Method Reference Values November 2016
        /// </summary>
        public static string Retina = "retina";

        /// <summary>
        /// Smart card
        /// </summary>
        public static string Sc = "sc";

        /// <summary>
        /// Confirmation using SMS message to the user at a registered number
        /// </summary>
        public static string Sms = "sms";

        /// <summary>
        /// Proof-of-possession(PoP) of a software-secured key.See
        /// Appendix C of[RFC4211] for a discussion on PoP.
        /// </summary>
        public static string Swk = "swk";

        /// <summary>
        /// Confirmation by telephone call to the user at a registered number
        /// </summary>
        public static string Tel = "tel";

        /// <summary>
        /// User presence test
        /// </summary>
        public static string User = "user";

        /// <summary>
        /// Voice biometric
        /// </summary>
        public static string Vbm = "vbm";

        /// <summary>
        /// Windows integrated authentication, as described in [MSDN]
        /// </summary>
        public static string Wia = "wia";
    }
}

The AuthorizationHandler then uses the RequireMfa requirement and validates the amr claim. The OpenID Connect server is implemented using IdentityServer4 with ASP.NET Core Identity in this example. When the user logs in using OTP, ie one time passwords, the amr claim is returned with a mfa value. If using a different OpenID Connect server implementation, or a different MFA type, then the amr claim will, or can have a different value, and the code would need to be extended to accept this as well.

using Microsoft.AspNetCore.Authorization;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace AspNetCoreRequireMfaOidc
{
    public class RequireMfaHandler : AuthorizationHandler<RequireMfa>
    {

        protected override Task HandleRequirementAsync(
            AuthorizationHandlerContext context, 
            RequireMfa requirement
        ){
            if (context == null)
                throw new ArgumentNullException(nameof(context));
            if (requirement == null)
                throw new ArgumentNullException(nameof(requirement));

            var amrClaim = context.User.Claims.FirstOrDefault(t => t.Type == "amr");

            if (amrClaim != null && amrClaim.Value == Amr.Mfa)
            {
                context.Succeed(requirement);
            }

            return Task.CompletedTask;
        }
    }
}

In the ConfigureServices method in the Startup class, the AddOpenIdConnect method is used as the default challenge scheme. The authorization handler which is used to check the amr claim is added as to the IoC. A policy is created then which adds the RequireMfa requirement.

public void ConfigureServices(IServiceCollection services)
{
	services.ConfigureApplicationCookie(options =>
	{
		options.Cookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always;
	});

	services.AddSingleton<IAuthorizationHandler, RequireMfaHandler>();

	services.AddAuthentication(options =>
	{
		options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
		options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
	})
	.AddCookie()
	.AddOpenIdConnect(options =>
	{
		options.SignInScheme = "Cookies";
		options.Authority = "https://localhost:44352";
		options.RequireHttpsMetadata = true;
		options.ClientId = "AspNetCoreRequireMfaOidc";
		options.ClientSecret = "AspNetCoreRequireMfaOidcSecret";
		options.ResponseType = "code id_token";
		options.Scope.Add("profile");
		options.Scope.Add("offline_access");
		options.SaveTokens = true;
	});

	services.AddAuthorization(options =>
	{
		options.AddPolicy("RequireMfa", policyIsAdminRequirement =>
		{
			policyIsAdminRequirement.Requirements.Add(new RequireMfa());
		});
	});

	services.AddRazorPages();
}

This policy is then used in the Razor pages as required. This could be added globally for the whole application as well.

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

namespace AspNetCoreRequireMfaOidc.Pages
{
    [Authorize(Policy= "RequireMfa")]
    public class IndexModel : PageModel
    {
        private readonly ILogger<IndexModel> _logger;

        public IndexModel(ILogger<IndexModel> logger)
        {
            _logger = logger;
        }

        public void OnGet()
        {

        }
    }
}

If the user authenticates without MFA, then the amr claim will probably have a pwd value, and the request will not be authorized to access the page. Using the default values, the user will be redirected to the account/AccessDenied page. This can be changed, or you can implement your own custom logic here. In this example, a link is added, so that the valid user can setup MFA for his or her account.

@page
@model AspNetCoreRequireMfaOidc.AccessDeniedModel
@{
    ViewData["Title"] = "AccessDenied";
    Layout = "~/Pages/Shared/_Layout.cshtml";
}

<h1>AccessDenied</h1>

You require MFA to login here

<a href="https://localhost:44352/Manage/TwoFactorAuthentication">Enable MFA</a>

Now only users that do MFA authenticatation can access the page, or website. If different MFA types are used, or 2FA is ok, then the amr claim will have different values, and need to be processed correctly. Different Open ID Connect servers will also return different values for this claim, and might not follow the specification Authentication Method Reference Values.

If we login without MFA, ie just using a password, the amr has the pwd value:

And access is denied:

Or if we login using OTP with Identity:

Links:

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

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

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

6 comments

  1. […] Force ASP.NET Core OpenID Connect client to require MFA (Damien Bowden) […]

  2. […] Force ASP.NET Core OpenID Connect client to require MFA – Damien Bowden […]

  3. In the sample there is only one ‘access denied’ page that simply states that the user should login using mfa. That’s fine for the sample, but suppose I have an additional requirement, minimum age of 21. How should an admin, logged in with mfa, but under 21 be handled?.

    I think the options are limited. The policyhandler either succeeds or fails. So what happens next? And this is something what really puzzles me. Should I check what caused the access denied? If there is a way to determine what caused it. Can I retrieve the information from the authorization context or another source?

    You say, “Using the default values, the user will be redirected to the account/AccessDenied page. This can be changed, or you can implement your own custom logic here.”, but I don’t see how and really wonder what you have in mind. Do I need to implement rules to determine which access denied page should be shown? Should the user be redirected based on a failed policy?

    One note about the code. Readers may not be aware that the amr claim is in fact a JSON array of strings: amr = [ “pwd”, “mfa” ]. Perhaps you should take this into account in the code as FirstOrDefault can give unexpected results. This also raises the question how multiple values should be handled.

    1. Hi Ruard

      Thanks for the feedback.

      I would create a second policy for this and add this to the method, page required. The policyhandler succeeds, fails or returns no result. So you can implement your authorization logic as required, using the requirements to separate. You can implement the logic you require in the handlers, or the account/AccessDenied. You can also set the path for this in the startup and add custom logic in your controller.

      services.AddAuthentication(options =>…)
      .AddOpenIdConnect(options =>…)
      .AddCookie(options =>
      {
      options.AccessDeniedPath = “/path/unauthorized”;
      options.LoginPath = “/path/login”;
      });

      I need to fix the amr handling, thanks.
      Greetings Damien

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

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

%d bloggers like this: