Mixed mode authentication with Azure AD and Aspnet Identity for Episerver multisites

Topic: Episerver

Mehdi Panjwani
03.09.2019

A year ago, one of our Episerver CMS customers decided to move their websites from on-premise hosting to Episerver DXC Azure hosting. They wanted to use their own Azure AD as authentication and authorization for content editors and admins. We, as a service provider, wanted to use Aspnet Identity for troubleshooting and support purposes. So the mixed mode authentication (Azure AD + Aspnet Identity) was the need. 

Environment details:
CMS: Episerver multisite solution (10 websites - 13 languages)
Website Hosting: Episerver DXC Azure (single web app and single app pool serving 10 sites)
Azure AD: Customer had their own separate Azure cloud synched with their AD

Requirements:
Authentication & Authorization: Azure AD & Aspnet Identity
Preproduction and production should use customer's Azure AD
Integration and development environment could use temporary Microsoft accounts for testing.
http://local.alloy1.com/episerver should use Azure AD login
http://local.alloy1.com/cmslogin should use Aspnet identity login

Few things to note:

  • The hosted website and the customer's Azure AD were on different cloud environments.
  • We used OpenID Connect instead of WS Federation because WS Federation doesn't support multi-tenancy. In order to use WS Federation, each site must run in its own web application so WS Federation was ruled out.
  • In this tutorial, we will configure 2 locally hosted multisites against Azure AD.

Without further ado, lets see it in action.

  1. Setup Microsoft Azure
    - Create your free Microsoft account here (https://azure.microsoft.com/en-gb/free/) then login at https://portal.azure.com
    - Create App registration. Navigate to "Azure Active Directory" > "App registrations" and click "New registration".
    App registration defines the Manifest (Epi Roles), URI's allowed for authentication (also known as reply URLs) and Application Id (to be used with OpenID connect).

    Register the application. Give any name and choose single tenant or multitenant based on your requirements. 

    After registering the app, add redirect URIs (only these URI's will be accepted when authentication response token is returned after user is authenticated):
    http://local.alloy1.com:80//EPiServer
    https://local.alloy1.com:443//EPiServer
    http://local.alloy1.com:80
    http://local.alloy2.com:80//EPiServer
    https://local.alloy2.com:443//EPiServer
    http://local.alloy2.com:80

    The UI only allows to save HTTPS or LOCALHOST named site. So I prefer adding it manually from Manifest. Click on Manifest and add below json data under tag "replyUrlsWithType".
    	"replyUrlsWithType": [
    {
    "url": "http://local.alloy1.com:80//EPiServer",
    "type": "Web"
    },
    {
    "url": "https://local.alloy1.com:443//EPiServer",
    "type": "Web"
    },
    {
    "url": "http://local.alloy1.com:80",
    "type": "Web"
    },
    {
    "url": "http://local.alloy2.com:80//EPiServer",
    "type": "Web"
    },
    {
    "url": "https://local.alloy2.com:443//EPiServer",
    "type": "Web"
    },
    {
    "url": "http://local.alloy2.com:80",
    "type": "Web"
    }
    ]

    Allow "ID tokens" request for our registered app under "Authentication":

    Next, we need to create roles in our "MixedAuth-Local" App registration. We will setup these roles:
    AzureWebAdmins: Can login and only admin the sites
    AzureWebEditors: Can login and only see the CMS editing interface but cannot change any content
    AzureAllSitesWebEditors: Can change the contents
    AzureVisitorGroup: Can manage visitor groups
    AzureSearchAdmins: Can manage and configure Epi find features
    Later we will attach these Azure roles to Epi virtual roles. With this kind of setup, we will never have to change web.config whenever new roles are required.

    In the Manifest file, paste below roles under section "appRoles". If you want to add more roles, make sure you specify new unique guids for them:
    	"appRoles": [
    {
    "allowedMemberTypes": [
    "User"
    ],
    "description": "AzureWebAdmins can admin the sites",
    "displayName": "AzureWebAdmins",
    "id": "157180e6-f96d-46d0-91a7-b62d5b0ba6cb",
    "isEnabled": true,
    "lang": null,
    "origin": "Application",
    "value": "AzureWebAdmins"
    },
    {
    "allowedMemberTypes": [
    "User"
    ],
    "description": "AzureWebEditors can only see CMS editing module but they still need site specific edit permissions",
    "displayName": "AzureWebEditors",
    "id": "36d19f07-e418-4acd-9b7e-2b2680b9d308",
    "isEnabled": true,
    "lang": null,
    "origin": "Application",
    "value": "AzureWebEditors"
    },
    {
    "allowedMemberTypes": [
    "User"
    ],
    "description": "AzureAllSitesWebEditors can edit all websites",
    "displayName": "AzureAllSitesWebEditors",
    "id": "e0cb40bb-76e6-457d-a4a1-c85a023d7fb4",
    "isEnabled": true,
    "lang": null,
    "origin": "Application",
    "value": "AzureAllSitesWebEditors"
    },
    {
    "allowedMemberTypes": [
    "User"
    ],
    "description": "AzureVisitorGroup can manage visitor groups",
    "displayName": "AzureVisitorGroup",
    "id": "b2907ad2-9c58-43b6-86d6-7e02498a2338",
    "isEnabled": true,
    "lang": null,
    "origin": "Application",
    "value": "AzureVisitorGroup"
    },
    {
    "allowedMemberTypes": [
    "User"
    ],
    "description": "AzureSearchAdmins can manage and configure the Epi Find features",
    "displayName": "AzureSearchAdmins",
    "id": "b27307d7-d27e-45c5-b50f-4d83f4c38df5",
    "isEnabled": true,
    "lang": null,
    "origin": "Application",
    "value": "AzureSearchAdmins"
    },
    {
    "allowedMemberTypes": [
    "User"
    ],
    "description": "AzureAlloy1WebEditors can edit only Alloy1 site",
    "displayName": "AzureAlloy1WebEditors",
    "id": "fdb47c37-489e-43a0-9d8c-eb4c1a7ff339",
    "isEnabled": true,
    "lang": null,
    "origin": "Application",
    "value": "AzureAlloy1WebEditors"
    },
    {
    "allowedMemberTypes": [
    "User"
    ],
    "description": "AzureAlloy2WebEditors can edit only Alloy2 site",
    "displayName": "AzureAlloy2WebEditors",
    "id": "5e633fbd-61bd-4789-a9a1-68d64dbf5d13",
    "isEnabled": true,
    "lang": null,
    "origin": "Application",
    "value": "AzureAlloy2WebEditors"
    }
    ]
    - Assign roles to AD users. The free test Azure account doesn't supports assigning Active Directory groups to users. So instead of assigning Roles to Groups and Groups to users, we will directly assign Roles to the user. Under "Azure Active Directory" > "Enterprise applications", click our newly registered app "MixedAuth-Local". Then click "Users and groups" and click "Add user".

    Select user and the roles that you want to specify. For this demo, I will assign AzureWebEditors and AzureAlloy1WebEditors to my user.



  2. Code changes
    - Install necessary Nuget packages:
    Microsoft.AspNet.Identity.Owin
    Microsoft.AspNet.Identity.Core
    Microsoft.AspNet.Identity.EntityFramework
    Microsoft.Owin.Host.SystemWeb
    Microsoft.Owin.Security.Cookies
    Microsoft.Owin.Security.OAuth
    Microsoft.Owin.Security.OpenIdConnect
    Microsoft.Owin
    Microsoft.IdentityModel.Tokens
    Microsoft.IdentityModel.Protocols.OpenIdConnect
    Microsoft.IdentityModel.Logging
    System.IdentityModel.Tokens.Jwt
    - Web.config changes. Modify your web.config as per below:
    <configuration>
    <appSettings>
    <add key="owin:AutomaticAppStartup" value="true" />
    <add key="owin:AppStartup" value="EpiserverTest.Startup, EpiserverTest" />
    <add key="ida:AADInstance" value="https://login.microsoftonline.com/{0}" />
    <add key="ida:Tenant" value="xxxxxxxxxx.onmicrosoft.com" />
    <add key="ida:ClientId" value="This is Application(client) ID of MixedAuth-Local App" />
    <add key="ida:PostLogoutRedirectUri" value="http://local.alloy1.com/CMSLogin/Signout" />
    <add key="ida:Authority" value="common" />
    </appSettings>
    <system.web>
    <authentication mode="None">
    </authentication>
    <membership>
    <providers>
    <clear />
    </providers>
    </membership>
    <roleManager enabled="false">
    <providers>
    <clear />
    </providers>
    </roleManager>
    </system.web>
    <system.webServer>
    <rewrite>
    <rules>
    <rule name="Redirect episerver logout url to our custom logout implementation" stopProcessing="true">
    <match url="util/logout.aspx" ignoreCase="true" />
    <action type="Redirect" url="/CMSLogin/Signout" />
    </rule>
    </rules>
    </rewrite>
    </system.webServer>
    <episerver.framework>
    <virtualRoles addClaims="true">
    <providers>
    <add name="Administrators" type="EPiServer.Security.WindowsAdministratorsRole, EPiServer.Framework" />
    <add name="Everyone" type="EPiServer.Security.EveryoneRole, EPiServer.Framework" />
    <add name="Authenticated" type="EPiServer.Security.AuthenticatedRole, EPiServer.Framework" />
    <add name="Anonymous" type="EPiServer.Security.AnonymousRole, EPiServer.Framework" />
    <add name="CmsAdmins" type="EPiServer.Security.MappedRole, EPiServer.Framework" roles="Administrators, WebAdmins, AzureWebAdmins" mode="Any" />
    <add name="CmsEditors" type="EPiServer.Security.MappedRole, EPiServer.Framework" roles="WebEditors" mode="Any" />
    <add name="Creator" type="EPiServer.Security.CreatorRole, EPiServer" />
    <add name="PackagingAdmins" type="EPiServer.Security.MappedRole, EPiServer.Framework" roles="Administrators, WebAdmins" mode="Any" />
    <add name="SearchAdmins" type="EPiServer.Security.MappedRole, EPiServer.Framework" roles="Administrators, WebAdmins, AzureSearchAdmins" mode="Any" />
    <add name="VisitorGroupAdmins" type="EPiServer.Security.MappedRole, EPiServer" roles="AzureVisitorGroup, AzureWebAdmins, WebAdmins" mode="Any" />
    <add name="SiteimproveAdmins" type="EPiServer.Security.MappedRole, EPiServer" roles="AzureSiteImproveGroup, AzureWebAdmins, WebAdmins" mode="Any" />
    </providers>
    </virtualRoles>
    </episerver.framework>
    <location path="EPiServer">
    <system.web>
    <authorization>
    <allow roles="WebEditors, WebAdmins, Administrators, AzureWebAdmins, AzureWebEditors" />
    <deny users="*" />
    </authorization>
    </system.web>
    </location>
    <location path="EPiServer/CMS/admin">
    <system.web>
    <authorization>
    <allow roles="WebAdmins, Administrators, AzureWebAdmins" />
    <deny users="*" />
    </authorization>
    </system.web>
    </location>
    <location path="Views/Plugins">
    <system.web>
    <authorization>
    <allow roles="WebAdmins, WebEditors, Administrators" />
    <deny users="*" />
    </authorization>
    </system.web>
    </location>
    </configuration>


    For roles assignment, we have assigned CmsAdmin role to our AzureWebAdmin role. So any one with AzureWebAdmin role will become an Admin. We have not assigned AzureWebEditors to CMSEditors because we do not want to give all the editing access to AzureWebEditors role. Instead CMS content related editing roles will be handled in Episerver access rights. We just allow the EpiServer location path to AzureWebEditors so they are allowed to login. So as an example, to give a user editing rights for Alloy1 site, we will assign two roles: AzureWebEditors (this allows the authorization to location path="Episerver") and AzureAlloy1WebEditors (this allows editing rights for Alloy1 site only). You can further restrict roles down to languages (e.g. AzureAlloy1EnglishWebEditors, AzureAlloy1FrenchWebEditors) and handle the content and language specific rights from Episerver Admin panel.

    In production environment (paid Azure subscription), we can assign roles to AD synced groups and assign those groups to users. However, external users cannot be assigned groups and they will still need to be assigned roles directly.

    - Code changes:
    Paste below code in Startup.cs class. If Startup class do not exists, create one in the root directory of the project.
    Startup.cs was modified as per our needs. Original source here: https://world.episerver.com/documentation/developer-guides/CMS/security/integrate-azure-ad-using-openid-connect/
    using EPiServer.Cms.UI.AspNetIdentity;
    using EPiServer.Security;
    using EPiServer.ServiceLocation;
    using EpiserverTest;
    using Microsoft.AspNet.Identity;
    using Microsoft.AspNet.Identity.Owin;
    using Microsoft.IdentityModel.Protocols.OpenIdConnect;
    using Microsoft.IdentityModel.Tokens;
    using Microsoft.Owin;
    using Microsoft.Owin.Extensions;
    using Microsoft.Owin.Security;
    using Microsoft.Owin.Security.Cookies;
    using Microsoft.Owin.Security.Notifications;
    using Microsoft.Owin.Security.OpenIdConnect;
    using Owin;
    using System;
    using System.Configuration;
    using System.Globalization;
    using System.Security.Claims;
    using System.Threading.Tasks;
    using System.Web;
    using System.Web.Helpers;

    [assembly: OwinStartup(typeof(Startup))]
    namespace EpiserverTest
    {
      public class Startup
    {
    private static readonly string aadInstance = ConfigurationManager.AppSettings["ida:AADInstance"];
    private static string clientId = ConfigurationManager.AppSettings["ida:ClientId"];
    private static readonly string postLogoutRedirectUri = ConfigurationManager.AppSettings["ida:PostLogoutRedirectUri"];
    private static string tenant = ConfigurationManager.AppSettings["ida:Tenant"];
    private static readonly string authority = String.Format(CultureInfo.InvariantCulture, aadInstance, tenant);
    private const string UrlLogout = "/CMSLogin/Signout";
    private const string UrlLogin = "/Util/login.aspx";

    public void Configuration(IAppBuilder app)
    {
    // Add CMS ASP.NET Identity
    app.AddCmsAspNetIdentity<ApplicationUser>();

    // Enable cookie authentication, used to store the claims between requests
    app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);
    app.UseCookieAuthentication(new CookieAuthenticationOptions());

    // Use cookie to store information for the signed in user
    app.UseCookieAuthentication(new CookieAuthenticationOptions
    {
    AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
    SlidingExpiration = true,
    ExpireTimeSpan = TimeSpan.FromHours(2.0),
    LoginPath = new PathString(UrlLogin),
    LogoutPath = new PathString(UrlLogout),
    Provider = new CookieAuthenticationProvider
    {
    // Enables the application to validate the security stamp when the user logs in.
    // This is a security feature which is used when you change a password or add an external login to your account.
    OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<ApplicationUserManager<ApplicationUser>, ApplicationUser>(
    validateInterval: TimeSpan.FromMinutes(30),
    regenerateIdentity: (manager, user) => manager.GenerateUserIdentityAsync(user)),
    OnApplyRedirect = (context => context.Response.Redirect(context.RedirectUri)),
    OnResponseSignOut = (context => context.Response.Redirect(UrlLogin))
    }
    });

    //Enable OpenId authentication
    app.UseOpenIdConnectAuthentication(new OpenIdConnectAuthenticationOptions
    {
    ClientId = clientId,
    Authority = authority,
    PostLogoutRedirectUri = postLogoutRedirectUri,
    TokenValidationParameters = new TokenValidationParameters
    {
    ValidateIssuer = false,
    RoleClaimType = ClaimTypes.Role
    },
    Notifications = new OpenIdConnectAuthenticationNotifications
    {
    AuthenticationFailed = context =>
    {
    context.HandleResponse();
    context.Response.Write(context.Exception.Message);
    return Task.FromResult(0);
    },
    RedirectToIdentityProvider = context =>
    {
    // Here you can change the return uri based on multisite
    HandleMultiSiteReturnUrl(context);

    // To avoid a redirect loop to the federation server send 403
    // when user is authenticated but does not have access
    if (context.OwinContext.Response.StatusCode == 401 &&
    context.OwinContext.Authentication.User.Identity.IsAuthenticated)
    {
    context.OwinContext.Response.StatusCode = 403;
    context.HandleResponse();
    }

    return Task.FromResult(0);
    },
    SecurityTokenValidated = (ctx) =>
    {
    var redirectUri = new Uri(ctx.AuthenticationTicket.Properties.RedirectUri,
    UriKind.RelativeOrAbsolute);
    if (redirectUri.IsAbsoluteUri)
    {
    ctx.AuthenticationTicket.Properties.RedirectUri = redirectUri.PathAndQuery;
    }

    // Storing role as SSO in claims dictionary. Useful when logging out user.
    ctx.AuthenticationTicket.Identity.AddClaim(new Claim(ClaimTypes.Role, "SSO"));

    //Sync user and the roles to EPiServer in the background
    ServiceLocator.Current.GetInstance<ISynchronizingUserService>()
    .SynchronizeAsync(ctx.AuthenticationTicket.Identity);

    return Task.FromResult(0);
    }
    }
    });

    app.UseStageMarker(PipelineStage.Authenticate);

    // If the application throws an antiforgery token exception like “AntiForgeryToken: A Claim of Type NameIdentifier or IdentityProvider Was Not Present on Provided ClaimsIdentity”
    AntiForgeryConfig.UniqueClaimTypeIdentifier = ClaimTypes.NameIdentifier;
    }

    private void HandleMultiSiteReturnUrl(RedirectToIdentityProviderNotification<OpenIdConnectMessage, OpenIdConnectAuthenticationOptions> context)
    {
    // here you change the context.ProtocolMessage.RedirectUri to corresponding siteurl
    // this is a sample of how to change redirecturi in the multi-tenant environment
    if (context.ProtocolMessage.RedirectUri == null)
    {
    var currentUrl = HttpContext.Current.Request.Url;
    context.ProtocolMessage.RedirectUri = new UriBuilder(
    currentUrl.Scheme,
    currentUrl.Host,
    currentUrl.Port).ToString() + "/EPiServer";
    }
    }
    }
    }
    Register the route in route table (Global.asax.cs or wherever you have your routes)
    public class EPiServerApplication : EPiServer.Global
    {
    protected void Application_Start()
    {
    AreaRegistration.RegisterAllAreas();
    //Tip: Want to call the EPiServer API on startup? Add an initialization module instead (Add -> New Item.. -> EPiServer -> Initialization Module)

    Routes(RouteTable.Routes);
    }
    public void Routes(RouteCollection routes)
    {
    routes.MapRoute("Mixed Authentication", "CMSLogin/{action}/", new { controller = "CMSLogin", action = "Signin" });
    }
    }
    Create CMSLoginController.cs (in Controllers/Login/CMSLoginController.cs) and paste below code:
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Web;
    using System.Web.Mvc;
    using System.Configuration;
    using Microsoft.Owin.Security;
    using EpiserverTest.ViewModels.Pages;
    using EPiServer.Shell.Security;
    using EPiServer.ServiceLocation;
    using Microsoft.Owin.Security.OpenIdConnect;
    using Microsoft.Owin.Security.Cookies;
    using System.Web.Security;
    using EPiServer.Security;
    using System.Security.Claims;
    using EPiServer.Web.Routing;
    using EPiServer.Core;

    namespace EpiserverTest.Controllers.Login
    {
    public class CMSLoginController : Controller
    {
    private readonly string loginUrl = "/Util/login.aspx";
    private readonly UISignInManager _uISignInManager;

    public CMSLoginController(UISignInManager uISignInManager)
    {
    _uISignInManager = uISignInManager;
    }

    // GET: Login
    [HttpGet]
    [Route("CMSLogin/Signin/{ReturnUrl}")]
    public ActionResult Signin(string ReturnUrl)
    {
    return Redirect(loginUrl);
    }

    [HttpGet]
    [Route("CMSLogin/Signout")]
    public ActionResult Signout()
    {
    if (HttpContext.GetOwinContext().Authentication.User.HasClaim(x => x.Type.Equals(ClaimTypes.Role) && x.Value.Equals("SSO") )) // if SSO user
    {
    HttpContext.GetOwinContext().Authentication.SignOut(
    OpenIdConnectAuthenticationDefaults.AuthenticationType,
    CookieAuthenticationDefaults.AuthenticationType);
    return Redirect(UrlResolver.Current.GetUrl(ContentReference.StartPage));
    }
    else // if Aspnet user
    {
    _uISignInManager.SignOut();
    return Redirect(loginUrl);
    }
    }
    }
    }
    No need to create a view for CMSLoginController since we will redirect the user to Episerver's own authentication page.

    PS: The Azure roles will only show/sync in Episerver's Admin Access Rights after the user with the assigned roles logs in. This is because the synchronization of Azure roles with Episerver happens after the user logs in.

Abonner på vårt nyhetsbrev