If you supply a service via a web application (known as Software as a Service, SaaS for short) then you need to support users that have problems with your site. One useful tool for your support team is to be able to access your service as if you were the customer who is having the problem. Clearly you don’t want to use their password (which you shouldn’t know anyway) so you need another way to do this. In this article I describe what is known as user impersonation and describe one way to implement this feature in an ASP.NET Core web app.
NOTE: User impersonation does have privacy issues around user data, because the support person will be able to access their data. Therefore, if you do use user impersonation you need to either get specific permission from a user before using this feature, or add it to your T&Cs.
I have spent a lot of work on ASP.NET Core authorization features for both my clients and readers, and this article adds another useful feature in to a long series looking at feature and data authorization. The whole series consists of:
- Part 1: A better way to handle authorization in ASP.NET Core – original article.
- Part 2: Handling data authorization in ASP.NET Core and Entity Framework Core.
- Part 3: A better way to handle authorization – six months on – improvements
- Part 4: Building robust and secure data authorization with EF Core.
- Part 5: A better way to handle authorization – refreshing users claims.
- Part 6: Adding user impersonation to an ASP.NET Core web application (this article).
There is an example web application in a GitHub repo called PermissionAccessControl2 which goes with articles 3 to 6. This is an open-source (MIT licence) application that you can clone and run locally. Be default it uses in-memory databases which are seeded with demo data on start-up. That means its easy to run and see how the “user impersonation” feature works in practice.
NOTE: To try out the “user impersonation” you should run the PermissionAccessControl2 ASP.NET Core project. Then you need to log in the SuperAdmin user (email Super@g1.com with password Super@g1.com) and then go to the Impersonation->Pick user. You can see the changes by using the User’s menu, or using one of the Shop features.
TL;DR; – summary
- The user impersonation feature allows a current user, normally a support person, to change their feature and data authorization settings to match another user. This means that the support user will experience the system as if they are the impersonated user.
- User impersonation is useful in SaaS systems for investigating/fixing problems that customers encounter. This is especially true in systems where each organization/user has their own data, as it allows the support person to access the user’s data.
- I add this feature to my “better authorization” system as described in this series, but the described approach can also be applied to ASP.NET Core Identity systems using Roles etc.
- All the code, plus a working ASP.NET Core example is available via the GitHub repo called PermissionAccessControl2. It is open-source.
Setting the scene – what should a “user impersonation” feature do?
User impersonation gives you access to the services and data that a user has. Typical things you might user impersonation for are:
- If a customer is having problems, then user impersonation allows you to access a customer’s data as if you were them.
- If a customer reports that there is a bug in your system you can check it out using their own settings and data.
- Some customers might pay for enhanced support where your staff can to enter data or fix problems that they are struggling with.
NOTE: In the conclusion I list some other benefits that I have found in development and testing.
So, to do any of these things the support person must have:
- The same authentication setting, e.g. ASP.NET Core Roles, that the user has (especially for items 1 and 2) – known as feature authorization, which in my system are called Permission.
- Access to the same data that the user has – known as data authorization, which in my system is called DataKey.
NOTE: Many SaaS systems have separate data per organization and/or per user. This is known as a multi-tenant system and I cover this in Part 2 and Part 4 articles in the “better authorization” series.
But I don’t want to change current user’s “UserId”, which holds a unique value for each user (e.g. a string containing a GUID). By keeping the support user’s UserId I can use it in any logging or tracking of changes to the database. That way if there are any problems you can clearly see who did what.
There is another part of the user’s identity called “Name” which typically holds the user’s email address or name. Because of the data privacy laws such as GDPR I don’t use this in logger or tracking.
So, taking all these parts of the user’s identity here is a list below of what I want to impersonate and what I leave as the original (support) user’s setting:
- Feature authorization, e.g. Roles, Permissions – Use impersonated user’s settings (see note below)
- Data authorization, e.g. data key – Use impersonated user’s settings
- UserId, i.e. unique user value – Keep support user’s UserId
- Name, e.g. their email address – Depends: in my system I keep support user’s UserName
NOTE: In most impersonation cases the feature authorizations should change to settings of user you are impersonating – that way the support user will be see what the user sees. But sometimes it’s useful to keep the feature authorization of the support person who is doing the impersonating – that might unlock more powerful features that will help in quickly fixing some more complex issues. I provide both options in my implementation.
Now I will cover how to add a “user impersonation” feature to an ASP.NET Core web application.
The architecture of my “user impersonation” feature
To implement my “user impersonation” feature I have tapped into ASP.NET Core’s application Cookie events called OnValidatePrincipal (I use this a lot in “better authorization” series). This event happens every HTTP request and provides me with the opportunity to change the user’s Claims (all the user’s settings are held in a Claim class, which are stored as key/value strings in the authentication cookie or token).
My user impersonating code is controlled by the existence of a cookie defined in the class ImpersonationCookie. As shown in the diagram below the code linked to the OnValidatePrincipal event looks for this cookie and goes into impersonation mode while that cookie is present and reverts back to normal if the impersonation cookie is deleted.
I will describe this process in the following stage:
- A look at the impersonation cookie
- A look at the ImpersonationHandler
- The adaptions to the code called by the OnValidatePrincipal event.
- The impersonation services.
- Making the sure that the impersonation feature is robust.
1. A look at the impersonation cookie
I use an ImpersonationCookie to control impersonation: it holds data needed to setup the impersonation and its existence keeps the impersonation going. The code handling the OnValidatePrincipal event can read that cookie via the event’s CookieContext, which includes the HttpContext. The cookie payload (a string) comes from a class called ImpersonationData that holds this data and can convert to/from a string. The three values are:
- The UserId of the user to be impersonated
- The Name of the user to be impersonated (only used to display the name of the user being impersonated)
- A Boolean called KeepOwnPermissions (see next paragraph).
While impersonating a user the support person usually takes on the Permissions of the impersonated user so that the application will react as if the impersonated user is logged in. However, I have seen situations where its useful to have the support’s more comprehensive Permissions, for instance to access extra commands that the impersonated user wouldn’t normally have access to. That is why I have the KeepOwnPermissions value, which if true will keep the support’s Permissions.
All these three values have some (admittedly low) security issues so I use ASP.NET Core’s Data Protection feature to encrypt/decrypt the string holding the ImpersonationData.
2. A look at the ImpersonationHandler
I have put as much of the code that handles the impersonation into one class called ImpersonationHandler. On being created it a) checks if the impersonation cookie exists and b) checks if an impersonation claim is found in the current user’s claims. From these two tests it can work out what state the current user in in. The states are listed below:
- Normal: Not impersonating
- Starting: Starting impersonation.
- Impersonating: Is impersonating
- Stopping: Stopping impersonation
The only time the Permissions and DataKey need to be recalculated is when the state is Starting or Stopping, and the ImpersonationHandler has a property called ImpersonationChange which is true in that case. This minimises calls to recalculation to provide good performance (Note: A recalculate will also happen if you are using the “refreshing user’s claims” feature described in the Part 5 article).
The recalculation of the Permissions and DataKey needs a UserId, and there are there are two public methods, GetUserIdForWorkingOutPermissions and GetUserIdForWorkingDataKey, which provide the correct UserId based on the impersonation state and the “KeepOwnPermissions” (see step 1) setting. (I used two separate methods for the Permissions and DataKey because the “KeepOwnPermissions” will affect the Permissions’ UserId returned but doesn’t affect the DataKey’s UserId).
The other public method needed to set the user claims is called AddOrRemoveImpersonationClaim. Its job is to add or remove the “Impersonalising” claim. This claim is used to a) tell the ImpersonationHandler whether it is already impersonating and b) contains the Name of the user being impersonated, which gives a visual display of what user you are impersonating.
3. The adaptions to the code called by the OnValidatePrincipal event.
Anyone who has been following this series will know that I tap into the authorization cookie OnValidatePrincipal event. This event happens on every HTTP request and allows the claims and the authorization cookie to be changed. For performance reasons you do need to minimise what you do in this event as it is runs so often.
I have already described in detail the code called by the OnValidatePrincipal event here in Part 1. Below is the updated PermissionAccessControl2 code, now with the impersonation added. There are some notes at the end that only describe the parts added to provide the impersonation feature.
public async Task ValidateAsync(CookieValidatePrincipalContext context) { var extraContext = new ExtraAuthorizeDbContext( _extraAuthContextOptions, _authChanges); var rtoPLazy = new Lazy<CalcAllowedPermissions>(() => new CalcAllowedPermissions(extraContext)); var dataKeyLazy = new Lazy<CalcDataKey>(() => new CalcDataKey(extraContext)); var originalClaims = context.Principal.Claims.ToList(); var impHandler = new ImpersonationHandler(context.HttpContext, _protectionProvider, originalClaims); var newClaims = new List<Claim>(); if (originalClaims.All(x => x.Type != PermissionConstants.PackedPermissionClaimType) || impHandler.ImpersonationChange || _authChanges.IsOutOfDateOrMissing(AuthChangesConsts.FeatureCacheKey, originalClaims.SingleOrDefault(x => x.Type == PermissionConstants.LastPermissionsUpdatedClaimType)?.Value, extraContext)) { var userId = impHandler.GetUserIdForWorkingOutPermissions(); newClaims.AddRange(await BuildFeatureClaimsAsync(userId, rtoPLazy.Value)); } if (originalClaims.All(x => x.Type != DataAuthConstants.HierarchicalKeyClaimName) || impHandler.ImpersonationChange) { var userId = impHandler.GetUserIdForWorkingDataKey(); newClaims.AddRange(BuildDataClaims(userId, dataKeyLazy.Value)); } if (newClaims.Any()) { newClaims.AddRange(RemoveUpdatedClaimsFromOriginalClaims( originalClaims, newClaims)); impHandler.AddOrRemoveImpersonationClaim(newClaims); var identity = new ClaimsIdentity(newClaims, "Cookie"); var newPrincipal = new ClaimsPrincipal(identity); context.ReplacePrincipal(newPrincipal); context.ShouldRenew = true; } extraContext.Dispose(); }
The changes to add impersonalisation to the ValidateAsync code are:
- Lines 10 to 11: I create a new ImpersonationHandler to use throughout the method
- Line 16 and 27: the “impHandler.ImpersonationChange” property will be true if impersonation is starting or stopping, which are the times where the user’s claims need to be recalculated.
- Lines 22 and 29: the UserId to calculate the Permissions and DataKey can alter if in impersonation mode. These impHandler methods controls what UserId value (support user’s UserId or the impersonated user’s UserId).
- Line 37: working out the impersonation state relies on having an “Impersonalising” claim while impersonation is active. This method makes sure the “Impersonalising” claim is added on starting impersonation, or removes the impersonation claim when stopping impersonation.
The other things to note is the code above contains the “refresh claims” feature described in the Part 5 article. This means that while impersonating a user the claims will be recalculated. The ImpersonationHandler is designed to handle this, and it will continue to impersonate a user during a “refresh claims” event.
NOTE: Even if you don’t need the “refresh claims” feature you might like to take advantage of the “How to tell your front-end that the Permissions have changed” I describe in Part 5. This allows a front-end framework, like React.js, Angular.js, Vue.js etc. to detect that the Permissions have changed so that it can show the appropriate displays/links.
4. The Impersonation services
The ImpersonationService class has two methods: StartImpersonation and StopImpersonation. They have some error checks, but the actual code is really simple because all they do is create or delete the Impersonation Cookie respectively. The code for both methods are shown below
public string StartImpersonation(string userId, string userName, bool keepOwnPermissions) { if (_cookie == null) return "Impersonation is turned off in this application."; if (!_httpContext.User.Identity.IsAuthenticated) return "You must be logged in to impersonate a user."; if (_httpContext.User.Claims.GetUserIdFromClaims() == userId) return "You cannot impersonate yourself."; if (_httpContext.User.InImpersonationMode()) return "You are already in impersonation mode."; if (userId == null) return "You must provide a userId string"; if (userName == null) return "You must provide a username string"; _cookie.AddUpdateCookie(new ImpersonationData( userId, userName, keepOwnPermissions) .GetPackImpersonationData()); return null; } public string StopImpersonation() { if (!_httpContext.User.InImpersonationMode()) return "You aren't in impersonation mode."; _cookie.Delete(); return null; }
The only thing of note in this code is the keepOwnPermissions property in the StartImpersonation method. This controls whether the impersonated user’s Permissions or current support user’s Permissions are used.
I have also added two Permissions: Impersonate and ImpersonateKeepOwnPermissions which are used in the impersonationController to control who can access the impersonation feature.
5. Making the sure that the impersonation feature is robust
There are a few things that could cause problems or security risks, such as someone logging out while in impersonation mode and the next user logging in and inheriting the impersonation mode. For these reasons I set up a few things to fail safe.
Firstly, I set the CookieOptions as shown below, which makes the cookie secure and has a lifetime of the client browser.
_options = new CookieOptions { Secure = false, //ONLY FOR DEMO!! HttpOnly = true, IsEssential = true, Expires = null, MaxAge = null };
Here is notes on these options
- Line 3: In real life you would want this to be true because you would be using HTTPS, but for this demo I allow HTTP
- Line 4: This says JavaScript can’t read it
- Line 5: This is an essential cookie, and setting this to true which means it is allowed without user clearance.
- Lines 6 and 7: These two settings make the cookie a session cookie, which means it is deleted when the client (e.g. browser) is shut down.
The other thing I do is delete the impersonation when the user logs out. I do this by capturing another event called OnSigningOut to delete the impersonation cookie.
services.ConfigureApplicationCookie(options => { options.Events.OnValidatePrincipal = authCookieValidate.ValidateAsync; //This ensures the impersonation cookie is deleted when a user signs out options.Events.OnSigningOut = authCookieSigningOut.SigningOutAsync; });
Finally I update the _LoginPartial.cshtml to make it clear you are in impersonation mode and who you are impersonating. I replace the “Hello @User.Identity.Name” with “@User.GetCurrentUserNameAsHtml()”, which shows “Impersonating joe@gmail.com” with the bootstrap class, text-danger. Here is the code that does that.
public static HtmlString GetCurrentUserNameAsHtml(this ClaimsPrincipal claimsPrincipal) { var impersonalisedName = claimsPrincipal.GetImpersonatedUserNameMode(); var nameToShow = impersonalisedName ?? claimsPrincipal.Claims.SingleOrDefault(x => x.Type == ClaimTypes.Name)?.Value ?? "not logged in"; return new HtmlString( "<span" + (impersonalisedName != null ? " class=\"text-danger\">Impersonating " : ">Hello ") + $"{nameToShow}</span>"); }
The downsides of the User Impersonation feature
There aren’t any big extra performance issues with the impersonation feature, but of course the use of the authorization cookie OnValidatePrincipal event does add a (very) small extra cost on every HTTP request.
One downside is that it’s quite complex with various classes, services and startup code. If you want a simpler impersonation approach I would recommend Max Vasilyev’s “User impersonation in Asp.Net Core” article that directly uses ASP.NET Core’s Identity features.
Another downside is the security issue of a support user being able to see and change a user’s data. I strongly recommend adding logging around the impersonation feature and also marking all data with the person who created/edited it using the UserId, which I make sure is the real (support) user’s UserId. That way if there is a problem you can show who did what.
Conclusion
Over the years I have found user impersonation really useful (I wrote about this on ASP.NET MVC back in 2015). This article now provides user impersonation for ASP.NET Core and also fits in with my “better authorization” approach. All the code, with a working example web application, is available in the GitHub repo called PermissionAccessControl2.
As well as allowing you to provide good help to your users I have found the impersonation feature great for helping in early stages of development. For instance, say you haven’t got the user support safe enough for your SaaS user to use, then you can have your support people manage that until you have implemented a version suitable for SaaS users.
User impersonation is also really useful for live testing, because you can quickly swap between different types of users to check that the system is working as you designed (You might also like to look at my “Getting better data for unit testing your EF Core applications” article on how to extract and anonymize personal data from a live system too).
You may not want to use my “better authorization” approach, but use ASP.NET Core’s Roles. In that case the approach I use, with the authorization cookie OnValidatePrincipal event, is still valid. Its just that you need to alter the Roles Claim in the user’s Claims. The ImpersonationHandler class will still be useful for you, as it simply detects the impersonalisation and provides the UserId of the impersonalised user – from there you can look up the impersonalised user’s Roles and replace them in the user’s claims.
Happy coding!
If you have a ASP.NET Core or Entity Framework Core problem that you want help on then I am available as a freelance contractor. Please send me a contact request via my Contact page and we can talk some more on Skype.