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.
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.And here is how you register the middleware. Make sure to add it after authorization middleware (
UseAuthorizationline).In my opinion though, you should just call
SignInManager<TUser>.RefreshSignInAsync(TUser)whenever user roles are updated. then you would probably not need such a "periodic update".