November 9, 2020

Enabling Verndale's Verndale.ImpersonateUsers package in Episerver's Foundation CMS Application

I recently needed to enable user impersonation for a prospective customer who wanted a better way to preview personalization for more complex scenarios where the in-editor Visitor Group Preview wasn't quite sufficient. This is for a portal, where every user would be authenticated, so a fantastic workaround would be to simply impersonate a user. This may not solve for behavioral personalization, but theirs is primarily built around roles and profiles.

For example, what if we want to preview the home page as a member who belongs to the Human Resources team AND who also happens to be a new hire (< 30 days). If you had a visitor group that required both, and targeted it, that might work. But in this case, these are two separate visitor groups.

My chosen best option was to use a tool built by one of Episerver's best partners, Verndale. Aptly named Verndale.ImpersonateUsers. It's worth mentioning that Verndale has put a LOT of really nice add-ons in the marketplace and they're worth checking out.

There were a couple of bumps getting impersonation to work in Foundation, so here's how to get up and running.

The package is only available to admins, or at least users who can access the Admin section of Episerver CMS. When you trigger impersonation through the tool it stores the current user information and effectively creates a new Claims Identity for the user you want to impersonate. This relies on information coming from OwinContext. 

Foundation overrides some defaults from Owin for user management. For example, Foundation uses SiteUser as an override for ApplicationUser. This means that some things don't get registered the way that the impersonation package expects in order to get user information and effect the sign-in simulation. Namely, the ApplicationSigninManager.

This can be added in the Startup.cs Configuration method, but in order to do so we also have to add supporting prerequisites for everything to resolve correctly through the Owin Context:


app.AddCmsAspNetIdentity<ApplicationUser>();
app.CreatePerOwinContext<ApplicationDbContext<ApplicationUser>>(ApplicationDbContext<ApplicationUser>.Create);
app.CreatePerOwinContext<ApplicationUserManager<ApplicationUser>>(ApplicationUserManager<ApplicationUser>.Create);
app.CreatePerOwinContext<ApplicationSignInManager<ApplicationUser>>(ApplicationSignInManager<ApplicationUser>.Create);

That helps get over the first technical hurdle. Though I'd like to see more documentation around these add-ons, this one wasn't too difficult to get past.

The same cannot necessarily be said for the bug (or feature?) I found. I'm somewhat curious if this is due to something in the way Foundation works or the add-on, or if the two simply are an imperfect marriage. Errors aren't thrown when you attempt to impersonate, but when you wish to disable it.

As I said before, the package generates a new Claims Identity for the user you impersonate and more or less performs a temporary authentication swap. In Foundation, at least, this produces duplicate claims and becomes the root cause of the error I received. Luckily, the good developers at Verndale made good use of IOC and we can easily work around it.

The package contains an ImpersonationRepository class that performs the primary ImpersonateUser and RevertImpersonation functions. RevertImpersonation makes a call out to a helper class which reads in stored data from Claims (stored when impersonation is enabled) in order to swap the Claims Identity back out and restore the original. This helper method relied on a very simple LINQ function: SingleOrDefault. Because there are duplicate claims, an error is thrown.

ImpersonationRepository, luckily, is enabled as a Service, which means we can swap it out. Which, in turn, is awesome. #GreatPower

I took the original code and removed the offending call to the helper class. I replaced SingleOrDefault with my preferred FirstOrDefault (and added a DefaultIfEmpty for good measure). The former expects a single item in the list and throws an error if that expectation isn't met and there are two or more. The latter merely returns the first found. Since they're duplicates, that's good enough.

Here's my custom ImpersonationRepository:


using EPiServer.Cms.UI.AspNetIdentity;
using EPiServer.Logging;
using EPiServer.ServiceLocation;
using Microsoft.AspNet.Identity;
using Microsoft.Owin.Security;
using System;
using System.Linq;
using System.Security.Claims;
using System.Web;
using Verndale.ImpersonateUsers.Repositories;

namespace Foundation.Features.Impersonation
{
    [ServiceConfiguration(ServiceType = typeof(IImpersonationRepository))]
    public class ImpersonationRepository : IImpersonationRepository
    {
        private static readonly ILogger Logger = LogManager.GetLogger();

        public void ImpersonateUser(string userName, UserManager<ApplicationUser, string> userManager)
        {
            HttpContext current = HttpContext.Current;
            string name = current.User.Identity.Name;
            ApplicationUser result1 = userManager.FindByNameAsync(userName).Result;
            if (result1 == null)
                throw new Exception("Unable to find the user " + userName);
            ClaimsIdentity result2 = userManager.CreateIdentityAsync(result1, "ApplicationCookie").Result;
            result2.AddClaim(new Claim("UserImpersonation", "true"));
            result2.AddClaim(new Claim("OriginalUsername", name));
            result2.AddClaim(new Claim("ImpersonatedUsername", userName));
            IAuthenticationManager authentication = current.GetOwinContext().Authentication;
            authentication.SignOut("ApplicationCookie");
            IAuthenticationManager authenticationManager = authentication;
            AuthenticationProperties properties = new AuthenticationProperties();
            properties.IsPersistent = false;
            ClaimsIdentity[] claimsIdentityArray = new ClaimsIdentity[1]
            {
        result2
            };
            authenticationManager.SignIn(properties, claimsIdentityArray);
        }

        public void RevertImpersonation(UserManager<ApplicationUser, string> userManager)
        {
            HttpContext current = HttpContext.Current;
            if (!HttpContext.Current.User.IsImpersonating())
            {
                ImpersonationRepository.Logger.Warning("Unable to remove impersonation because there is no impersonation");
            }
            else
            {
                var principal = HttpContext.Current.User;

                string originalUsername = string.Empty;
                if (!(principal is ClaimsPrincipal principal1) || !principal1.IsImpersonating())
                    return;
                Claim claim = principal1.Claims.DefaultIfEmpty(null).FirstOrDefault<Claim>((Func<Claim, bool>)(c => c.Type == "OriginalUsername"));
                if (claim == null) return;
                originalUsername = claim.Value;

                ApplicationUser result1 = userManager.FindByNameAsync(originalUsername).Result;
                ClaimsIdentity result2 = userManager.CreateIdentityAsync(result1, "ApplicationCookie").Result;

                IAuthenticationManager authentication = current.GetOwinContext().Authentication;
                authentication.SignOut("ApplicationCookie");
                IAuthenticationManager authenticationManager = authentication;
                AuthenticationProperties properties = new AuthenticationProperties();
                properties.IsPersistent = false;
                ClaimsIdentity[] claimsIdentityArray = new ClaimsIdentity[1]
                {
                    result2
                };
                authenticationManager.SignIn(properties, claimsIdentityArray);
            }
        }
    }
}

Naturally, now that we have a new service, it needs to be registered in an initialization module. In Foundation CMS, that file is InitializeSite.cs, and needs this line added:

_services.AddTransient<IImpersonationRepository, Features.Impersonation.ImpersonationRepository>();

Happy coding!

No comments: