ASP.NET Core 6.0 - Remember Me Or Not

This article describes the SlidingExpiration, AllowRefresh, IsPersistent, IssuedUtc, and ExpiresUtc AuthenticationProperties. I will assume you have downloaded the ASP.NET Core 6.0 - Users With Device 2FA Project. Visual Studio 2022 is required to develop .NET 6 and ASP.NET Core 6.0 applications. VS 2022 Downloads.

Users With Device 2FA Project and Article Series

This article series is about the implementation of ASP.NET Core 6.0 with Visual Studio 2022. The ASP.NET Core 6.0 - Users With Device 2FA Project (UWD2FAP) implements WebAuthn, also known as FIDO2, instead of authenticator apps for two-factor authentication (2FA). The project implements Bootstrap v5 and Bootstrap Native. After a user registers, they can enable 2FA with Windows Hello, Android Lock Screen, or a FIDO2 security key. If I had this project when I created KenHaggerty. Com three years ago, I would have started with this user authentication template. The latest version is published at Preview. KenHaggerty. Com. I encourage you to register and evaluate multiple 2FA devices in Manage Account > Two-Factor Authentication. Details, screenshots, and related articles can be found at ASP.NET Core 6.0 - Users With Device 2FA Project. The details page includes the version change log.

The UWD2FAP implements AppSettings and a CookieValidator to control how often a user must authenticate.

AppSettings.cs:
/// <summary>
/// Used to set the <see cref="CookieAuthenticationOptions.SlidingExpiration"/> for the
/// <see cref="AppSettings.ApplicationScheme"/> cookie. 
/// </summary>
/// <remarks>
/// The SlidingExpiration is set to true to instruct the handler to re-issue a new cookie with a new expiration
/// time any time it processes a request which is more than halfway through the expiration window.
/// The default value is true.
/// </remarks>
public static readonly bool LoginSlidingExpirationEnabled = true;
/// <summary>
/// Used to set the <see cref="AuthenticationProperties.IsPersistent"/> and 
/// <see cref="AuthenticationProperties.ExpiresUtc"/>  for the 
/// <see cref="AppSettings.ApplicationScheme"/> cookie. 
/// </summary>
/// <remarks>
/// A zero value will hide the Log In page's Remember Me checkbox, set the 
/// <see cref="AuthenticationProperties.IsPersistent"/> to false, and set the
/// <see cref="AuthenticationProperties.ExpiresUtc"/> to null. This will require the user to log in again
/// after all browser instances have closed. A value greater than zero will set the 
/// <see cref="AuthenticationProperties.IsPersistent"/> to true and set the
/// <see cref="AuthenticationProperties.ExpiresUtc"/> to UtcNow plus the value.
/// The <see cref="CookieAuthenticationOptions.ExpireTimeSpan"/> default is 14 days. 
/// </remarks>
public static readonly int LoginRememberMeDays = 14;
/// <summary>
/// Used to validate the <see cref="AuthenticationProperties.IssuedUtc"/> for the
/// <see cref="AppSettings.ApplicationScheme"/> cookie. 
/// </summary>
/// <remarks>
/// A zero value will not validate <see cref="AuthenticationProperties.IssuedUtc"/>. A value greater than zero
/// will validate the <see cref="AuthenticationProperties.IssuedUtc"/> plus the value is less than UtcNow.
/// </remarks>
public static readonly int LoginRememberMeMaxDays = 0;
/// <summary>
/// Used to set the <see cref="AuthenticationProperties.ExpiresUtc"/> for the
/// <see cref="AppSettings.TwoFactorRememberMeScheme"/> cookie. 
/// </summary>
/// <remarks>
/// A zero value will hide the 2FA remember this machine checkbox and
/// require 2FA authentication for every log in.
/// </remarks>
public static readonly int TwoFactorRememberMeDays = 0;
CookieValidator.cs:
// Copyright © Ken Haggerty (https://kenhaggerty.com)
// Licensed under the MIT License.
namespace UsersWithDevice2FA.Services.Middleware;
/// <summary>
/// Static class to validate the ClaimsPrincipal. 
/// </summary>
public static class CookieValidator
{
    /// <summary>
    /// Adds a claim with <see cref="ClaimTypes.AuthenticationInstant"/> and UtcNow to the
    /// current <see cref="ClaimsPrincipal"/>.
    /// </summary>
    /// <param name="context">
    /// The accessor used to access the <see cref="CookieValidatePrincipalContext"/>.
    /// </param>
    /// <remarks>
    /// Implemented with the <see cref="CookieAuthenticationEvents.OnSigningIn"/> event.
    /// </remarks>
    /// <returns><see cref="Task{TResult}"/> for <see cref="void"/></returns>
    public static async Task AddAuthenticationInstantClaimAsync(CookieSigningInContext context)
    {
        var claimsPrincipal = context.Principal;
        if (claimsPrincipal != null)
            claimsPrincipal.Identities.First().AddClaim(new Claim(ClaimTypes.AuthenticationInstant, DateTimeOffset.UtcNow.ToString()));

        await Task.CompletedTask;
        return;
    }

    /// <summary>
    /// Signs out the <see cref="IdentityConstants.ApplicationScheme"/>,
    /// <see cref="IdentityConstants.ExternalScheme"/>, and
    /// <see cref="IdentityConstants.TwoFactorUserIdScheme"/>
    /// if <see cref="ValidateCookieAsync"/> fails.
    /// </summary>
    /// <param name="context">
    /// The accessor used to access the <see cref="CookieValidatePrincipalContext"/>.
    /// </param>
    /// <remarks>
    /// Implemented with the <see cref="CookieAuthenticationEvents.OnValidatePrincipal"/> event.
    /// </remarks>
    /// <returns><see cref="Task{TResult}"/> for <see cref="void"/></returns>
    public static async Task ValidateAsync(CookieValidatePrincipalContext context)
    {
        if (!await ValidateCookieAsync(context).ConfigureAwait(false))
        {
            context.RejectPrincipal();
            await context.HttpContext.SignOutAsync(AppSettings.ApplicationScheme).ConfigureAwait(false);
            await context.HttpContext.SignOutAsync(AppSettings.TwoFactorUserIdScheme).ConfigureAwait(false);
            await context.HttpContext.SignOutAsync(AppSettings.TwoFactorRememberMeScheme).ConfigureAwait(false);
            await context.HttpContext.SignOutAsync(AppSettings.ImpersonateUserScheme).ConfigureAwait(false);
        }
    }
    /// <summary>
    /// Verifies the <see cref="ClaimsPrincipal"/>'s <see cref="ClaimTypes.Sid"/>
    /// matches the related <see cref="AppUser"/>'s <see cref="AppUser.SecurityStamp"/>.
    /// Verifies the <see cref="ClaimTypes.AuthenticationInstant"/> when
    /// <see cref="AppSettings.LoginRememberMeMaxDays"/> is greater than 0.
    /// </summary>
    /// <param name="context">The accessor used to access the <see cref="CookieValidatePrincipalContext"/>.</param>
    /// <returns><see cref="Task{TResult}"/> for <see cref="bool"/> indicating success.</returns>
    /// <remarks>
    /// A <see cref="AppSettings.LoginRememberMeMaxDays"/> value greater than zero will verify the
    /// <see cref="ClaimTypes.AuthenticationInstant"/> plus the
    /// <see cref="AppSettings.LoginRememberMeMaxDays"/> is less than UtcNow.
    /// </remarks>
    private static async Task<bool> ValidateCookieAsync(CookieValidatePrincipalContext context)
    {
        var claimsPrincipal = context.Principal;
        if (claimsPrincipal == null) return false;

        var nameIdentifier = claimsPrincipal.Claims
            .Where(c => c.Type == ClaimTypes.NameIdentifier && c.Issuer == AppSettings.ClaimIssuer)
            .Select(c => c.Value)
            .FirstOrDefault();

        if (!int.TryParse(nameIdentifier, out int appUserId)) return false;

        if(AppSettings.LoginRememberMeMaxDays > 0)
        {
            var issuedClaimValue = claimsPrincipal.Claims
                .Where(c => c.Type == ClaimTypes.AuthenticationInstant && c.Issuer == AppSettings.ClaimIssuer)
                .Select(c => c.Value)
                .FirstOrDefault();

            if (issuedClaimValue == null || !DateTimeOffset.TryParse(issuedClaimValue, out var issuedUtc)) 
                return false;

            if (issuedUtc.AddDays(AppSettings.LoginRememberMeMaxDays) < DateTimeOffset.UtcNow)
                return false;
        }

        var securityStamp = claimsPrincipal.Claims
            .Where(c => c.Type == ClaimTypes.Sid && c.Issuer == AppSettings.ClaimIssuer)
            .Select(c => c.Value)
            .FirstOrDefault();

        if (string.IsNullOrEmpty(securityStamp)) return false;

        try
        {
            var userService = context.HttpContext.RequestServices.GetRequiredService<IUserService>();
            return await userService.ValidateAppUserSecurityStampAsync(appUserId, securityStamp).ConfigureAwait(false);
        }
        catch (Exception)
        {
            return false;
        }
    }
}

The CookieValidator's AddAuthenticationInstantClaimAsync method can be implemented with the CookieAuthenticationEvents' OnSigningIn event. The UWD2FAP adds the AuthenticationInstant claim with the SignInServices' CreateUserPrincipalAsync method.

Program.cs:
builder.Services.AddAuthentication(AppSettings.ApplicationScheme)
    .AddCookie(AppSettings.ApplicationScheme, options =>
    {
        options.Cookie.Name = AppSettings.ApplicationScheme;
        options.ClaimsIssuer = AppSettings.ClaimIssuer;
        options.LoginPath = "/account/login";
        options.LogoutPath = "/account/logout";
        options.AccessDeniedPath = "/account/accessdenied";
        options.ReturnUrlParameter = "returnurl";
        options.ExpireTimeSpan = TimeSpan.FromDays(AppSettings.LoginRememberMeDays);
        options.SlidingExpiration = AppSettings.LoginSlidingExpirationEnabled;
        options.Events = new CookieAuthenticationEvents
        {
            OnSigningIn = CookieValidator.AddAuthenticationInstantClaimAsync,
            OnValidatePrincipal = CookieValidator.ValidateAsync
        };
    })
    .AddCookie(AppSettings.TwoFactorUserIdScheme, options =>
    {
        options.Cookie.Name = AppSettings.TwoFactorUserIdScheme;
        options.ClaimsIssuer = AppSettings.ClaimIssuer;
        options.ExpireTimeSpan = TimeSpan.FromMinutes(5);
    })
    .AddCookie(AppSettings.TwoFactorRememberMeScheme, options =>
    {
        options.Cookie.Name = AppSettings.TwoFactorRememberMeScheme;
        options.ClaimsIssuer = AppSettings.ClaimIssuer;
        options.SlidingExpiration = false;
        options.ExpireTimeSpan = TimeSpan.FromDays(AppSettings.TwoFactorRememberMeDays);
    })
    .AddCookie(AppSettings.ImpersonateUserScheme, options =>
    {
        options.Cookie.Name = AppSettings.ImpersonateUserScheme;
        options.ClaimsIssuer = AppSettings.ClaimIssuer;
    });

AppSettings. LoginSlidingExpirationEnabled

The CookieAuthenticationOptions' SlidingExpiration property defaults to true. From GitHub - dotnet / aspnetcore.

/// <summary>
/// Create an instance of the options initialized with the default values
/// </summary>
public CookieAuthenticationOptions()
{
    ExpireTimeSpan = TimeSpan.FromDays(14);
    ReturnUrlParameter = CookieAuthenticationDefaults.ReturnUrlParameter;
    SlidingExpiration = true;
    Events = new CookieAuthenticationEvents();
}
...
/// <summary>
/// The SlidingExpiration is set to true to instruct the handler to re-issue a new cookie with a new
/// expiration time any time it processes a request which is more than halfway through the expiration window.
/// </summary>
public bool SlidingExpiration { get; set; }

When the SlidingExpiration property is omitted or true, the user will not need to re-authenticate as long as they browse or refresh a page after halfway through the expiration window set by the ExpireTimeSpan property and before the current expiration window ends. Set LoginSlidingExpirationEnabled to false to disable the CookieAuthenticationOptions' SlidingExpiration and the AuthenticationProperties' AllowRefresh.

AppSettings. LoginRememberMeDays

When the LoginRememberMeDays is set to zero, the Log In page's Remember Me checkbox is not rendered, the CookieAuthenticationOptions' ExpireTimeSpan is set to zero, and the AuthenticationProperties' ExpiresUtc is set to null. This will produce a session authentication cookie and require the user to log in again after all browser instances have closed. A value greater than zero will set the AuthenticationProperties' ExpiresUtc to UtcNow plus the value and the AuthenticationProperties' IsPersistent property to true. When the LoginSlidingExpirationEnabled is false, the authentication cookie will expire after the number of days set with LoginRememberMeDays.

AppSettings. LoginRememberMeMaxDays

The LoginRememberMeMaxDays depends on the AuthenticationInstant claim which specifies the instant at which an entity was authenticated. The UWD2FAP adds the AuthenticationInstant claim with the CreateUserPrincipalAsync method. The CookieValidator's AddAuthenticationInstantClaimAsync method can be implemented with the CookieAuthenticationEvents' OnSigningIn event. A zero value will not validate the AuthenticationInstant claim. A value greater than zero will validate the AuthenticationInstant claim's value plus the LoginRememberMeMaxDays value is less than UtcNow.

AppSettings. TwoFactorRememberMeDays

The TwoFactorRememberMeDays is used to set the ExpiresUtc for the TwoFactorRememberMeScheme cookie. A zero value will hide the 2FA remember this machine checkbox and require 2FA authentication for every log in. A value greater than zero will set the TwoFactorRememberMeScheme cookie's ExpiresUtc to UtcNow plus the value.

Idle Logout

The UWD2FAP, the ASP.NET Core 6.0 - Users Without Passwords Project, the ASP.NET Core 6.0 - Users Without Identity Project, and the free ASP.NET Core 6.0 - Demos And Utilities Project implement an Idle Logout feature which will automatically logout users who are idle longer than the IdleLogoutThreshold minutes plus the IdleLogoutCountdown seconds. A click, mousemove, or keypress event resets the idle timer. The feature is implemented with the IdleLogoutEnabled, IdleLogoutThreshold, and IdleLogoutCountdown AppSettings.

AppSettings.cs:
/// <summary>
/// Set to automatically logout users who are idle longer than the <see cref="IdleLogoutThreshold"/>
/// plus the <see cref="IdleLogoutCountdown"/>.
/// </summary>
/// <remarks>
/// A click, mousemove, or keypress event resets the idle timer. These event listeners are only enabled
/// when the user is authenticated.
/// </remarks>
public static readonly bool IdleLogoutEnabled = false;
/// <summary>
/// Used to set the number of minutes the user must be idle before the logout warning banner is displayed.
/// </summary>
public static readonly int IdleLogoutThreshold = 15;
/// <summary>
/// Used to set the number of seconds the logout warning banner is displayed.
/// </summary>
public static readonly int IdleLogoutCountdown = 15;

The Idle Logout feature depends on a _IdleLogoutPartial.cshtml and a IdleLogout page in the Account folder/ namespace. The _IdleLogoutPartial.cshtml implements the event listeners, timers, and css to display a countdown warning before calling an AJAX logout method. The AJAX logut method is implemented with the IdleLogout page.

namespace UsersWithDevice2FA.Pages.Account;
public class IdleLogoutModel : PageModel
{
    private readonly ISignInService _signInService;
    public IdleLogoutModel(ISignInService signInService)
    {
        _signInService = signInService;
    }

    public void OnGet()
    {
    }

    public JsonResult OnGetIsAuthenticated() => new(User.Identity?.IsAuthenticated ?? false);

    public JsonResult OnGetIsRequired()
    {
        // Sets the IsRequired method only for users with any claim in claimsRequirement.
        // An empty list returns true for all authenticated users.
        List<Claim> claimsRequirement = new();
        claimsRequirement.AddRange(AuthorizationClaims.ToSuperArray());
        claimsRequirement.Add(new Claim(ClaimTypes.AuthenticationMethod, AppSettings.ImpersonateUserAuthentication));

        if (User.Identity?.IsAuthenticated ?? false)
        {
            if (claimsRequirement.Any())
                return new(User.Claims.Intersect(claimsRequirement, new AuthorizationClaimComparer()).Any());
            return new(true);
        }
        return new(false);
    }

    public async Task<JsonResult> OnPostLogoutAsync()
    {
        await _signInService.SignOutAsync().ConfigureAwait(false);
        return new JsonResult(true);
    }
}
Idle Logout Warning.
Idle Logout Warning Mobile.

The idle user is signed out and redirected to the IdleLogout page.

Idle Logout Page.
Idle Logout Page Mobile.

The IdleLogout page also implements a demonstration with a one minute idle timer.

Idle Logout Demo.
Idle Logout Demo Mobile.
Ken Haggerty
Created 03/11/22
Updated 03/11/22 18:51 GMT

Log In or Reset Quota to read more.

Article Tags:

Authentication JavaScript
Successfully completed. Thank you for contributing.
Processing...
Something went wrong. Please try again.
Contribute to enjoy content without advertisments.
You can contribute without registering.

Comments(0)

Loading...
Loading...

Not accepting new comments.

Submit your comment. Comments are moderated.

User Image.
DisplayedName - Member Since ?