Search code examples
asp.net-coreblazorasp.net-identityblazor-server-side

Identity library - Check Enabled & re-read Claims on each page


I am using the ASP.NET Identity library in a Blazor (server side) application. I'm hitting this problem but I think it's part of a larger problem.

First off, if I change the claims a user has, especially if I'm reducing them, I want those to take effect immediately - regardless of the user's actions. After all, if I say please log out and back in so your reduction in permissions takes effect - yeah people are going to get right on that .

I like caching the login so the user does not need to log in every time they hit the website. I absolutely want to keep that. But I want that cascading parameter Task<AuthenticationState> to re-read the claims every time it goes to a new page (yes it's a SPA but you know what I mean - goes to a new url in the SPA). Not re-read for each new session, but re-read for each new page. So that change takes effect immediately.

In addition I am going to add an Enable column to the AspNetUsers table and the IdentityUser model. And again, I want this checked every time the page changes so that Task<AuthenticationState> knows the user is disabled and so @attribute [Authorize] will not allow a page to load/display if the user is disabled.

So how do I implement both of these features?


Solution

  • I got it working. In Program.cs add:

    // after AddServerSideBlazor()
    builder.Services.AddScoped<AuthenticationStateProvider, ExAuthenticationStateProvider>();
    

    And here's ExAuthenticationStateProvider.cs (which also implements handling ExIdentityUser.Enabled):

    public class ExAuthenticationStateProvider : ServerAuthenticationStateProvider
    {
    
        // in UTC - when to do the next check (this time or later)
        private DateTime _nextCheck = DateTime.MinValue;
    
        private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(30);
    
        private readonly UserManager<ExIdentityUser> _userManager;
    
        public ExAuthenticationStateProvider(UserManager<ExIdentityUser> userManager, IConfiguration config)
        {
            _userManager = userManager;
            var minutes = config.GetSection("Identity:RevalidateMinutes").Value;
            if (!string.IsNullOrEmpty(minutes) && int.TryParse(minutes, out var intMinutes)) 
                _checkInterval = TimeSpan.FromMinutes(intMinutes);
        }
    
        /// <summary>
        /// Revalidate the next time GetAuthenticationStateAsync() is called.
        /// </summary>
        public void ResetNextCheck()
        {
            _nextCheck = DateTime.MinValue;
        }
    
        /// <inheritdoc />
        public override async Task<AuthenticationState> GetAuthenticationStateAsync()
        {
    
            // if less than 30 minutes since last check, then just return the default
            var now = DateTime.UtcNow;
            if (now < _nextCheck)
                return await base.GetAuthenticationStateAsync();
            _nextCheck = now + _checkInterval;
    
            // if we're not authenticated, then just return the default
            var authState = await base.GetAuthenticationStateAsync();
            if (authState.User.Identity == null)
            {
                Trap.Break();
                return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
            }
    
            // they're not authenticated so return what we have.
            if ((!authState.User.Identity.IsAuthenticated) || string.IsNullOrEmpty(authState.User.Identity.Name))
                return new AuthenticationState(new ClaimsPrincipal(authState.User.Identity));
    
            // if the user is not in the database, then just return the default
            var user = await _userManager.FindByNameAsync(authState.User.Identity.Name);
            if (user == null)
            {
                Trap.Break();
                return new AuthenticationState(new ClaimsPrincipal(authState.User.Identity));
            }
    
            // disabled - so anonymous user (system doesn't have the concept of disabled users)
            if (!user.Enabled)
            {
                var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
                return new AuthenticationState(anonymousUser);
            }
    
            // update to the latest claims - only if changed (don't want to call NotifyAuthenticationStateChanged() if nothing changed)
            var listDatabaseClaims = (await _userManager.GetClaimsAsync(user)).ToList();
            var listExistingClaims = authState.User.Claims.Where(claim => AuthorizeRules.AllClaimTypes.Contains(claim.Type)).ToList();
    
            bool claimsChanged;
            if (listExistingClaims.Count != listDatabaseClaims.Count)
                claimsChanged = true;
            else
                claimsChanged = listExistingClaims.Any(claim => listDatabaseClaims.All(c => c.Type != claim.Type));
    
            if (!claimsChanged)
                return authState;
    
            // existing identity, but with new claims
            // the ToList() is to make a copy of the claims so we can read the existing ones and then remove from claimsIdentity
            var claimsIdentity = new ClaimsIdentity(authState.User.Identity);
            foreach (var claim in claimsIdentity.Claims.ToList().Where(claim => AuthorizeRules.AllClaimTypes.Contains(claim.Type)))
                claimsIdentity.RemoveClaim(claim);
            claimsIdentity.AddClaims(listDatabaseClaims);
    
            // set this as the authentication state
            var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
            authState = new AuthenticationState(claimsPrincipal);
            SetAuthenticationState(Task.FromResult(authState));
    
            // return the existing or updates state
            return authState;
        }
    }