Jamie Stone
Jamie Stone

Reputation: 1

How do I refresh custom claims on a .net Cookie Identity on an interval? (IE: every half hour)

I inherited a ASP.Net site using Microsoft Identity for authentication and authorization. We use SQL for the identity database.

I am not understanding something with the site's claims logic. We are using a handful of custom claims to dictate which pages/products the user has access to. I would like it to be set up so once a user logs in, when they navigate to a new page, the claims get refreshed if it has been over 30 minutes. I want to do this to avoid checking our system in real time on each request. Basically cache the claims for 30 minutes on login, and refresh them if it's been over 30 minutes.

I am not understanding something with the identity logic. I thought that setting the ValidationInterval on the SecurityStampValidatorOptions would make the "OnRefreshingPrincipal" get called if it has been over a half hour since the user was authenticated.

When running locally, this method is never hit. Is there a different way that I am supposed to or can refresh the claims if it has been over 30 minutes since they were last refreshed?

My example (with some renames and company specific details removed)

namespace MyWebsite
{
    public static class SiteStartupExtensionMethods
    {
        public static void AddMyCustomIdentityAndCookieOptions(this IServiceCollection services, ConfigurationManager configurationManager)
        {
            // Add the identity database db context
            services.AddDbContextFactory<MyIdentityDatabaseDbContext>(options =>
                options.UseSqlServer(configurationManager.GetConnectionString("MyIdentityDatabaseDb")),
                ServiceLifetime.Transient
            );

            // Add the identity options
            services.Configure<IdentityOptions>(options =>
            {
                // Custom options removed for this post
            });

            // Add the identity / entity framework store
            services.AddIdentity<MyCustomIdentityUser, IdentityRole>()
                .AddEntityFrameworkStores<MyIdentityDatabaseDbContext>()
                .AddDefaultTokenProviders()
                .AddPasswordValidator<CommonPasswordValidator<MyCustomIdentityUser>>()
                .AddPasswordValidator<UsernameAsPasswordValidator<MyCustomIdentityUser>>();

            // Configure the cookie options
            services.AddDataProtection()
                .PersistKeysToFileSystem(new DirectoryInfo(@"C:\....."))
                .SetApplicationName("SharedCookieApp");
            services.ConfigureApplicationCookie(options =>
            {
                options.Cookie.Domain = ".myCustomWebsite.com";
                options.Cookie.SameSite = SameSiteMode.None;
                options.Cookie.HttpOnly = false;
                options.Cookie.Name = ".AspNet.SharedCookie";
                options.LoginPath = new PathString("/login");
                options.AccessDeniedPath = new PathString("/access-denied");

                options.Events = new CookieAuthenticationEvents()
                {
                    OnRedirectToLogin = (context) =>
                    {
                        // Custom logic removed
                        return Task.CompletedTask;
                    }
                };
            });

            // Set the security stamp options
            services.Configure<SecurityStampValidatorOptions>(options =>
            {
                options.ValidationInterval = TimeSpan.FromMinutes(30);
                options.OnRefreshingPrincipal = context =>
                {
                    // This is not being called?
                    // Custom claims refreshing logic was here
                    return Task.FromResult(0);
                };
            });

            // Add the claims factory
            services.AddScoped<IUserClaimsPrincipalFactory<MyCustomIdentityUser>, ClaimsFactory>();

            // Add the authentication state provider
            services.AddScoped<AuthenticationStateProvider, RevalidatingIdentityAuthenticationStateProvider<MyCustomIdentityUser>>();
        }
    }
}

RevalidatingIdentityAuthenticationStateProvider.cs:

public class RevalidatingIdentityAuthenticationStateProvider<TUser> : RevalidatingServerAuthenticationStateProvider where TUser : class
    {
        private readonly IServiceScopeFactory _scopeFactory;
        private readonly IdentityOptions _options;

        public RevalidatingIdentityAuthenticationStateProvider(ILoggerFactory loggerFactory, IServiceScopeFactory scopeFactory, IOptions<IdentityOptions> optionsAccessor) : base(loggerFactory)
        {
            _scopeFactory = scopeFactory;
            _options = optionsAccessor.Value;
        }

        protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);

        protected override async Task<bool> ValidateAuthenticationStateAsync(
            AuthenticationState authenticationState, CancellationToken cancellationToken)
        {
            // Get the user manager from a new scope to ensure it fetches fresh data
            var scope = _scopeFactory.CreateScope();
            try
            {
                var userManager = scope.ServiceProvider.GetRequiredService<UserManager<TUser>>();
                return await ValidateSecurityStampAsync(userManager, authenticationState.User);
            }
            finally
            {
                if (scope is IAsyncDisposable asyncDisposable)
                {
                    await asyncDisposable.DisposeAsync();
                }
                else
                {
                    scope.Dispose();
                }
            }
        }

        private async Task<bool> ValidateSecurityStampAsync(UserManager<TUser> userManager, ClaimsPrincipal principal)
        {
            var user = await userManager.GetUserAsync(principal);
            if (user == null)
            {
                return false;
            }
            else if (!userManager.SupportsUserSecurityStamp)
            {
                return true;
            }
            else
            {
                var principalStamp = principal.FindFirstValue(_options.ClaimsIdentity.SecurityStampClaimType);
                var userStamp = await userManager.GetSecurityStampAsync(user);
                return principalStamp == userStamp;
            }
        }
    }

I have attempted to change and lower the "ValidationInterval" down to a minute and the "OnRefreshingPrincipal" method is not being hit.

Upvotes: 0

Views: 1582

Answers (1)

Emre Bener
Emre Bener

Reputation: 1462

SignInManager<TUser>.RefreshSignInAsync(TUser) is the method you are looking for. this method will refresh cookie claims.

As to applying it at specific intervals, you can implement a minimal middleware. add a claim to the cookie that keeps "lastRefreshDate", and in the middleware, check if it has been 30 minutes since the last refresh. if so, refresh cookie and update "lastRefreshDate" property.

public class CookieRefreshMiddleware
{
    private readonly RequestDelegate _next;
    private readonly SignInManager<ApplicationUser> _signInManager;

    public CookieRefreshMiddleware(RequestDelegate next, SignInManager<ApplicationUser> signInManager)
    {
        _next = next;
        _signInManager = signInManager;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var user = context.User;
        if (user.Identity.IsAuthenticated)
        {
            var lastRefreshDateClaim = user.FindFirst("lastRefreshDate");
            if (lastRefreshDateClaim != null)
            {
                var lastRefreshDate = DateTime.Parse(lastRefreshDateClaim.Value);
                if ((DateTime.UtcNow - lastRefreshDate).TotalMinutes >= 30)
                {
                    var appUser = await _signInManager.UserManager.GetUserAsync(user);
                    appUser.Claims.Remove(lastRefreshDateClaim);
                    appUser.Claims.Add(new Claim("lastRefreshDate", DateTime.UtcNow.ToString()));
                    
                    // Update the cookie
                    await _signInManager.RefreshSignInAsync(appUser);
                }
            }
        }

        // Call the next delegate/middleware in the pipeline
        await _next(context);
    }
}

// In Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // Other configurations...

    app.UseMiddleware<CookieRefreshMiddleware>();

    // Other configurations...
}

And here is how you register the middleware. Make sure to add it after authorization middleware (UseAuthorization line).

app.UseMiddleware<CookieRefreshMiddleware>();

Another approach you can follow is to simply update the security stamp of the necessary user which invalidates any existing cookie they might have, but they would have to log in again whenever their cookie are invalidated. Identity library has a method to update/refresh security stamp.

With all that said, the best thing you could possibly do is to NOT store user claims in a cookie, and instead opting for a session cookie (look up session state). This way, the roles are stored elsewhere (for example; in-memory or redis) and users' cookies simply contain their session ids. This isolates roles from cookies which also solves the issue of users being out of sync with their roles for a period of time, as session updates will take effect immediately.

Upvotes: 3

Related Questions