4

ASP.NET Core: Three ways to refresh the claims of a logged-in user

 1 year ago
source link: https://www.thereformedprogrammer.net/asp-net-core-three-ways-to-refresh-the-claims-of-a-logged-in-user/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

ASP.NET Core: Three ways to refresh the claims of a logged-in user

An ASP.NET Core application uses claims to hold the logged-in user’s authentication and authorization data. These claims are created on login and stored in a cookie or a JWT Token for quick access. This makes access to the claims is very fast, but downside is claims are fixed. Most of the time the “fixed claims” approach works fine, but there are some circumstances where you might need to update the user’s claims.

This article describes three different ways to dynamically change a user’s claims for cookie and/or JWT Token authentication. The article also introduces some of the deeper parts of ASP.NET Core’s authorization and middleware, which might help you in understanding how ASP.NET Core works.

NOTE: This article covers some similar topics in the Advanced techniques around ASP.NET Core Users and their claims, but includes new approaches provided by a new distributed cache Net.DistributedFileStoreCache library (referred to as FileStore cache in this article). The first example has a small improvement, while the other two approaches are new and work with JWT Token authentication.

This article is part of the series that covers .NET multi-tenant applications in general. The other articles in “Building ASP.NET Core and EF Core multi-tenant apps” series are:

TL;DR; – Summary of this article

  • ASP.NET Core creates HttpContext.User on login, which contains user information (like their name and email) in claims. The User, with its claims, is stored in a cookie or a JWT Token for quick access. You can think of the cookie / JWT Token as a kind of cache.
  • If you have data a) is used in almost every HTTP request, and b) it takes some time calculate, then it’s a good candidate to calculate/add it as a claim during the login. See this article on how to do add your own claims on login.
  • By default, the claims in a logged-in user won’t change until they log out and log back in again. Normally the “fixed claims” is fine but have various situations where I need to update a user’s claims around managing multi-tenant users.
  • Therefore, I have needed to create ways to refresh the claims of a logged-in user. In this article I describe three approaches:
    • 1. Update user claims via cookie event: This is a relatively easy way to update the user’s claims when using is the standard way to update an already logged-in user’s claims, but it only works for cookie authentication – see this section on this approach.
    • 2. Replace a user claim on a change: This uses middleware to update a claim when the logged-in claim is out of date. This approach it works with both cookie and cookie authentication – see this section on this approach.
    • 3. On-demand add a new claim: This uses middleware to add a new claim not already in your JWT Token or Cookie. This is useful if you have secret / personal data that you don’t want in a JWT Token because the data isn’t encrypted – see this section on this approach.
  • All these examples rely on a distributed cache called Net.DistributedFileStoreCache I created for these types of situations. This get a cache value in ~25 nanoseconds, but adding a cache value is slow-ish ( > 1 milliseconds). The very fast cache read means using these approaches won’t slow down your application.

Setting the scene – three types of dynamically updating a logged-in claims

I have created the AuthPermissions.AspNetCorelibrary (shortened to AuthP in this article) which contains a) an improved ASP.NET Core Roles authorization system and b) features to help create an ASP.NET Core multi-tenant database system. The AuthP’s improved Roles authorization adds a Roles/Permissions claim and if the multi-tenant feature is activated, then an DataKey claim is also added.

In certain circumstances these AuthP’s Roles/Permissions and DataKey claims may change, and to handle this I have developed code to dynamically change a user’s claims. Here are two that I have found:

  • In an AuthP multi-tenant application there is code to move a tenant’s data from one database to another, thus changing tenant DataKey. This requires the DataKey claim of all the user’s linked to the moved tenant. You can try this out on AuthP’s Example4 hierarchical multi-tenant application and Authp’s Example6 sharding multi-tenant application.
  • The AuthP library version of ASP.NET Core Roles allows a Role to be dynamically changed, which means that an admin user can what pages / Web APIs are in a Role. If a Role is changed there might be security issues, so the user’s Roles/Permissions claim needs updating. You can try this out in the AuthP’s Example2 WebApi application that uses the JWT Token authentication.
  • The third approach is useful if you need a secret or personal value claim when using JWT Token authentication. You shouldn’t be added to the JWT Token because the token data isn’t encrypted. For instance, various personal privacy laws stop you from adding Personal Identifiable Information (PII) values in a JWT Token. You can try this out in the AuthP’s Example2 WebApi application that uses the JWT Token authentication.

Introducing the three examples of updating a user’s claims

The three examples of updating a user’s claims use different approaches to implementation. This allows you to choose the approach that works for you, but even if you never need these approaches seeing how they use events and ASP.NET Core middleware might help you understand the ASP.NET Core code a bit more.

The three approaches in this article assume you have added extra claims to your users on login. This is  described in this section of the article called “Advanced techniques around ASP.NET Core Users and their claims”. I recommend you read this article if you aren’t aware how to add extra claims to a user on login.

All the solutions rely on a distributed cache called Net.DistributedFileStoreCache I created for these types of situations (can find out more about the FileStore cache in this article). The FileStore cache’s key feature is that it as a very fast read time, measured in nanoseconds, which is important if you want application to be fast because each implementation is called on every HTTP request. It also a distributed cache, so it will work on web sites using multiple instances.

Here is a list of the three examples with a quick summary, their pros and cons and a comment on performance:

  1. Update user claims via cookie event: Cookie authentication only.
    1. Summary: This is the standard way to update an already logged-in user’s claims.
    1. Pros: Can handle any type of change because it can change all the claims.
    1. Cons: Doesn’t work with JWT Token authentication
    1. Performance: It re-calculate all the extra claims, but only for logged-in users. Very efficient as the cookie is updated to the new claims.
  2. Replace a user claim on a change: JWT Token and cookie authentication
    1. Summary: This uses middleware to update a claim when the logged-in claim is out of date.
    1. Pro: Can work with both JWT Token and cookie authentication.
    1. Cons: Could need a big cache file if lots of users.
    1. Performance: It re-calculates one claim for all users, not just logged-in users. If large number of users, then this can be slower than the third example.
  3. On-demand add a new claim: JWT Token and cookie authentication
    1. Summary: This adds a new claim not already in your JWT Token or Cookie.
    1. Pro: Hide secret data, e.g. you shouldn’t add a Personal Identifiable Information (PII) claim when using JWT Token.
    1. Cons: None
    1. Performance: It has a very good performance because the claim only calculated if a user is logged in, but it would be slower than example 2 if a database change altered many users.

1. Update user claims via cookie event: Cookie authentication only

This example only works with cookie authentication only but its fairly simple and is fast, i.e. it doesn’t add much extra time to each HTTP request and It’s also quite easy to adapt to different situations. The downside is you can’t use this with a JWT Token.

The two parts of this implementation are:

  • Detect Change: The code links to EF Core’s StateChanged event and detects a change to the entries that would change the claim. On such an event it writes the current UTC DateTime to the “LastChanged” entry in the cache – see the left side of the diagram below.
  • Apply to User: The code links to the cookie’s OnValidatePrincipal event and if the user’s “LastChanged” claim DateTime is older that the “LastChanged” DateTime from the cache,  then it will update the user’s claims and also create a new authentication cookie – see the right side of the diagram below.

The figure below shows how this example works.

CookieAuthenticationTenantChange.png

The main code to implement contains three pieces of code:

  1. The event code to detect the database changes that require an update to user’s claims
  2. The GlobalChangeTimeService which sets / gets the “LastChanged” entry
  3. The event code called by cookie’s OnValidatePrincipal to check / update a user’s claims

With two support services

Let’s now look at the main code:

1.3 The event code to detect the database changes

I’m using EF Core which has a several events to track what is happing to the database. In this case I used EF Core’s ChangeTracker.StateChanged (see this useful article about EF Core events). The code can be found in the TenantKeyOrShardChangeService class inherits the IDatabaseStateChangeEvent interface and much be register via the ASP.NET Core DI provider. The constructor of the application’s DbContext contains an extra parameter containing an IEnumerable<IDatabaseStateChangeEvent> that contains any registered classes that have the IDatabaseStateChangeEvent interface.

In this example I am looking for two changes to the Tenant entity:

  • The ParentDataKey property being modified – this would change the DataKey claim, which defines the tenant filer key (lines 13 to 14).
  • The DatabaseInfoName property being modified – this is used in sharding and would change the DatabaseInfoName claim, which defines what database to use (lines 15 to 16).

The code below is taken from the TenantKeyOrShardChangeService class and shows the code to register its event listener, and the actual event handler.

public void RegisterEventHandlers(
AuthPermissionsDbContext context)
{
context.ChangeTracker.StateChanged +=
RegisterKeyOrShardChange;
}
private void RegisterKeyOrShardChange(object sender,
EntityStateChangedEventArgs e)
{
if (e.Entry.Entity is Tenant
&& e.NewState == EntityState.Modified
&& (e.Entry.OriginalValues[nameof(Tenant.ParentDataKey)] !=
e.Entry.CurrentValues[nameof(Tenant.ParentDataKey)] ||
e.Entry.OriginalValues[nameof(Tenant.DatabaseInfoName)] !=
e.Entry.CurrentValues[nameof(Tenant.DatabaseInfoName)])
)
{
_globalAccessor.SetGlobalChangeTimeToNowUtc();
}
}

The SetGlobalChangeTimeToNowUtc method is called if a modification is found. This method comes from the GlobalChangeTimeService class which is described next section.

1.2 The GlobalChangeTimeService class

The GlobalChangeTimeService class provides a thin wrapper around the FileStore cache and has two methods that set and get the “ChangeAtThisTime”  entry in the FileStore cache. Also handles the DateTime conversions. The methods are:

  • SetGlobalChangeTimeToNowUtc(), which sets the cache entry with the name “ChangeAtThisTime” with a value of DateTime.UtcNow.DateTimeToTicks()
  • GetGlobalChangeTimeUtc(), which returns a DateTime from the cache entry with the name “ChangeAtThisTime”, or DateTime.MinValue if that entry hasn’t been set.

1.3 The event code called by cookie’s OnValidatePrincipal

The event code can be found in the SomethingChangedCookieEvent class and needs to register it in your Program class using the code shown below

builder.Services.ConfigureApplicationCookie(options =>
{
options.Events.OnValidatePrincipal =
SomethingChangedCookieEvent
.UpdateClaimsIfSomethingChangesAsync;
});

The code shown below comes from the SomethingChangedCookieEvent class. The steps are:

  1. Lines 4 to 7: Gets the current user’s claims and the latest time a Global Change was found.
  2. Lines 9 to 11: If the user’s claims are older that the Global Change time it needs to update the user’s claims.
  3. Lines 19 to 25: This updates the AuthP’s claims via its AuthP’s ClaimsCalculator which will recalculate the extra claims, including the claim that holds the last time the claims were updated.
  4. Lines 27 to 29: This a) creates a new User (of type ClaimsPrincipal) for this HTTP request, and b) in line 30 it says the authentication cookie should be updated with these new claims.
public static async Task UpdateClaimsIfSomethingChangesAsync
(CookieValidatePrincipalContext context)
{
var originalClaims = context.Principal.Claims.ToList();
var globalTimeService = context.HttpContext.RequestServices
.GetRequiredService<IGlobalChangeTimeService>();
var lastUpdateUtc = globalTimeService.GetGlobalChangeTimeUtc();
if (originalClaims.
GetClaimDateTimeTicksValue(EntityChangeClaimType)
< lastUpdateUtc)
{
//Need to refresh the user's claims
var userId = originalClaims.GetUserIdFromClaims();
if (userId == null)
//this shouldn't happen, but best to return
return;
var claimsCalculator = context.HttpContext.RequestServices
.GetRequiredService<IClaimsCalculator>();
var newClaims = await claimsCalculator
.GetClaimsForAuthUserAsync(userId);
//Copy over unchanged claims
newClaims.AddRange(originalClaims.
RemoveUpdatedClaimsFromOriginalClaims(newClaims));
var identity = new ClaimsIdentity(newClaims, "Cookie");
var newPrincipal = new ClaimsPrincipal(identity);
context.ReplacePrincipal(newPrincipal);
context.ShouldRenew = true;
}
}
private static IEnumerable<Claim>
RemoveUpdatedClaimsFromOriginalClaims(
this List<Claim> originalClaims, List<Claim> newClaims)
{
var newClaimTypes = newClaims.Select(x => x.Type);
return originalClaims
.Where(x => !newClaimTypes.Contains(x.Type));
}

2. Replace a user claim on a change: JWT Token and cookie authentication

This approach uses middleware to replace a claim, not by updating the user’s claims in the first approach, but by updating the current HTTP User on every HTTP request. The pro of this approach is that it works for JWT Token and cookie authentication, but the con is needs code to run on every HTTP request which cause some performance problems. I only considered this approach after I had created a the FileStore distributed cache, as it has a read time of ~25 nanoseconds.

Like the first example there are two parts to this approach:

  • Detect Change: The code links to EF Core’s StateChanged event and detects a change to the entries that would change the claim. In this case it calculates the new claim value for each affected user and stores each claim value in the cache – see the left side of the diagram below.
  • Apply to User: The extra middleware code runs after the authorization middleware and if a new claim for the current user is found in the cache, then it replaces the out-of-date claim and creates a new ClaimsPrincipal – see the right side of the diagram below.

The figure below shows how this example works.

MiddlewareUpdateAuthorization.png

The main code to implement contains pieces of code:

  1. The event code to detect the database changes and add replacement claims to the cache.
  2. The middleware which updates the HTTP User if a replacement claim is found in the cache.

It also uses the Net.DistributedFileStoreCache library to provide a cache with a very fast read.

Let’s now look at the main code:

2.1 The event code to detect the database changes

In the first example the database event code just had to detect a change, so it’s used EF Core’s StateChanged event. In this example we need to detect a change and then calculate the updated claim once the database has been updated, which makes the code more complex. You can find the code in the RoleChangedDetectorService class, but because the code is quite long, I will describe the various parts with pseudo-code

RoleChangedDetectorServicePseudoCode-1-1024x685.png

The code in the AddPermissionOverridesToCache method calculates the claim for each effected Users and stores the new claim in the FileStore cache with a key containing the userId of user that the claim applies to.

The RoleChangedDetectorService inherits the the IDatabaseStateChangeEvent interface and much be register via the ASP.NET Core DI provider. The constructor of the application’s DbContext contains an extra parameter containing an IEnumerable<IDatabaseStateChangeEvent> that contains any registered classes that have the IDatabaseStateChangeEvent interface.

2.2. The middleware which updates the HTTP User outdated claim

The middleware code can be found in the UpdateRoleClaimMiddleware class which both provides the extension method to register the middleware, and the code that will be called on every HTTP request. Here is the code in you need to your Program class to add this code into the into the middleware pipeline (see highlighted line) – note that the UsePermissionsChange method must come after the UseAuthorization.

//other code left out
app.UseAuthentication();
app.UseAuthorization();
app.UsePermissionsChange();
//other code left out

The actual method in the UpdateRoleClaimMiddleware class that updates a claim if that claim has been updated is shown below, with this list explain what each part does and where that code is found:

  1. Lines 4 to 5: It only looks at logged-in user. Not logged-in requests are ignored.
  2. Lines 12 to 13: This looks for a replacement value for its Permissions’ claim value. If its null, then there no replacement and the current User is used.
  3. Lines 17 to 23: This gets the current User’s claims and replaces the Permissions’ claim with a new claim where its value is taken from the found cache.
  4. Lines 25 to 28:  This creates a new ClaimsIdentity containing the updated claim. This new user sent back to the outer code which assigns it to the HTTPContext’s  context.User property.
public static async Task<ClaimsPrincipal> ReplacePermissionsMiddleware(
IServiceProvider serviceProvider, ClaimsPrincipal user)
{
var userId = user.GetUserIdFromUser();
if (userId != null)
{
//There is a logged-in user, find if the
//FileStore cache contains a new Permissions claim
var fsCache = serviceProvider.GetRequiredService
<IDistributedFileStoreCacheClass>();
var replacementPermissions = await fsCache.GetAsync(
userId.FormReplacementPermissionsKey());
if (replacementPermissions != null)
//Replacement permissions claim found, so update the User
var updateClaims = user.Claims.ToList();
var found = updateClaims.FirstOrDefault(c =>
c.Type == PermissionConstants.PackedPermissionClaimType);
updateClaims.Remove(found);
updateClaims.Add(new Claim(
PermissionConstants.PackedPermissionClaimType,
replacementPermissions));
var appIdentity = new ClaimsIdentity(
updateClaims,
user.Identity!.AuthenticationType);
return new ClaimsPrincipal(appIdentity);
}
}
return null; //no change to the current user
}

The ReplacePermissionsMiddleware method is called from the code that registers the middleware. If the method returns null, then the current HTTP User is unchanged as there was no update. If there is an update to the User’s claims the method returns a new User (ClaimsPrincipal), which is then assigned to the HttpContext.User property.

3. On-demand add a new claim in middleware: JWT Token and cookie

The final example is similar to the second example as its uses middleware, but it’s the middleware that calculates the claim rather than the database trigger. This approach is good when you have secret / sensitive claims that you don’t want to put in a JWT Token, because a JWT Token isn’t encrypted. For instance, various privacy rules say that Personal Identifiable Information (PII), e.g. user’s email address, when using JWT Token.

The two parts to this approach are:

  • On-demand Claim: The middleware code will look for claim value from the cache. If the cache value is empty, then it will access the database to get the latest value and store that in the cache. Finally, the code will add the claim to the HTTP User.
  • Detect Change: If a database change alters the cache value, then it removes any existing cache value, thus causing the middleware to recalculate the claim value for the user.

The figure below shows how this example works.

MiddlewareOnDemandClaim-1024x355.png

The main code to implement contains pieces of code:

  1. The middleware which adds a new claim to the HTTP User from the cache.
  2. The event code to detect the database changes and removes the cache value.

It also uses the Net.DistributedFileStoreCache library to provide a cache with a very fast read.

Let’s now look at the main code:

3.1 The middleware which adds a new claim

The middleware code can be found in the AddEmailClaimMiddleware class which both provides the extension method to register the middleware, and the code that will be called on every HTTP request. Here is the code in you need to your Program class to add this code into the into the middleware pipeline (see highlighted line) – note that the UseAddEmailClaimToUsers method must come after the UseAuthorization.

//other code left out
app.UseAuthentication();
app.UseAuthorization();
app.UseAddEmailClaimToUsers();
//other code left out

The actual method in the AddEmailClaimMiddleware class that adds the new claim is shown below, with this list explain what each part does and where that code is found:

  1. Lines 4 to 5: It only looks at logged-in user. Not logged-in requests are ignored.
  2. Lines 11 to 13: This looks for a replacement value for its Email claim value. If its null, then it needs to access the database for the latest email of the current HTTP User.
  3. Lines 16 to 26: This gets the current user email from the database and adds a cache entry so that the next time it doesn’t have to query the database.
  4. Lines 30 to 35:  This creates a new ClaimsIdentity containing the added email claim. This new user sent back to the outer code which assigns it to the HTTPContext’s  User property.
public static async Task<ClaimsPrincipal> AddEmailClaimToCurrentUser(
IServiceProvider serviceProvider, ClaimsPrincipal user)
{
var userId = user.GetUserIdFromUser();
if (userId != null)
{
//There is a logged-in user, so cache contains a new Permissions claim
var fsCache = serviceProvider.GetRequiredService
<IDistributedFileStoreCacheClass>();
var usersEmail = await fsCache.GetAsync(
userId.FormAddedEmailClaimKey());
if (usersEmail == null)
{
//Not set up yet, get the user's email
var context = serviceProvider.GetRequiredService
<AuthPermissionsDbContext>();
usersEmail = context.AuthUsers
.Where(x => x.UserId == userId)
.Select(x => x.Email).FirstOrDefault();
if (usersEmail == null)
return null; //shouldn't happen, but could in certain updates
//Add to cache so next time it will be quicker
await fsCache.SetAsync(userId.FormAddedEmailClaimKey(), usersEmail);
}
//We need to add the Email from the cache
var updateClaims = user.Claims.ToList();
updateClaims.Add(new Claim(ClaimTypes.Email, usersEmail));
var appIdentity = new ClaimsIdentity(updateClaims,
user.Identity!.AuthenticationType);
return new ClaimsPrincipal(appIdentity);
}
return null; //no change to the current user
}

The AddEmailClaimToCurrentUser method is called from the code that registers the middleware. If the user is logged in the method will return a new User (ClaimsPrincipal) with the email claim added, which is then assigned to the HttpContext.User property. For user that aren’t logged in, then the method returns null, which means the current User should be used.

3.2. The event code to detect the change of a user’s email

The database event code is very simple – if a user’s email has changed, then make sure the cache entry linked to the user’s email is removed. That means the next time that user accesses the web app the AddEmailClaimMiddleware will recalculate the user’s email (and add the cache entry).

The code below come from the EmailChangeDetectorService class.

public void RegisterEventHandlers(AuthPermissionsDbContext context)
{
context.ChangeTracker.StateChanged +=
delegate(object sender, EntityStateChangedEventArgs e)
{
if (e.Entry.Entity is AuthUser user
&& e.NewState == EntityState.Modified
&& e.Entry.OriginalValues[nameof(AuthUser.Email)]
!= e.Entry.CurrentValues[nameof(AuthUser.Email)]
)
//Email has changed, so we remove the current cache value
_fsCache.Remove(user.UserId.FormAddedEmailClaimKey());
};
}

The EmailChangeDetectorService inherits the IDatabaseStateChangeEvent interface, and much be register via the ASP.NET Core DI provider. The constructor of the application’s DbContext contains an extra parameter containing an IEnumerable<IDatabaseStateChangeEvent> that contains any registered classes that have the IDatabaseStateChangeEvent interface.

Conclusions

This article gives a detailed looks at three ways to update the claims of a logged-in user to a ASP.NET Core application. The three examples provide a wide range of approaches that cover most claim update situations. And each example can be tweaked to make them perform better for specific changes: for instance, the first example updates all logged-in users, but I could be changed to be more selective on which users are updated.    

I start with the cookie-only approach which I used many years ago on my first ASP.NET Core application for a client. Its pretty simple and performs very well when changes are infrequent. I have used this for many years, including in client’s applications, and it well tried and tested. The only change I have added in this article is the use of the FileStore distributed cache, which makes it a bit faster.

For many years I didn’t have a viable solution for changing the claims when using JWT Token authentication, which is a problem as many frontend frameworks work better with a JWT Token. It wasn’t until I build the FileStore distributed cache, which has a very fast read time, e.g. ~25 nanoseconds (see FileStore distributed cache benchmark for full data), that I found an approach that has a good per-HTTP request performance.

Examples 2 and 3 offer two approaches that work with both cookie and JWT Token authentication, with their implementations almost the reverse of each other: example 2 calculates the updated claims within the database event, while example 3 calculate the claim in the middleware code. These different approaches also provide different performance parameters – see the performance section in the list of the three approaches in introduction section for more on this.

NOTE: These three examples are in the AuthP’s repro examples and be tried by running various ASP.NET Core applications. The Update user claims via cookie event version can be found in AuthP’s Example4 hierarchical multi-tenant application and Authp’s Example6 sharding multi-tenant application. The last two examples work with JWT Token, so both middleware versions are in the AuthP’s Example2 WebApi application that uses the JWT Token authentication.

Happy coding.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK