User impersonation in Asp.Net Core

I’ve blogged about user impersonation in Asp.Net MVC three years ago and this article has been in top 10 of most visited pages in my blog.

Since Asp.Net Core is out and more or less ready for production, it is time to review the article and give guidance how to do the same thing in Core. Approach have not changed at all, we do same steps as before, only some API been changed and it took me a bit of time to figure out all the updated API parts.

Impersonation Process

Impersonation is when an admin user is logged in with the same privileges as a user, but without knowing their password or other credentials. I’ve used this in couple applications and it was invaluable for support cases and debugging user permissions.

The process of impersonation in Asp.Net core is pretty simple – we create cookie for potential user and give this to the current admin user. Also we need to add some information to the cookie that impersonation is happening and give admin a way to go back to their own account without having to log-in again.

In my previous article I’ve used a service-layer class to do impersonation. This time I’m doing everything in a controller just because it is easier. However please don’t be alarmed by this – I’m still a big believer of thin controllers – they should accept requests and hand-over the control to other layers. So if you feel you need to add an abstraction layer – this should be pretty simple. I’m not doing this here for simplicity sake.

So this is the part that starts the impersonation:

[Authorize(Roles = "Admin")] // <-- Make sure only admins can access this 
public async Task<IActionResult> ImpersonateUser(String userId)
{
    var currentUserId = User.GetUserId();

    var impersonatedUser = await _userManager.FindByIdAsync(userId);

    var userPrincipal = await _signInManager.CreateUserPrincipalAsync(impersonatedUser);

    userPrincipal.Identities.First().AddClaim(new Claim("OriginalUserId", currentUserId));
    userPrincipal.Identities.First().AddClaim(new Claim("IsImpersonating", "true"));

    // sign out the current user
    await _signInManager.SignOutAsync();

    await HttpContext.Authentication.SignInAsync(cookieOptions.ApplicationCookieAuthenticationScheme, userPrincipal);

    return RedirectToAction("Index", "Home");
}

In this snippet you can see that I’m creating a ClaimsPrincipal for impersonation victim and adding extra claims. And then using this claims principal to create auth cookie.

To de-impersonate use this method:

[Authorize]
public async Task<IActionResult> StopImpersonation()
{
    if (!User.IsImpersonating())
    {
        throw new Exception("You are not impersonating now. Can't stop impersonation");
    }

    var originalUserId = User.FindFirst("OriginalUserId").Value;

    var originalUser = await _userManager.FindByIdAsync(originalUserId);

    await _signInManager.SignOutAsync();

    await _signInManager.SignInAsync(originalUser, isPersistent: true);

    return RedirectToAction("Index", "Home");
}

Here we check if impersonation claim is available, then get original user id and login with that user. You will probably ask what is .IsImpersonating() method? Here it is along with GetUserId() method:

public static class ClaimsPrincipalExtensions
{
    //https://stackoverflow.com/a/35577673/809357
    public static string GetUserId(this ClaimsPrincipal principal)
    {
        if (principal == null)
        {
            throw new ArgumentNullException(nameof(principal));
        }
        var claim = principal.FindFirst(ClaimTypes.NameIdentifier);

        return claim?.Value;
    }

    public static bool IsImpersonating(this ClaimsPrincipal principal)
    {
        if (principal == null)
        {
            throw new ArgumentNullException(nameof(principal));
        }

        var isImpersonating = principal.HasClaim("IsImpersonating", "true");

        return isImpersonating;
    }
}

Dealing with Security Stamp Invalidation and cookie refreshing

Now this bit I always forget. When Security Stamp validation and cookie refreshing kicks in, it will erase the custom claims we’ve put into the cookie when started impersonation. This is a subtle bug because this happens only every 30 minutes by default and I’m never using impersonation long enough to experience this bug. However it happens and I better update this post, since I have a solution.

In you Startup class inside ConfigureServices put this block of code:

services.Configure<IdentityOptions>(options =>
{
    // this sets how often the cookie is refreshed. Adjust as needed.
    options.SecurityStampValidationInterval = TimeSpan.FromMinutes(10);
    options.OnSecurityStampRefreshingPrincipal = context =>
    {
        var originalUserIdClaim = context.CurrentPrincipal.FindFirst("OriginalUserId");
        var isImpersonatingClaim = context.CurrentPrincipal.FindFirst("IsImpersonating");
        if (isImpersonatingClaim.Value == "true" && originalUserIdClaim != null)
        {
            context.NewPrincipal.Identities.First().AddClaim(originalUserIdClaim);
            context.NewPrincipal.Identities.First().AddClaim(isImpersonatingClaim);
        }
        return Task.FromResult(0);
    };
});

This fixes the issue by re-adding our custom claims to the new cookie.

And this is pretty much it. You can see the full working sample on GitHub

Impersonation in Asp.Net Core v2.0

Aspnet Core v2 has been out for a while, but I did not get a chance to migrate my projects. Today that happened and I had to work out impersonation for Core v2. Mostly I’ve followed this guide: https://docs.microsoft.com/en-us/aspnet/core/migration/1x-to-2x/ and then Identity part: https://docs.microsoft.com/en-us/aspnet/core/migration/1x-to-2x/identity-2x

One important change was in ImpersonationController.

   //[Authorize(Roles = "Admin")] // <-- Make sure only admins can access this 
    public async Task<IActionResult> ImpersonateUser(String userId)
    {
        var currentUserId = User.GetUserId();

        var impersonatedUser = await _userManager.FindByIdAsync(userId);

        var userPrincipal = await _signInManager.CreateUserPrincipalAsync(impersonatedUser);

        userPrincipal.Identities.First().AddClaim(new Claim("OriginalUserId", currentUserId));
        userPrincipal.Identities.First().AddClaim(new Claim("IsImpersonating", "true"));

        // sign out the current user
        await _signInManager.SignOutAsync();

        await HttpContext.SignInAsync(IdentityConstants.ApplicationScheme, userPrincipal); // <-- This has changed from the previous version.

        return RedirectToAction("Index", "Home");
    }

Also there was syntax change in Startup when configuring Security Stamp invalidator:

        services.Configure<SecurityStampValidatorOptions>(options => // different class name
        {
            options.ValidationInterval = TimeSpan.FromMinutes(1);  // new property name
            options.OnRefreshingPrincipal = context =>             // new property name
            {
                var originalUserIdClaim = context.CurrentPrincipal.FindFirst("OriginalUserId");
                var isImpersonatingClaim = context.CurrentPrincipal.FindFirst("IsImpersonating");
                if (isImpersonatingClaim.Value == "true" && originalUserIdClaim != null)
                {
                    context.NewPrincipal.Identities.First().AddClaim(originalUserIdClaim);
                    context.NewPrincipal.Identities.First().AddClaim(isImpersonatingClaim);
                }
                return Task.FromResult(0);
            };
        });

Other than that the impersonation is mostly the same. See full working sample in CoreV2 branch on GitHub