Force Login to Optimizely DXP Environments using an Authorization Filter

When working with sites deployed to the Optimizely DXP, you may want to restrict access to the site in a particular environment to only authenticated users. This is useful when you're working on a site that's not yet live, and you don't want outside visitors to see the "work in progress" version of the site. I also see this requirement quite often for the non-production environments (integration and preproduction), which are commonly used for testing or training.

One way to lock down these environments is by simply updating the website's access rights in the environment's Settings (also known as Admin Mode), which is stored in the database tied to that environment. However, if you often synchronize the content from the production environment to the non-production environments, these settings will get overwritten and you'll continually have to remember to update them again.

A better way to achieve this is by creating an authorization filter that forces users to log in before they can access the site, and to use this filter in the DXP environments where you want to restrict access.

Creating the Authorization Filter

To create the authorization filter, you'll need to create a class that implements the IAuthorizationFilter interface. This interface has a single method, OnAuthorization, that needs to be implemented.

We'll also take this a step further by allowing the filter to optionally only allow certain roles.

public class EnvironmentAuthorizationFilter : IAuthorizationFilter
{
    private readonly string[] _roleNames;

    public EnvironmentAuthorizationFilter()
        : this(string.Empty)
    {
    }

    public EnvironmentAuthorizationFilter(string roleName)
        : this(new string[] { roleName })
    {
    }

    public EnvironmentAuthorizationFilter(string[] roleNames)
    {
        _roleNames = roleNames.Where(str => !string.IsNullOrEmpty(str)).ToArray();
    }

    public void OnAuthorization(AuthorizationFilterContext context)
    {
        /* 
        * Depending on why they failed authorization, the authorization filter will return 
        * one of two different types of IActionResult:
        * 
        - ChallengeResult — This indicates that the user was not authorized to execute 
            the action because they weren't yet logged in.
        - ForbidResult — This indicates that the user was logged in but didn't meet 
            the requirements to execute the action. They didn't have a required 
            claim, for example.
        */

        var controllerActionDescriptor = context.ActionDescriptor as ControllerActionDescriptor;

        if (controllerActionDescriptor != null)
        {
            // Allow controllers or actions with [AllowAnonymous] to bypass the check
            if (controllerActionDescriptor.ControllerTypeInfo.IsDefined(typeof(AllowAnonymousAttribute), false) ||
                controllerActionDescriptor.MethodInfo.IsDefined(typeof(AllowAnonymousAttribute), false))
            {
                return;
            }

            // EPiServer.Cms.Shell.UI.Controllers.Internal.AccountController is the out-of-the-box login screen.
            // If you are using another account login controller, you'll need to update this,
            // otherwise it will result in an infinite redirect loop.
            if (controllerActionDescriptor.ControllerTypeInfo.Equals(typeof(AccountController)))
            {
                return;
            }
        }

        if (!context.HttpContext.User.Identity.IsAuthenticated)
        {
            context.Result = new ChallengeResult();
            return;
        }

        if (_roleNames.Length == 0)
        {
            return;
        }

        foreach (var roleName in _roleNames)
        {
            if (context.HttpContext.User.IsInRole(roleName))
            {
                return;
            }
        }

        context.Result = new ForbidResult();
    }
}

In the authorization filter, we're allowing controllers or actions with the [AllowAnonymous] attribute to bypass the check. We needed to do this for custom API endpoints that we wanted to be accessible without authentication.

The filter also bypasses the check when the built-in Optimizely login screen is accessed. This is important because if the user isn't authenticated, they'll be redirected to the login screen. If the login screen is also protected, it will result in an infinite redirect loop.

Checking for the DXP Environment

To determine if the site is running in a specific DXP environment, we can use the IHostEnvironment or IWebHostEnviroment service, or we can get the environment name from the environment variables.

(Note: The IWebHostEnviroment service is an extension of the IHostEnvironment service, so it all works the same. I'm mentioning this because you'll commonly see the IWebHostEnviroment service used in the Startup.cs class, which is where the authorization filter will be registered.)

Here's some examples of how you can check for the environment:

// Get the environment name from the IHostEnvironment service
var environmentName = hostEnvironment.EnvironmentName;

// Get the environment name from the environment variables
var environmentName = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");

// Check if the environment name is "Development"
hostEnvironment.IsEnvironment("Development");

// Check if the environment name is "Development" or "Production"
hostEnvironment.IsDevelopment();
hostEnvironment.IsProduction();

Let's take this one step further and create some extension methods to make it easier to check for specific DXP environments.

First, let's create an enum to represent the DXP environments:

public enum DxpEnvironment
{
    None,
    Integration,
    Preproduction,
    Production
}

Next, let's create some helper methods and some extension methods:

public static class DxpEnvironmentExtensions
{
    public static bool IsDxpEnvironment()
    {
        return GetDxpEnvironment() != DxpEnvironment.None;
    }

    public static bool IsDxpEnvironment(DxpEnvironment dxpEnvironment)
    {
        return GetDxpEnvironment() == dxpEnvironment;
    }

    public static bool IsDxpEnvironment(this IWebHostEnvironment webHostEnvironment)
    {
        return webHostEnvironment.GetDxpEnvironment() != DxpEnvironment.None;
    }

    public static bool IsDxpEnvironment(this IWebHostEnvironment webHostEnvironment, DxpEnvironment dxpEnvironment)
    {
        return webHostEnvironment.GetDxpEnvironment() == dxpEnvironment;
    }

    public static DxpEnvironment GetDxpEnvironment()
    {
        DxpEnvironment dxpEnvironment;

        var environmentName = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");

        if (!Enum.TryParse(environmentName, true, out dxpEnvironment))
        {
            dxpEnvironment = DxpEnvironment.None;
        }

        return dxpEnvironment;
    }

    public static DxpEnvironment GetDxpEnvironment(this IWebHostEnvironment webHostEnvironment)
    {
        DxpEnvironment dxpEnvironment;

        if (!Enum.TryParse(webHostEnvironment.EnvironmentName, true, out dxpEnvironment))
        {
            dxpEnvironment = DxpEnvironment.None;
        }

        return dxpEnvironment;
    }
}

Now that we have a way to check for the DXP environment, we can use this while registering the authorization filter in our Startup.cs class.

Registering the Authorization Filter

To register the authorization filter, we need to configure the MvcOptions to add the filter in the ConfigureServices method in the Startup.cs class.

If we only want to use this in the DXP's non-production environments, we'll check for the DXP environment by name before adding the filter:

// Locks down access to low-level DXP environments
if (_webHostEnvironment.IsDxpEnvironment(DxpEnvironment.Integration) || _webHostEnvironment.IsDxpEnvironment(DxpEnvironment.Preproduction))
{
    services.Configure<MvcOptions>(options =>
    {
        // Only allow authorized users
        options.Filters.Add(new EnvironmentAuthorizationFilter());
    });
}

Or, if we want to lock down access to all DXP environments, we can just use the IsDxpEnvironment extension method:

// Locks down access to all DXP environments
if (_webHostEnvironment.IsDxpEnvironment())
{
    services.Configure<MvcOptions>(options =>
    {
        // Only allow authorized users
        options.Filters.Add(new EnvironmentAuthorizationFilter());
    });
}

Let's not forget that we can also optionally pass in a role name or an array of role names to the EnvironmentAuthorizationFilter constructor. This will allow us to only allow users with specific roles to access the site.

// Only allow authorized users with the "WebAdmins" role
options.Filters.Add(new EnvironmentAuthorizationFilter("WebAdmins"));

// Only allow authorized users with the "WebAdmins" or "WebEditors" role
options.Filters.Add(new EnvironmentAuthorizationFilter(new string[] { "WebAdmins", "WebEditors" }));

And that's it! Now, only authenticated users will be able to access the site in the your DXP environments.