User impersonation with ASP.Net Identity 2

UPD July 2017: If you are looking to do User Impersonation in Asp.Net Core, read this article: http://tech.trailmax.info/2017/07/user-impersonation-in-asp-net-core/

Recently I’ve migrated my project to ASP.NET Identity. One of the features I had in the project is “Impersonation”. Administrators could impersonate any other user in the system. This is a strange requirement, but business behind the project wanted it.

This is the old impersonation way:

  1. When admin wanted impersonation, system would serialise information about admin account (mostly username).
  2. Find account for impersonated user
  3. Create a new authentication cookie for impersonated user
  4. As data add serialised information about admin account to the cookie
  5. Set the cookie
  6. Redirect admin to client page.
  7. Bingo, admin logged in as a client user.

To de-impersonate repeate the process in reverse. Get data about admin from cookie data (if it is present), delete cookie for client-user, login admin user again. Bingo, admin is logged in as admin again.

Here is the article how the old way is implemented

This exact code did not work with Identity framework. I tried finding the solution online, but nothing was available. My question on Stackoverslow immediately got 4 up-votes, but no answers. So people are interested in doing it, but nobody published any material on this. So here I am -)

Claims

New Identity framework works with Claims. Claim is a bit of information (think string key-value pair) that is attached to a user. You can store a claim in database and it is restored every time when you get a user from a storage. Or you can assign claims to a user before signing them in. Later on you can easily check if a user has a claim. Think of this like checking a dictionary of strings if there is a required key with a required value, or extract a value from dictionary by a key. Probably this is clear as mud, but bear with me, I’ll get to a code and it’ll become all clear.

Impersonation

To get admin user logged in as somebody else – not a problem. Delete old auth-cookie, create a new one for another user, redirect. The problem is to detect that admin is impersonating. And then to de-impersonte the admin, but don’t allow other users to de-impersonate. So here I’m creating a new user identity and add extra claims to the user, saying impersonation is going on and who is impersonating. So here is the code:

public async Task ImpersonateUserAsync(string userName)
{
    var context = HttpContext.Current;

    var originalUsername = context.User.Identity.Name;

    var impersonatedUser = await userManager.FindByNameAsync(userName);

    var impersonatedIdentity = await userManager.CreateIdentityAsync(impersonatedUser, DefaultAuthenticationTypes.ApplicationCookie);
    impersonatedIdentity.AddClaim(new Claim("UserImpersonation", "true"));
    impersonatedIdentity.AddClaim(new Claim("OriginalUsername", originalUsername));

    var authenticationManager = context.GetOwinContext().Authentication;
    authenticationManager.SignOut(DefaultAuthenticationTypes.ApplicationCookie);
    authenticationManager.SignIn(new AuthenticationProperties() { IsPersistent = false }, impersonatedIdentity);
}

This is pretty standard way Identity framework is logging in users. Only here we add extra claims – one for “Yes, impersonation is happening” and another one for “Original username is admin”.

First is used to detect if we are impersonating, second is to de-impersonate the user back into admin rights.

To detect if impersonation happens here is an extension method:

public static bool IsImpersonating(this IPrincipal principal)
{
    if (principal == null)
    {
        return false;
    }

    var claimsPrincipal = principal as ClaimsPrincipal;
    if (claimsPrincipal == null)
    {
        return false;
    }


    return claimsPrincipal.HasClaim("UserImpersonation", "true");
}

And this would be used like this:

if(HttpContext.Current.User.IsImpersonating())
{
    // do my stuff for admins
}

To get original username, use this extension method:

public static String GetOriginalUsername(this IPrincipal principal)
{
    if (principal == null)
    {
        return String.Empty;
    }

    var claimsPrincipal = principal as ClaimsPrincipal;
    if (claimsPrincipal == null)
    {
        return String.Empty;
    }

    if (!claimsPrincipal.IsImpersonating())
    {
        return String.Empty;
    }

    var originalUsernameClaim = claimsPrincipal.Claims.SingleOrDefault(c => c.Type == "OriginalUsername");

    if (originalUsernameClaim == null)
    {
        return String.Empty;
    }

    return originalUsernameClaim.Value;
}

And you would call it like this:

HttpContext.Current.User.GetOrigianlUsername()

And this is how de-impersonation happens:

public async Task RevertImpersonationAsync()
{
    var context = HttpContext.Current;

    if (!HttpContext.Current.User.IsImpersonating())
    {
        throw new Exception("Unable to remove impersonation because there is no impersonation");
    }


    var originalUsername = HttpContext.Current.User.GetOriginalUsername();

    var originalUser = await userManager.FindByNameAsync(originalUsername);

    var impersonatedIdentity = await userManager.CreateIdentityAsync(originalUser, DefaultAuthenticationTypes.ApplicationCookie);
    var authenticationManager = context.GetOwinContext().Authentication;

    authenticationManager.SignOut(DefaultAuthenticationTypes.ApplicationCookie);
    authenticationManager.SignIn(new AuthenticationProperties() { IsPersistent = false }, impersonatedIdentity);
}

This is the basics of my process. I have a bit more checks and validations in place, mostly for null references. Also as a claim I store return url, where admin have started impersonation, so admin can be redirected to the original location where they started from. Also userManager is an instance of UserManager<User> that was injected by a constructor. I skipped that for brevity.

Update 29 February 2016

Quite a few readers here have complained that temporary claims are lost when the cookie is getting updated by SecurityStampValidator. I knew about this issue, but had no need to fix this as none of my clients have complained about it. Until last week when they mentioned this issue and got very confused when their impersonating session suddenly exploded.

The fix is as following: you need to replace SecurityStampValidator with your own to make sure claims with details about impersonation stay there after the cookie is updated. I’ve taken the original class code from Codeplex repository and modified to my needs:

public static class ImpersonatingSecurityStampValidator
{
    public static Func<CookieValidateIdentityContext, Task> OnValidateIdentity<TManager, TUser>(
        TimeSpan validateInterval, Func<TManager, TUser, Task<ClaimsIdentity>> regenerateIdentity)
        where TManager : UserManager<TUser, string>
        where TUser : class, IUser<string>
    {
        return OnValidateIdentity(validateInterval, regenerateIdentity, id => id.GetUserId());
    }

    public static Func<CookieValidateIdentityContext, Task> OnValidateIdentity<TManager, TUser, TKey>(
        TimeSpan validateInterval, Func<TManager, TUser, Task<ClaimsIdentity>> regenerateIdentityCallback,
        Func<ClaimsIdentity, TKey> getUserIdCallback)
        where TManager : UserManager<TUser, TKey>
        where TUser : class, IUser<TKey>
        where TKey : IEquatable<TKey>
    {
        if (getUserIdCallback == null)
        {
            throw new ArgumentNullException("getUserIdCallback");
        }
        return async context =>
        {
            var currentUtc = DateTimeOffset.UtcNow;
            if (context.Options != null && context.Options.SystemClock != null)
            {
                currentUtc = context.Options.SystemClock.UtcNow;
            }
            var issuedUtc = context.Properties.IssuedUtc;

            // Only validate if enough time has elapsed
            var validate = (issuedUtc == null);
            if (issuedUtc != null)
            {
                var timeElapsed = currentUtc.Subtract(issuedUtc.Value);
                validate = timeElapsed > validateInterval;
            }
            if (validate)
            {
                var manager = context.OwinContext.GetUserManager<TManager>();
                var userId = getUserIdCallback(context.Identity);
                if (manager != null && userId != null)
                {
                    var user = await manager.FindByIdAsync(userId);
                    var reject = true;
                    // Refresh the identity if the stamp matches, otherwise reject
                    if (user != null && manager.SupportsUserSecurityStamp)
                    {
                        var securityStamp =
                            context.Identity.FindFirstValue(Constants.DefaultSecurityStampClaimType);
                        if (securityStamp == await manager.GetSecurityStampAsync(userId))
                        {
                            reject = false;
                            // Regenerate fresh claims if possible and resign in
                            if (regenerateIdentityCallback != null)
                            {
                                var identity = await regenerateIdentityCallback.Invoke(manager, user);
                                if (identity != null)
                                {
                                    /**** CHANGES START HERE ****/
                                    if (context.Identity.FindFirstValue("UserImpersonation") == "true")
                                    {
                                        // need to preserve impersonation claims
                                        identity.AddClaim(new Claim("UserImpersonation", "true"));
                                        identity.AddClaim(context.Identity.FindFirst("OriginalUsername"));
                                    }
                                    /**** CHANGES END HERE ****/

                                    context.OwinContext.Authentication.SignIn(context.Properties, identity);
                                }
                            }
                        }
                    }
                    if (reject)
                    {
                        context.RejectIdentity();
                        context.OwinContext.Authentication.SignOut(context.Options.AuthenticationType);
                    }
                }
            }
        };
    }
}

Most of this class stays as it is unchanged. I’ve inserted a few lines just before user is getting re-signed in – look for /**** CHANGES START HERE ****/. There I check if impersonation is in process. Then I re-add original username claim and “UserImpersonation” flag.

Once you have added this class to your solution, you need to go to App_Start/AuthConfig.cs file and change SecurityStampValidator to be ImpersonatingSecurityStampValidator. Something like this:

    app.UseCookieAuthentication(new CookieAuthenticationOptions
    {
        // your other configuration. Be careful not to remove it
        Provider = new CookieAuthenticationProvider
        {
            OnValidateIdentity = ImpersonatingSecurityStampValidator.OnValidateIdentity<UserManager, ApplicationUser>(
                validateInterval: TimeSpan.FromMinutes(10),
                regenerateIdentity: (manager, user) => manager.CreateIdentityAsync(user))
        }
    });

This way you will get cookie invalidation if SecurityStamp is updated. And you will preserve your impersonation session until you cancel it.