0

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?

David Thielen
  • 28,723
  • 34
  • 119
  • 193
  • Middleware can be executed every time an http request is sent, so I think it should be a good choice to use middleware to retrieve the user's claims and enabled status from the database every time the page loads. – Jason Pan Jun 26 '23 at 09:53
  • @JasonPan Without knowing what the Identity library is expecting I don't see how that would work. In addition one of my problems is I need the library to-re-read the claims and I don't think that can be accomplished with middleware as the request presently is just not made. Also... I think this is DB requests, not http requests. – David Thielen Jun 26 '23 at 11:47
  • We also could inject dbcontext inside the middleware right? – Jason Pan Jun 26 '23 at 13:28
  • @JasonPan Yes I can. But without knowing what the Identity library is expecting, it's not a solid approach. And I think most of what I need is to have the Identity library take additional action and middleware can't do that. – David Thielen Jun 26 '23 at 14:26
  • Hello David, I also find you have post same issue in Q&A, in your business, is there any manual or other system operations that modify the identity-related tables in the database? – Jason Pan Jun 28 '23 at 01:45
  • If yes, you can [check this thread and learn how to use sqldependency](https://learn.microsoft.com/en-us/answers/questions/755349/asp-net-core-5-0-signalr-with-sql-dependency) to update your custom AuthenticationStateProvider. – Jason Pan Jun 28 '23 at 01:52
  • @JasonPan I think that link will let me know if the DB is changed by someone else. But that is not a worry as I'm the only one changing it. That would be good code for the Identity library to implement but based on the issue I'm having, it looks like they did not do this. I think I need to find a way to add to what the Identity library is doing as it builds the context.User and Task for each page. I'm hoping there's a supported way to do this as opposed to re-writing the key code that handles this part. – David Thielen Jun 28 '23 at 03:53
  • @JasonPan Is there a supported middleware API to do this? If so, can you give me a link to the documentation? TIA – David Thielen Jun 28 '23 at 03:54
  • Don't know if this helps you but I'm using Blazor too with custom user management on server, and I've used cookies w/o ASP.NET Identity. In server, with cookie auth, you can get an event to *replace* the principal coming from blazor, so you only trust the id/name, not the other claims. On Blazor side if you keep the instance of HttpClient, it will keep the cookie automatically. Here are some extracts: https://pastebin.com/raw/6j1H7MnQ This is to enforce proper security. Then you can add some server => blazor - (like SignalR) event to improve changes propagation – Simon Mourier Jul 07 '23 at 09:54

1 Answers1

0

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;
    }
}
David Thielen
  • 28,723
  • 34
  • 119
  • 193