ASP.NET Core 3.1 - 2FA Sign In Service

Ken Haggerty
Created 08/26/2020 - Updated 11/25/2020 00:17

This article will describe the implementation of a class for the functions PasswordSignInAsync, SignOut, RefreshSignIn, and functions which remember and forget the current 2FA browser. I will assume you have created a new ASP.NET Core 3.1 Razor Pages project. See Tutorial: Get started with Razor Pages in ASP.NET Core. You should review the earlier articles of the Users Without Identity Project series.

The UWIP used a simple AuthenticateUser function to to validate users. If a user with 2FA enabled, logs in with a valid login name and password, they are not immediately authenticated. I analyzed how ASP.NET Core Identity implements 2FA authentication and use Identity SignInResult which is loaded with netcoreapp3.1 to return status. I developed a SignInService class derived from the Identity SignInManager<TUser> to implement a PasswordSignInAsync function which returns the SignInResult indicating Succeeded, Failed, or RequiresTwoFactor. The SignInService also implements functions like RefreshSignIn, SignOut, and functions which remember and forget the current 2FA browser. The UWIP does not implement recovery codes.

Services > SignInService.cs:
// Copyright © 2020 Ken Haggerty (https://kenhaggerty.com)
// Licensed under the MIT License.

public interface ISignInService
{
    Task<SignInResult> PasswordSignInAsync(string userName, string password, bool isPersistent);
    bool IsSignedIn(ClaimsPrincipal principal);
    Task RefreshSignInAsync(AppUser user);
    Task SignOutAsync();

    Task<SignInResult> TwoFactorAuthenticatorSignInAsync(int code, bool isPersistent, bool rememberClient);
    Task<bool> IsTwoFactorClientRememberedAsync(AppUser user);
    Task ForgetTwoFactorClientAsync();
}

public class SignInService : ISignInService
{
    private readonly IUserService _userService;
    private readonly IHttpContextAccessor _contextAccessor;
    private HttpContext _context;

    /// <summary>
    /// Creates a new instance of <see cref="SignInService" />.
    /// </summary>
    /// <param name="userService">An instance of <see cref="userService" /> used to retrieve users from and persist users.</param>
    /// <param name="contextAccessor">The accessor used to access the <see cref="HttpContext" />.</param>
    public SignInService(IUserService userService, IHttpContextAccessor contextAccessor)
    {
        _userService = userService ?? throw new ArgumentNullException(nameof(userService));
        _contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor));
    }

    /// <summary>
    /// The <see cref="HttpContext" /> used.
    /// </summary>
    public HttpContext Context
    {
        get
        {
            var context = _context ?? _contextAccessor?.HttpContext;
            if (context == null) throw new InvalidOperationException("HttpContext must not be null.");
            return context;
        }
        set
        {
            _context = value;
        }
    }

    /// <summary>
    /// Attempts to sign in the specified <paramref name="userName" /> and <paramref name="password" />

    /// combination as an asynchronous operation.
    /// </summary>
    /// <param name="userName">The user name to sign in.</param>
    /// <param name="password">The password to attempt to sign in with.</param>
    /// <param name="isPersistent">Flag indicating whether the sign-in cookie should persist after the browser is closed.</param>
    /// <returns>The task object representing the asynchronous operation containing the <see name="SignInResult" />

    /// for the sign-in attempt.</returns>
    public async Task<SignInResult> PasswordSignInAsync(string userName, string password, bool isPersistent)
    {
        var user = await _userService.GetAppUserByLoginNameUppercaseAsync(userName.ToUpper());
        if (user == null)
            return SignInResult.Failed;

        return await PasswordSignInAsync(user, password, isPersistent);
    }

    /// <summary>
    /// Returns true if the principal has an identity with the application cookie identity
    /// </summary>
    /// <param name="principal">The <see cref="ClaimsPrincipal" /> instance.</param>
    /// <returns>True if the user is logged in with identity.</returns>
    public bool IsSignedIn(ClaimsPrincipal principal)
    {
        if (principal == null)
            throw new ArgumentNullException(nameof(principal));

        return principal?.Identities != null &&
            principal.Identities.Any(i => i.AuthenticationType == UWIPConstants.ApplicationScheme);
    }

    /// <summary>
    /// Regenerates the user's application cookie, whilst preserving the existing
    /// AuthenticationProperties like rememberMe, as an asynchronous operation.
    /// </summary>
    /// <param name="user">The user whose sign-in cookie should be refreshed.</param>
    /// <returns>The task object representing the asynchronous operation.</returns>
    public async Task RefreshSignInAsync(AppUser user)
    {
        var auth = await Context.AuthenticateAsync(UWIPConstants.ApplicationScheme);
        var authenticationMethod = auth?.Principal?.FindFirstValue(ClaimTypes.AuthenticationMethod);
        await SignInAsync(user, auth?.Properties, authenticationMethod);
    }

    /// <summary>
    /// Signs the current user out of the application.
    /// </summary>
    public async Task SignOutAsync()
    {
        await Context.SignOutAsync(UWIPConstants.ApplicationScheme);
        await Context.SignOutAsync(UWIPConstants.TwoFactorUserIdScheme);
    }

    /// <summary>
    /// Validates the sign in code from an authenticator app and creates and signs in the user, as an asynchronous operation.
    /// </summary>
    /// <param name="code">The two factor authentication code to validate.</param>
    /// <param name="isPersistent">Flag indicating whether the sign-in cookie should persist after the browser is closed.</param>
    /// <param name="rememberClient">Flag indicating whether the current browser should be remember, suppressing all further 
    /// two factor authentication prompts.</param>
    /// <returns>The task object representing the asynchronous operation containing the <see name="SignInResult" />

    /// for the sign-in attempt.</returns>
    public async Task<SignInResult> TwoFactorAuthenticatorSignInAsync(int code, bool isPersistent, bool rememberClient)
    {
        var twoFactorInfo = await RetrieveTwoFactorInfoAsync();
        if (twoFactorInfo == null || twoFactorInfo.UserId == 0)
            return SignInResult.Failed;

        var user = await _userService.GetAppUserByIdAsync(twoFactorInfo.UserId);
        if (user == null)
            return SignInResult.Failed;

        var authenticatorKey = await _userService.GetAuthenticatorKeyAsync(user.Id);
        var validCode = TwoFactorAuth.GetAuthenticatorCode(authenticatorKey);
        if (code.Equals(validCode))
        {
            await DoTwoFactorSignInAsync(user, isPersistent, rememberClient);
            return SignInResult.Success;
        }

        return SignInResult.Failed;
    }

    /// <summary>
    /// Returns a flag indicating if the current client browser has been remembered by two factor authentication
    /// for the user attempting to login, as an asynchronous operation.
    /// </summary>
    /// <param name="user">The user attempting to login.</param>
    /// <returns>
    /// The task object representing the asynchronous operation containing true if the browser has been remembered
    /// for the current user.
    /// </returns>
    public async Task<bool> IsTwoFactorClientRememberedAsync(AppUser user)
    {
        var result = await Context.AuthenticateAsync(UWIPConstants.TwoFactorRememberMeScheme);
        return (result?.Principal != null && result.Principal.FindFirstValue(ClaimTypes.NameIdentifier) == user.Id.ToString());
    }

    /// <summary>
    /// Clears the "Remember this browser flag" from the current browser, as an asynchronous operation.
    /// </summary>
    /// <returns>The task object representing the asynchronous operation.</returns>
    public Task ForgetTwoFactorClientAsync()
    {
        return Context.SignOutAsync(UWIPConstants.TwoFactorRememberMeScheme);
    }

    /// <summary>
    /// Attempts to sign in the specified <paramref name="user" /> and <paramref name="password" /> combination
    /// as an asynchronous operation.
    /// </summary>
    /// <param name="user">The user to sign in.</param>
    /// <param name="password">The password to attempt to sign in with.</param>
    /// <param name="isPersistent">Flag indicating whether the sign-in cookie should persist after the browser is closed.</param>
    /// <returns>The task object representing the asynchronous operation containing the <see name="SignInResult" />

    /// for the sign-in attempt.</returns>
    private async Task<SignInResult> PasswordSignInAsync(AppUser user, string password, bool isPersistent)
    {
        if (user == null)
            throw new ArgumentNullException(nameof(user));

        var attempt = CheckPasswordSignIn(user, password);
        return attempt.Succeeded ? await SignInOrTwoFactorAsync(user, isPersistent) : attempt;
    }

    private async Task<TwoFactorAuthenticationInfo> RetrieveTwoFactorInfoAsync()
    {
        var result = await Context.AuthenticateAsync(UWIPConstants.TwoFactorUserIdScheme);
        if (result?.Principal != null)
        {
            return new TwoFactorAuthenticationInfo
            {
                UserId = int.Parse(result.Principal.FindFirstValue(ClaimTypes.NameIdentifier))
            };
        }
        return null;
    }

    private async Task DoTwoFactorSignInAsync(AppUser user, bool isPersistent, bool rememberClient)
    {
        // Cleanup two factor user id cookie
        await Context.SignOutAsync(UWIPConstants.TwoFactorUserIdScheme);
        if (rememberClient)
            await RememberTwoFactorClientAsync(user);

        await SignInAsync(user, isPersistent, UWIPConstants.MultiFactorAuthentication);
    }

    /// <summary>
    /// Sets a flag on the browser to indicate the user has selected "Remember this browser" for two factor authentication purposes,
    /// as an asynchronous operation.
    /// </summary>
    /// <param name="user">The user who choose "remember this browser".</param>
    /// <returns>The task object representing the asynchronous operation.</returns>
    private async Task RememberTwoFactorClientAsync(AppUser user)
    {
        var principal = StoreRememberClient(user);
        await Context.SignInAsync(UWIPConstants.TwoFactorRememberMeScheme,
            principal, new AuthenticationProperties { IsPersistent = true });
    }

    private ClaimsPrincipal StoreRememberClient(AppUser user)
    {
        var rememberBrowserIdentity = new ClaimsIdentity(UWIPConstants.TwoFactorRememberMeScheme);
        rememberBrowserIdentity.AddClaim(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
        return new ClaimsPrincipal(rememberBrowserIdentity);
    }

    /// <summary>
    /// Signs in the specified <paramref name="user" />.
    /// </summary>
    /// <param name="user">The user to sign-in.</param>
    /// <param name="isPersistent">Flag indicating whether the sign-in cookie should persist after the browser is closed.</param>
    /// <param name="authenticationMethod">Name of the method used to authenticate the user.</param>
    /// <returns>The task object representing the asynchronous operation.</returns>
    private Task SignInAsync(AppUser user, bool isPersistent, string authenticationMethod = null)
    {
        return SignInAsync(user, new AuthenticationProperties { IsPersistent = isPersistent }, authenticationMethod);
    }

    /// <summary>
    /// Signs in the specified <paramref name="user" />.
    /// </summary>
    /// <param name="user">The user to sign-in.</param>
    /// <param name="authenticationProperties">Properties applied to the login and authentication cookie.</param>
    /// <param name="authenticationMethod">Name of the method used to authenticate the user.</param>
    /// <returns>The task object representing the asynchronous operation.</returns>
    private async Task SignInAsync(AppUser user, AuthenticationProperties authenticationProperties, string authenticationMethod = null)
    {
        var userPrincipal = await CreateUserPrincipalAsync(user.Id, authenticationMethod);

        await Context.SignInAsync(UWIPConstants.ApplicationScheme,
            userPrincipal,
            authenticationProperties ?? new AuthenticationProperties());
    }

    /// <summary>
    /// Attempts a password sign in for a user.
    /// </summary>
    /// <param name="user">The user to sign in.</param>
    /// <param name="password">The password to attempt to sign in with.</param>
    /// <returns>The task object representing the asynchronous operation containing the <see name="SignInResult" />

    /// for the sign-in attempt.</returns>
    /// <returns></returns>
    private SignInResult CheckPasswordSignIn(AppUser user, string password)
    {
        if (user == null)
            throw new ArgumentNullException(nameof(user));

        if (string.IsNullOrEmpty(password)) return SignInResult.Failed;
        var customPasswordHasher = new CustomPasswordHasher();
        var result = customPasswordHasher.VerifyPassword(user.PasswordHash, password);
        if (result.Succeeded)
            return SignInResult.Success;

        return SignInResult.Failed;
    }

    /// <summary>
    /// Creates a claims principal for the specified 2fa information.
    /// </summary>
    /// <param name="userId">The user whose is logging in via 2fa.</param>
    /// <param name="loginProvider">The 2fa provider.</param>
    /// <returns>A <see cref="ClaimsPrincipal" /> containing the user 2fa information.</returns>
    private ClaimsPrincipal StoreTwoFactorInfo(string userId, string loginProvider)
    {
        var twoFactorInfoIdentity = new ClaimsIdentity(UWIPConstants.TwoFactorUserIdScheme);
        twoFactorInfoIdentity.AddClaim(new Claim(ClaimTypes.NameIdentifier, userId));
        if (loginProvider != null)
            twoFactorInfoIdentity.AddClaim(new Claim(ClaimTypes.AuthenticationMethod, loginProvider));

        return new ClaimsPrincipal(twoFactorInfoIdentity);
    }

    /// <summary>
    /// Creates a <see cref="ClaimsPrincipal" /> for the user specified by <paramref name="userId" />,
    /// as an asynchronous operation.
    /// </summary>
    /// <param name="userId">The id for the user to create a <see cref="ClaimsPrincipal" /> for.</param>
    /// <returns>The task object representing the asynchronous operation, containing the ClaimsPrincipal
    /// for the specified user.</returns>
    private async Task<ClaimsPrincipal> CreateUserPrincipalAsync(int userId, string authenticationMethod)
    {
        var user = await _userService.GetAppUserByIdAsync(userId);
        string rowVersionString = Encoding.Unicode.GetString(user.RowVersion);
        var claims = new List<Claim>
            {
                new Claim(ClaimTypes.NameIdentifier, user.Id.ToString(), ClaimValueTypes.Integer),
                new Claim(ClaimTypes.Name, user.LoginName),
                new Claim(ClaimTypes.UserData, rowVersionString),
                new Claim(ClaimTypes.AuthenticationMethod, authenticationMethod),
                new Claim(UWIPConstants.TwoFactorEnabledClaimType, user.TwoFactorEnabled.ToString())
            };

        if (user.IsAdmin)
            claims.Add(new Claim(ClaimTypes.Role, UWIPConstants.AdminRole));
        if (user.MustChangePassword)
            claims.Add(new Claim(UWIPConstants.MustChangePasswordClaimType, string.Empty));

        var applicationIdentity = new ClaimsIdentity(claims, UWIPConstants.ApplicationScheme);

        return new ClaimsPrincipal(applicationIdentity);
    }

    /// <summary>
    /// Signs in the specified <paramref name="user" /> if <paramref name="bypassTwoFactor" /> is set to false.
    /// Otherwise stores the <paramref name="user" /> for use after a two factor check.
    /// </summary>
    /// <param name="user"></param>
    /// <param name="isPersistent">Flag indicating whether the sign-in cookie should persist after the browser is closed.</param>
    /// <param name="bypassTwoFactor">Flag indicating whether to bypass two factor authentication. Default is false</param>
    /// <returns>Returns a <see cref="SignInResult" /></returns>
    private async Task<SignInResult> SignInOrTwoFactorAsync(AppUser user, bool isPersistent, bool bypassTwoFactor = false)
    {
        if (!bypassTwoFactor && user.TwoFactorEnabled)
        {
            if (!await IsTwoFactorClientRememberedAsync(user))
            {
                // Store the userId for use after two factor check
                await Context.SignInAsync(
                    UWIPConstants.TwoFactorUserIdScheme,
                    StoreTwoFactorInfo(user.Id.ToString(),
                    UWIPConstants.TwoFactorLoginProvider));
                return SignInResult.TwoFactorRequired;
            }
        }
        await SignInAsync(user, isPersistent, UWIPConstants.PasswordAuthentication);
        return SignInResult.Success;
    }

    internal class TwoFactorAuthenticationInfo
    {
        public int UserId { get; set; }
        public string LoginProvider { get; set; } = UWIPConstants.TwoFactorLoginProvider;
    }
}

Inject the interface and implementation type into the service container.

Edit Startup.cs > ConfigureServices:
services.AddTransient<ISignInService, SignInService>();
Update 11/24/2020

I added the Rename Related Entities article and updated the project and article links.

Article Tags:

2FA Authorization Claims

Comment Count = 0

Please log in to comment.

Login Register
Logged in users receive web notifications.
Web Notifications