Wednesday, May 13, 2009

Impersonating a user in ASP.NET when using Windows Authentication

I frequently have the need to test, debug, troubleshoot, etc my ASP.NET intranet applications that use Windows Authentication as another user. For example, user1 says, the application is behaving strangely when I do xyz. It is very helpful to be able to see exactly what that user is seeing. Of course I could ask the user for their password, but that is a bad solution, and a bad idea to ever give passwords out, even to IT.

What I needed was a way to just specify the username I wanted, and not worry about the password. I want this functionality to be restricted to user in a certain role. For example, maybe an Admin role. I didn’t want any real performance impact either.

With that in mind, I set out to write a class to encapsulate all this logic. Below is the solution I came up with. It requires very little changes to your existing application. It hooks into the application at the Application_AuthenticationRequest event in your Global.asax. There are a couple of items that can be configured in the constructor or the web.config or use my defaults. You can control the impersonation through the url query string, or you can come up with your own user interface that calls my methods. See the example comment in the class for more details. The code is heavily commented and I also have examples of how to use the code as well. There really isn’t that much code once you take away the example code and comments.

Anyway, here is the code. Just paste the code into .cs file, and you should be ready to go.

using System;
using System.Collections;
using System.Configuration;
using System.Data;
using System.Linq;
using System.Web;
using System.Web.Security;
using System.Web.SessionState;
using System.Xml.Linq;
using System.Web.Routing;
using System.Web.DynamicData;
using DataModel;
using ICAWebSite3;
using System.Security.Principal;

namespace MyWebSite
{
   /// <summary>
   /// Impersonates an specified user. Impersonation is started by adding
   /// ImpersonatedUser=domain\username to the url or calling Impersonate() 
   /// and UnImpersonate methods from code. 
   /// </summary>
   /// <example>
   ///     In Global.asax and using url to control impersonation
   ///     void Application_AuthenticateRequest(object sender, EventArgs e)
   ///     {
   ///        Impersonation i = new Impersonation();
   ///        i.ImpersonateBasedOnQueryStrings("ImpersonatedUser", "UnImpersonate");
   ///        i.HandleAuthenticationRequest();
   ///     }
   ///    
   /// or 
   /// 
   ///     In Global.asax and using web interface to control impersonation
   ///     void Application_AuthenticateRequest(object sender, EventArgs e)
   ///     {
   ///        Impersonation i = new Impersonation();
   ///        i.HandleAuthenticationRequest();
   ///     }
   ///     
   ///     This example assumes you call the Impersonate() and UnImpersonate()
   ///     methods in your code appropriately. Be sure to use the same parameters to the 
   ///     constructor as you do in Global.asax. The web.config option shown below is recommended.
   ///     
   ///     Impersonation i = new Impersonation();
   ///     i.Impersonate(@"domain\someoneelse")
   ///     
   ///     You will also need to call this somewhere else also.
   ///     Impersonation i = new Impersonation();
   ///     i.UnImpersonate();
   ///     
   ///     NOTE: You may need to redirect after you call i.Impersonate(...) because the 
   ///     button event handlers, etc are after the Application_AuthenticateRequest event.
   ///     
   ///     Below is an example of how you may want to show/hide a textbox and two buttons
   ///     on a page or master page. The textbox is for the domain\username you want
   ///     to impersonate and the two buttons are Impersonate and UnImpersonate.
   ///     
   ///     protected void Page_Load(object sender, EventArgs e)
   ///     {
   ///         Impersonation i = new Impersonation();
   ///         btnUnImpersonate.Visible = i.IsImpersonating;
   ///         if (i.CanImpersonate)
   ///         {
   ///             btnImpersonate.Visible = true;
   ///             txtImpersonatedUser.Visible = true; 
   ///         }
   ///         else
   ///         {
   ///             btnImpersonate.Visible = false;
   ///             txtImpersonatedUser.Visible = false;
   ///         }
   ///         if (!IsPostBack)
   ///         {
   ///             txtImpersonatedUser.Text = i.ImpersonatedUsername;
   ///         }
   ///     }
   ///
   ///     protected void btnImpersonate_Click(object sender, EventArgs e)
   ///     {
   ///         Impersonation i = new Impersonation();
   ///         i.Impersonate(txtImpersonatedUser.Text);
   ///         Response.Redirect(Request.Url.OriginalString);
   ///     }
   ///
   ///     protected void btnUnImpersonate_Click(object sender, EventArgs e)
   ///     {
   ///         Impersonation i = new Impersonation();
   ///         i.UnImpersonate();
   ///         txtImpersonatedUser.Text = "";
   ///     }
   /// </example>
   /// <remarks>
   ///     The impersonation data is being persisted to the Application object currently.
   ///     This is very fast and has no real impact on performance of the application. 
   ///     It is important that performance be very quick because the Application_AuthenticateRequest
   ///     event fires for every request. This includes image and javascript files, etc. 
   ///     
   ///     The Application object works very well for single web server environments.
   ///     However in web farms where a user may be redirected to multiple servers, 
   ///     the impersonation would not work anymore unless the user happens to hit the same
   ///     server each time. 
   ///     
   ///     In environments like this, I recommend using a database or something
   ///     of the like since it can be accessed from all application instances. There will of course
   ///     be a performance hit for this, so use sparingly. You may want to experiment with
   ///     caching credentials, or limiting the impersonation to .aspx pages only. These
   ///     approaches have their own issues that you will need to content with as well.
   ///     
   ///     If you choose to change the place where impersonation state is kept you just need to change
   ///     the ImpersonationDateStore object. This is by design.
   ///     
   /// </remarks>
   public class Impersonation
   {
       /// <summary>
       /// The role that the user must be in in order for impersonation to work.
       /// For example, Admin.
       /// </summary>
       private string requiredRoleName;

       /// <summary>
       /// The time before an impersonation expires. This is a sliding expiration.
       /// This means that the time is relative to the last time the impersonation
       /// actually is set.
       /// This is Not the absoute time since impersonation was started, UNLESS
       /// the impersonation is only set once such as through the UI.
       /// </summary>
       private TimeSpan expirationDuration;

       /// <summary>
       /// Class responsible for persisting the impersonation state
       /// </summary>
       private ImpersonationDataStore ds;


       // Assuming there is some traffic on the site, any expired impersonations 
       // will be removed
       public Impersonation()
       {
           // if the web.config does not have a default value specified,
           // then use our own default.
           string configFileExpirationDuration = ConfigurationManager.AppSettings["Impersonation-ExpirationDuration"];
           if (!string.IsNullOrEmpty(configFileExpirationDuration))
           {

               // string in web.config should have the format:
               // [ws][-]{ d | d.hh:mm[:ss[.ff]] | hh:mm[:ss[.ff]] }[ws]
               // for more info see http://msdn.microsoft.com/en-us/library/system.timespan.tryparse.aspx 
               TimeSpan.TryParse(configFileExpirationDuration, out this.expirationDuration);
           }
           else
           {
               // default time to 20 minutes if one is not specified
               this.expirationDuration = new TimeSpan(0, 20, 0);
           }


           // if the web.config does not have a default value specified,
           // then use our own default.
           string configFileRequiredRoleName = ConfigurationManager.AppSettings["Impersonation-RequiredRoleName"];
           if (!string.IsNullOrEmpty(configFileRequiredRoleName))
           {

               this.requiredRoleName = configFileRequiredRoleName;
           }
           else
           {
               // default role to Admin
               this.requiredRoleName = "Admin";
           }

           Init();
       }

       // Assuming there is some traffic on the site, any expired impersonations 
       // will be removed
       public Impersonation(string requiredRoleName, TimeSpan expirationDuration)
       {
           this.expirationDuration = expirationDuration;
           this.requiredRoleName = requiredRoleName;

           Init();
       }

       /// <summary>
       /// Stuff that is done for all the constructors
       /// </summary>
       private void Init()
       {
           ds = new ImpersonationDataStore();

           ds.ClearExpiredImpersonations();
       }

     
  
       public void Impersonate(string impersonatedUsername)
       {
           if (Roles.IsUserInRole(requiredRoleName))
           {
               ImpersonatedUsernameInternal = impersonatedUsername;
           }
       }

      

       /// <summary>
       /// This must be called from the Application_AuthenticateRequest event in Global.asax Parameters are taken from the
       /// query string of the url. The url does NOT have to have either of the parameters
       /// present after the initial request with one of them in the url. Even if the 
       /// query parameters are not in the url the impersonation will exist until it expires 
       /// or is explicitly UnImpersonated by adding UnImpersonate=Y to the url or the UnImpersonate() method.
       /// </summary>
       /// <param name="impersonateParamName">The name of the query string key name. 
       /// In the url, the value associated with the key should be of the format
       /// domain\username</param>
       /// <param name="unimpersonateParamName">The name of the query string key name.
       /// In the url, the value associated with this can be anything. All that matters
       /// is that a value is specified so that the key will be passed from browser to the
       /// ASP.NET QueryString Dictionary.
       /// </param>
       public void ImpersonateBasedOnQueryStrings(string impersonateParamName, string unimpersonateParamName)
       {
           if (Roles.IsUserInRole(requiredRoleName))
           {
              
               string impersonateUserVal = HttpContext.Current.Request.QueryString[impersonateParamName] as string;
               bool unImpersonateUser = HttpContext.Current.Request.QueryString.AllKeys.Contains<string>(unimpersonateParamName);
              
               // if we need to unimpersonate then do so before we call Impersonate()
               if (unImpersonateUser)
               {
                   UnImpersonate(AuthenticatedUsername);
               }

               // if there is a new or same impersonation value, 
               // then set it to the current impersonation value
               if (!string.IsNullOrEmpty(impersonateUserVal))
               {
                   ImpersonatedUsernameInternal = impersonateUserVal;
               }
           }
       }

       /// <summary>
       /// This must be called from the Application_AuthenticateRequest event in Global.asax
       /// </summary>
       /// <param name="impersonateUserVal">The domain and username of the user that 
       /// is to be impersonated. The value should be of the format domain\username
       /// </param>
       public void HandleAuthenticationRequest()
       {
           if (Roles.IsUserInRole(requiredRoleName))
           {
               ImpersonationInfo info = ds.Retrieve(AuthenticatedUsername);
               if (info != null)
               {
                   string impersonatedUsername = info.ImpersonatedUser;
                   if (!string.IsNullOrEmpty(impersonatedUsername))
                   {
                       GenericIdentity id = new GenericIdentity(impersonatedUsername);
                       GenericPrincipal p = new GenericPrincipal(id, Roles.GetRolesForUser(impersonatedUsername));
                       HttpContext.Current.User = p;
                   }
               }
              
              
           }
      
       }

       /// <summary>
       /// Returns true if someone is being impersonated, else, false.
       /// </summary>
       public bool IsImpersonating
       {
           get { return (ImpersonatedUsername != null) && (ImpersonatedUsername != AuthenticatedUsername); }
       }

       /// <summary>
       /// True if the Authenticated user is in the role required to impersonate other users,
       /// else false
       /// </summary>
       public bool CanImpersonate
       {
           get { return Roles.IsUserInRole(AuthenticatedUsername, requiredRoleName); }
       }



       /// <summary>
       /// Call this method to unimpersonate
       /// </summary>
       public void UnImpersonate()
       {
           UnImpersonate(AuthenticatedUsername);
       }

       /// <summary>
       /// Call this method to unimpersonate
       /// </summary>
       /// <param name="authenticatedUsername"></param>
       private void UnImpersonate(string authenticatedUsername)
       {
           ds.Delete(authenticatedUsername);
       }

       public string AuthenticatedUsername
       {
           get
           {
               return HttpContext.Current.Request.ServerVariables["LOGON_USER"];
           }
       }

       public string ImpersonatedUsername
       {
           get
           {
               return ImpersonatedUsernameInternal;
           }
       }

       private class ImpersonationDataStore
       {
           public void Store(string key, ImpersonationInfo value)
           {
               HttpContext.Current.Application[GetImpersonationKey(key)] = value;
           }

           public ImpersonationInfo Retrieve(string key)
           {
               return HttpContext.Current.Application[GetImpersonationKey(key)] as ImpersonationInfo;
           }

           public void Delete(string key)
           {
               HttpContext.Current.Application.Remove(GetImpersonationKey(key));
           }

           private string GetImpersonationKey(string key)
           {
               return "Impersonation-" + key;
           }

           /// <summary>
           /// Removes any impersonations that have expired.
           /// </summary>
           public void ClearExpiredImpersonations()
           {
               string[] keys = HttpContext.Current.Application.AllKeys;
               string keyPrefix = GetImpersonationKey("");
               for (int i = 0; i < keys.Count(); i++)
               {
                   if (keys[i].StartsWith(keyPrefix))
                   {
                       object infoObj = HttpContext.Current.Application[keys[i]];

                       if (infoObj is ImpersonationInfo)
                       {
                           ImpersonationInfo info = infoObj as ImpersonationInfo;
                           DateTime expires = info.Expires;
                           if (expires < DateTime.Now)
                           {
                               Delete(info.AuthenticatedUsername);
                           }
                       }
                   }
               }
           }

       }

       private string ImpersonatedUsernameInternal
       {
           get
           {

               ImpersonationInfo info = ds.Retrieve(AuthenticatedUsername);
               if (info == null) return null;
               else return info.ImpersonatedUser;
           }

           set
           {
               ImpersonationInfo info = new ImpersonationInfo();
               info.AuthenticatedUsername = AuthenticatedUsername;
               info.ImpersonatedUser = value;

               // slide the expiration window out from the current date time.
               info.Expires = DateTime.Now.Add(expirationDuration);

               ds.Store(AuthenticatedUsername, info);
              
           }
       }

      

       /// <summary>
       /// Stores information about the impersonation.
       /// This could be persisted to a database or file.
       /// </summary>
       public class ImpersonationInfo
       {
           public string AuthenticatedUsername { get; set; }
           public DateTime Expires { get; set; }
           public string ImpersonatedUser { get; set; }
       }

      
      
   }
}

12 comments:

Sven said...

This has been a lifesaver. One question concerning the variable (configFileRequiredRoleName). Shouldn't this:

// if the web.config does not have a default value specified,
// then use our own default.
string configFileRequiredRoleName = ConfigurationManager.AppSettings["Impersonation-RequiredRoleName"];
if (!string.IsNullOrEmpty(configFileExpirationDuration))
{

this.requiredRoleName = configFileRequiredRoleName;
}
else
{
// default role to Admin
this.requiredRoleName = "Admin";
}

ACTUALLY BE THIS:

// if the web.config does not have a default value specified,
// then use our own default.
string configFileRequiredRoleName = ConfigurationManager.AppSettings["Impersonation-RequiredRoleName"];
if (!string.IsNullOrEmpty(configFileRequiredRoleName))
{

this.requiredRoleName = configFileRequiredRoleName;
}
else
{
// default role to Admin
this.requiredRoleName = "Admin";
}

Brent V said...

Hi Sven,

I'm so glad you found it useful. You are right. Copy and Paste bug. :) I guess I have always set a value for that so it wasn't a issue by luck. I have updated the posting.

Thank you so much for the correction.

Brent

Sven said...

Brent....another quick question for you. Ran into an issue today trying to impersonate a user that had a role that was defined with two words. Impersonating the user with a role of "Admin" was fine. But trying "Legal Admin" caused it to not work.

I changed the overloaded method:

public void Impersonate(string impersonatedUsername)
{
if (Roles.IsUserInRole(requiredRoleName))
{
ImpersonatedUsernameInternal = impersonatedUsername;
}
}

to this:

public void Impersonate(string impersonatedUsername)
{
if (Roles.IsUserInRole(impersonatedUsername, requiredRoleName))
{
ImpersonatedUsernameInternal = impersonatedUsername;
}
}

and now everything appears to work smoothly. I am still pretty new to all this roles and permissions stuff so I am not 100% sure why this is so. Thanks again.

Brent V said...

Hi Sven,

I don't think what you have is right. That if statement is to prevent users that are NOT in the "Admin" role (or whatever role you specify) from impersonating other users. By changing it to your lines you are actually checking if the user you are impersonating has rights to impersonate. I don't think that is what you want, but you may be using this is a different way than I imagine.

By default the code only allows users that are in the role "Admin" to use the impersonation functionality. You can change that to use Legal Admin or anything else if you like. The roles of the users that you are impersonating should not matter from an impersonation standpoint. I can't see how spaces in a role of a user you are impersonating would make a difference since I am just copying them.

I may be not understanding how you have it setup but here is how I have used this.

*** User A ***
Username: userA
Roles: Admin, Registered User

*** User B ***
Username: userB
Roles: Registered User

Let's assume I am logged into my Windows machine as User A. When the Authentication method is encountered in my ASP.NET web application it looks at User A's credentials. If it is in the "Admin" role then it proceeds to impersonate User B (assuming that is who I specified that I wanted to impersonate). After the impersonation, my Identity is now that of User B, not A. Now if you use something like Page.User you will get User B's info. The next time a page loads, the authentication method is called. Once again I show as User A. It checks to see if I am impersonating a user. If I am, it creates that User B identity again. Once again Page.User will be User B's info. This happens for every request.

The change you implemented will require User B to in the "Admin" role which makes no sense.

I am not clear if you change the role required to impersonate from "Admin" to "Legal Admin" or if (using my example) User B is in the "Legal Admin" role.

I hope that clears it up a bit.

Anonymous said...

Brent,

I'm in need of a bit of clarification. I've created a class using your code and updated my global.asax's Application_AuthenticateRequest to the following:
--------------------------------
Dim i As New Impersonation()
i.ImpersonateBasedOnQueryStrings("ImpersonatedUser", "UnImpersonate")
i.HandleAuthenticationRequest()
--------------------------------
The function fires on each page load, but how do I get the credentials from the querystring to here?

For the ImpersonateBasedOnQueryStrings function, should I be replacing "impersonateUser" with something like "Request.Querystring("test")" ?

Also, do I just provide a Y or N for the "UnImpersonate" parameter?

Thanks,
Doug

Brent V said...

Hi Doug,

I think I see where the confusion. Just to clarify, you don't actually have to put anything on the page or page load event. Not sure if you were trying to do that.

Once you have your Global.asax.vb file configured as it appears you have you should just have to do something like this in the url:

http://myhost/myapp/mypage.aspx?ImpersonatedUser=myusernamehere

To unimpersonate you can let the time expire or do:

http://myhost/myapp/mypage.aspx?UnImpersonate=Y

The Y doesn't really matter. I am just looking for the variable in the query string.

FYI, while this works, I found that using using the other option described is a bit easier to use and more flexible. In this scenario, you have a page that is accessible only to admin user(s) that give you an impersonate or unimpersonate button. The choice is yours.

Does that help?

Brent

Anonymous said...

Brent,

I'm having a problem with the unimpersonate methods. When it try's to get the ImpersonatedUsernameInternal, it doesn't find it because the ds.Retrieve passes the impersonated users name rather than the original AuthenticatedUsername becuase now HttpContext.Current.User.Identity.Name is the impersonated users identity. And since the ds.Store() saved the original AuthenticatedUsername, the impersonated user isn't found in the ds. Therefore, after I have impersonated, the code doesn't think I'm impersonating anymore and I can't tell it to unimpersonate because it doesn't find the current user in the ds. Please help.

private string ImpersonatedUsernameInternal
{
get
{

ImpersonationInfo info = ds.Retrieve(AuthenticatedUsername);
if (info == null) return null;
else return info.ImpersonatedUser;
}

set
{
ImpersonationInfo info = new ImpersonationInfo();
info.AuthenticatedUsername = AuthenticatedUsername;
info.ImpersonatedUser = value;

// slide the expiration window out from the current date time.
info.Expires = DateTime.Now.Add(expirationDuration);

ds.Store(AuthenticatedUsername, info);
}
}

Thanks,

David

Brent V said...

Hi Anonymous,

I have not had the problem you are experiencing. Let me know if you figure it out.

Brent

Anonymous said...

Great stuff - many thanks

Unknown said...

Still reading and looking to make this work.

So far all seems well using the buttons method but once I hit de impersonate button it fails on this line :
GenericPrincipal p = new GenericPrincipal(id, Roles.GetRolesForUser(impersonatedUsername));

Saying that

Method is only supported if the user name parameter matches the user name in the current Windows Identity

Might be an overlook on my part somewhere... posting in case someone answers quicker that my troubleshooting :)

James said...

While using query-string-based impersonation , I found that Impersonation was timing out in the middle of actions.

So made a small change to my implementation of HandleAuthenticationRequest: -

public void HandleAuthenticationRequest()
{
var roleManager = new RoleManager();

if (roleManager.IsUserInRole(AuthenticatedUsername, _requiredRoleName))
{
ImpersonationInfo info = _ds.Retrieve(AuthenticatedUsername);
if (info != null)
{
var impersonatedUsername = info.ImpersonatedUser;
if (!string.IsNullOrEmpty(impersonatedUsername))
{
GenericIdentity id = new GenericIdentity(impersonatedUsername);
GenericPrincipal p = new GenericPrincipal(id, roleManager.GetRolesForUser(impersonatedUsername));
HttpContext.Current.User = p;

//also, slide the Expiry time out again
info.Expires = DateTime.Now.Add(_expirationDuration);

// and re-persist of course
_ds.Store(AuthenticatedUsername, info);
}
}
}
}


- this rolls on the expiration time to always be 20 minutes from last activity of the Authenticated user.

Andrew said...

Hi Jean-Alexandre,

I'm getting the exact same problem also. Did you find a solution to this?

I'm using Azman to control roles and have a feeling it's a permissions issue when trying to authenticate.

Thanks!