Following on from Ben Morris' blog about Converting EPiServer 6 to use claims-based authentication with WIF I was intrigued to see if it was possible to create a role and membership provider that allowed us to use WIF in EPiServer with little or no modification apart from configuration changes.

I set out with the following list of requirements for my EPiServer WIF solution:

  • Plug in to EPiServer with no code changes, only config changes
  • All edit/admin functionality to work the same as the Windows providers (i.e. read only access)
  • Scheduled jobs and workflows to execute with no modification (i.e. EPiServer.Security.PrincipleInfo.CreatePrinciple() should continue to work)

I made the following assumptions:

  • The WIF providers will not be multiplexed
  • There will be claims that can be translated into roles for use in EPiServer
  • Users will only have a single identity (IClaimsPrincipal can support multiple IClaimsIdentity items)

Creating the WIF role and membership providers

The biggest drawback of WIF when it comes to EPiServer is the fact that EPiServer expects the role and membership providers to have access to role and membership stores. However this isn't possible under WIF as the user comes to EPiServer with a set of claims. So it was obvious that we needed a set of membership and role providers that provide a local cache. This will allow us to store membership and role data in a local cache when a user is first authenticated. This will allow the standard edit/admin functions to work correctly and also allow scheduled jobs and workflows to execute with no modification. The local cache can be provided by inheriting the standard SQL membership and role providers and modifying as follows:

using System.Collections.Generic;
using System.Security.Principal;
using System.Web;
using System.Web.Security;
using Microsoft.IdentityModel.Claims;
 
namespace EPiServerWIF.Providers
{
    public class WIFRoleProvider : SqlRoleProvider
    {
        /// <summary>
        /// Check if claims about the user are available
        /// </summary>
        /// <returns>True if claims are available, false if not</returns>
        private bool claimsAvailable()
        {
            return (HttpContext.Current != null && HttpContext.Current.User != null);
        }
 
        /// <summary>
        /// Return the roles from the claims that the user has (used in the HTTP context)
        /// </summary>
        /// <returns>List<string> containing all roles for the current user</returns>
        public static List<string> GetClaimsRoles(IPrincipal principal)
        {
            IClaimsPrincipal claimsPrinciple = ClaimsPrincipal.CreateFromPrincipal(principal);
            IClaimsIdentity claimsIdentity = (IClaimsIdentity)claimsPrinciple.Identity;
 
            List<string> userRoles = new List<string>();
 
            foreach (var claim in claimsIdentity.Claims)
            {
                if (claim.ClaimType.Contains(@"http://schemas.microsoft.com/ws/2008/06/identity/claims/role"))
                {
                    userRoles.Add(WIFRoleProvider.ClaimToRoleString(claim));
                }
            }
 
            return userRoles;
        }
 
        /// <summary>
        /// Map a claims role to a string for use in the local role cache
        /// </summary>
        /// <param name="claim">The claim containing the role information</param>
        /// <returns>A string representing a unique role</returns>
        public static string ClaimToRoleString(Claim claim)
        {
            return claim.Value;
 
            //You may want to qualify the role by the issuer, rather than simply using the role name
            //return claim.Value + " (" + claim.Issuer + ")";
        }
    }
}
using System.Web.Security;
 
namespace EPiServerWIF.Providers
{
    public class WIFMemberProvider : SqlMembershipProvider
    {
        //We are simply wrapping the default SqlMembershipProvider so ensure that the provider does not complain about passwords etc
 
        public override bool RequiresUniqueEmail
        {
            get { return false; }
        }
        public override bool RequiresQuestionAndAnswer
        {
            get { return false; }
        }
        public override bool EnablePasswordReset
        {
            get { return false; }
        }
        public override bool EnablePasswordRetrieval
        {
            get { return false; }
        }
 
        public override int MinRequiredNonAlphanumericCharacters
        {
            get { return 0; }
        }
        public override int MinRequiredPasswordLength
        {
            get { return 0; }
        }
        public override MembershipPasswordFormat PasswordFormat
        {
            get { return MembershipPasswordFormat.Hashed; }
        }
        public override int PasswordAttemptWindow
        {
            get { return int.MaxValue; }
        }
        public override int MaxInvalidPasswordAttempts
        {
            get { return int.MaxValue; }
        }
 
        public override string PasswordStrengthRegularExpression
        {
            get { return string.Empty; }
        }
 
    }
}

It can be seen that I used the "http://schemas.microsoft.com/ws/2008/06/identity/claims/role" claim for the roles but this can be changed to map to any claim that comes back from the security token service (STS).

Hooking into the WIF events

As we are caching the users and roles locally we need to know when a user has signed in to synchronise the cache. This is achieved by inheriting from the standard WSFederationAuthenticationModule overriding the OnSignedIn method and synchronising the providers. This allows us to pick up the claims from the STS and save them out to the local membership and role cache:

using System;
using System.Collections.Generic;
using System.Security.Principal;
using System.Web;
using System.Web.Security;
using EPiServerWIF.Providers;
using Microsoft.IdentityModel.Claims;
using Microsoft.IdentityModel.Web;
 
namespace EPiServerWIF.Modules
{
    public class EPiServerFederatedAuthenticationModule : WSFederationAuthenticationModule
    {
        protected override void OnSignedIn(EventArgs args)
        {
            base.OnSignedIn(args);
 
            //When the user has signed in then ensure the local providers are sync'ed
            SyncProviders(HttpContext.Current.User as IClaimsPrincipal);
        }
 
        public static void SyncProviders(IClaimsPrincipal claimsPricipal)
        {
            //Ensure we have a reference to user in the local membership provider store (add   
            //additional claim mappings for things such as email address as necessary here)
            string username = claimsPricipal.Identity.Name;
            if (Membership.GetUser(username) == null)
                Membership.CreateUser(username, Guid.NewGuid().ToString() + "aA#");
 
            //Get the WIF role provider from the providers collection
            WIFRoleProvider wifRoles = null;
            foreach (var provider in Roles.Providers)
            {
                if (provider.GetType() == typeof(WIFRoleProvider))
                    wifRoles = provider as WIFRoleProvider;
            }
 
            //Bail out at this point if we don't have a WifRolesProvider
            if (wifRoles != null)
            {
                //Get the claims that represent the roles and cache in the local roles store
                List<string> userRoles = new List<string>();
 
                //Get all claims that represent roles for this user
                userRoles = WIFRoleProvider.GetClaimsRoles(claimsPricipal as IPrincipal);
 
                //Ensure the role exists locally in the role cache
                foreach (string role in userRoles)
                {
                    if (!wifRoles.RoleExists(role))
                        Roles.CreateRole(role);
                }
 
                //Remove the user from all previous role associations
                string[] currentUserRoles = Roles.GetRolesForUser(username);
                if (currentUserRoles.Length > 0)
                    Roles.RemoveUserFromRoles(username, currentUserRoles);
 
                //Finally ensure our user is associated with the role
                Roles.AddUserToRoles(username, userRoles.ToArray());
            }
 
        }
 
    }
}

Now we need to replace the standard WIF WSFederationAuthenticationModule with the custom module in web.config as follows: 

<modules runAllManagedModulesForAllRequests="true">
  ...
  <add name="WSFederationAuthenticationModule" type="EPiServerWIF.Modules.EPiServerFederatedAuthenticationModule, EPiServerWIF" preCondition="managedHandler"/>
  ...
</modules>

We want to mark the providers as only having read only access to their store so this is achieved with a EPiServer 6 initialisation module. This used the EPiServer ProviderCapabilities collection which can be used to mark the WIF providers as being read only: 

using EPiServer.Framework;
using EPiServer.Security;
using EPiServerWIF.Providers;
 
namespace EPiServerWIF.Modules
{
    [InitializableModule]
    [ModuleDependency((typeof(EPiServer.Web.InitializationModule)))]
    public class WIFEPiServerModule : IInitializableModule
    {
 
        #region IInitializableModule Members
 
        public void Initialize(EPiServer.Framework.Initialization.InitializationEngine context)
        {
            //Show the providers have no capabilties
            if (!ProviderCapabilities.Providers.ContainsKey(typeof(WIFRoleProvider)))
                ProviderCapabilities.AddProvider(typeof(WIFRoleProvider), new ProviderCapabilitySettings(0, new string[0]));
            if (!ProviderCapabilities.Providers.ContainsKey(typeof(WIFMemberProvider)))
                ProviderCapabilities.AddProvider(typeof(WIFMemberProvider), new ProviderCapabilitySettings(0, new string[0]));
        }
 
 
        public void Preload(string[] parameters) { }
 
        public void Uninitialize(EPiServer.Framework.Initialization.InitializationEngine context) { }
 
        #endregion
 
    }
}

Seeing it action

At this point what we have is a set of role and membership providers that allows EPiServer to see WIF claims through read-only role and membership providers: 

However site editors and administrators can still use the user and group functions as they would normally:

I put together a super simple scheduled job to show the new providers in action. It simply executes a single line of code as shown below:

Obviously apart from the initialisation module and scheduled job this isn't an EPiServer specific implementation so could be used on any ASP.net application.

Signing in and signing out

By implementing WIF we are federating our sign in and sign out capabilities to a 3rd party STS. So to log in we need to redirect to actually sign in and come back to the current page. This is achieved as follows:

WSFederationAuthenticationModule authModule = FederatedAuthentication.WSFederationAuthenticationModule; 
authModule.RedirectToIdentityProvider((sender as Control).ID, authModule.Realm.TrimEnd(@"/".ToCharArray()) + Request.RawUrl, false); 

Similarly there is a federated sign out too:

WSFederationAuthenticationModule authModule = FederatedAuthentication.WSFederationAuthenticationModule; 
string signoutUrl = (WSFederationAuthenticationModule.GetFederationPassiveSignOutUrl(authModule.Issuer, authModule.Realm, null)); 
WSFederationAuthenticationModule.FederatedSignOut(new Uri(authModule.Issuer), new Uri(authModule.Realm.TrimEnd(@"/".ToCharArray()) + Request.RawUrl)); 

Testing with Security Token Services

I tested this approach using ADFS 2.0 connecting to Active Directory where I mapped the name claim to the AD username and AD groups to the roles claim. I also tried with a custom STS as described in Ben Morris' post here: http://www.ben-morris.com/creating-a-security-token-service-website-using-wif-in-visual-studio

Conclusion

This is simply a proof of concept and is not yet suitable for a production environment. But I would be very interested to hear from ideas on how to take this forward. I’d also like to hear how EPiServer plan to integrate WIF into EPiServer.

I think WIF integration could really drive EPiServer up the value chain and mark the CMS as even more enterprise focussed.

Debugging

One final note. Since this part of this solution uses an EPiServer initialisation module, its worth remembering Magnus Stråle's note about debugging initialisation modules: http://world.episerver.com/Blogs/Magnus-Strale/Dates/2010/8/Debugging-initialization-modules/

Source code

Download EPiServerWIF.zip for the source code


Comments

comments powered by Disqus