Dual Login Method for Sharepoint

Mike BerrymanI recently had a client with a unique situation regarding logging in to their Sharepoint environment.  Without going into too much boring detail, the bottom line was the client needed to allow their Active Directory users to log in to a Sharepoint site that only used Forms-Based Authentication.  (Sidenote: If you’re truly curious, the Sharepoint site itself wasn’t actively denying people using windows credentials.  The problem was that the browser the client’s AD users were using to access this particular Sharepoint site didn’t play well with windows credentials).

Now, this in and of itself isn’t really that big of an issue – it’s fairly common knowledge that you can set up a forms-based authentication provider in Sharepoint that will authenticate against Active Directory – but the problem was that in addition to the AD users, the client also has an additional set of users who are truly forms users who would also need to access this site.  That is to say, not only do the AD users need to access this Sharepoint site via forms-based authentication, but the real forms users still needed access as well!  This meant just switching the authentication provider to authenticate against AD would leave the forms users in the dust, but not changing the provider accordingly would result in AD users being unable to use the site, and of course you can’t have more than one forms membership provider in Sharepoint.

The answer? Custom authentication provider to do both!

Before going any further, I need to give credit where credit is due.  I didn’t start my custom provider purely from scracth.  Instead, I found a good starting point for what I wanted to accomplish in a blog post by Mike Mayer, found at http://www.geekoncode.com/2009/09/updated-custom-sharepoint.html.  It didn’t accomplish everything I wanted from a custom provider that hits both AD and Sql, but it’s a good start and saved me a chunk of time.  Also note that the custom provider I ended up with didn’t need to implement a good number of the override methods, as for my purposes they simply weren’t needed.

With disclaimers out of the way, on to the solution!  First up: The Membership Provider.  Being a dual method authentication provider and not exactly being able to check both Sql and AD at the same time for a supplied set of credentials, the first thing to note about this solution is that it attempts to authenticate a user against Sql first, and only if that fails will it then check against Active Directory.

public class FBAMembershipProvider : SqlMembershipProvider
{
    public override string ApplicationName
    {
        get { return "<application name>"; }
    }

    private LdapMembershipProvider _ldapProvider = null;
    private LdapMembershipProvider LdapProvider
    {
        get
        {
            if (_ldapProvider == null)
                _ldapProvider = new LdapMembershipProvider();
            return _ldapProvider;
        }
    }

    public override bool ValidateUser(string username, string password)
    {
        if (base.ValidateUser(username, password))
            return true;
        else if (LdapProvider.ValidateUser(username, password))
            return true;
        else
            return false;
    }

    public override MembershipUser GetUser(string username, bool userIsOnline)
    {
        if (base.GetUser(username, userIsOnline) != null)
            return base.GetUser(username, userIsOnline);
        else
            return LdapProvider.GetUser(username, userIsOnline);
    }

    public override MembershipUser GetUser(object providerUserKey, bool userIsOnline)
    {
        if (base.GetUser(providerUserKey, userIsOnline) != null)
            return base.GetUser(providerUserKey, userIsOnline);
        else
            return LdapProvider.GetUser(providerUserKey, userIsOnline);
    }

    public override MembershipUserCollection FindUsersByEmail(string emailToMatch, int pageIndex, int pageSize, out int totalRecords)
    {
        if (base.FindUsersByEmail(emailToMatch, pageIndex, pageSize, out totalRecords).Count > 0)
            return base.FindUsersByEmail(emailToMatch, pageIndex, pageSize, out totalRecords);
        else
            return LdapProvider.FindUsersByEmail(emailToMatch, pageIndex, pageSize, out totalRecords);
    }

    public override MembershipUserCollection FindUsersByName(string usernameToMatch, int pageIndex, int pageSize, out int totalRecords)
    {
        if (base.FindUsersByName(usernameToMatch, pageIndex, pageSize, out totalRecords).Count > 0)
            return base.FindUsersByName(usernameToMatch, pageIndex, pageSize, out totalRecords);
        else
            return LdapProvider.FindUsersByName(usernameToMatch, pageIndex, pageSize, out totalRecords);
    }

    public override MembershipUserCollection GetAllUsers(int pageIndex, int pageSize, out int totalRecords)
    {
        if (base.GetAllUsers(pageIndex, pageSize, out totalRecords).Count > 0)
            return base.GetAllUsers(pageIndex, pageSize, out totalRecords);
        else
            return LdapProvider.GetAllUsers(pageIndex, pageSize, out totalRecords);
    }

    public override string GetUserNameByEmail(string email)
    {
        if (!string.IsNullOrEmpty(base.GetUserNameByEmail(email)))
            return base.GetUserNameByEmail(email);
        else
            return LdapProvider.GetUserNameByEmail(email);    }
}

Notice that all of the overridden methods do basically the same thing: attempt the operation against the base (Sql), and if that fails, then attempt the same operation against Active Directory using the LdapMembershipProvider, which is up next.

public class LdapMembershipProvider : System.Web.Security.MembershipProvider
{
    private string _applicationName = string.Empty;
    public override string ApplicationName
    {
        get
        {
            if (_applicationName == string.Empty)
            {
                _applicationName = "<application name>";
            }
            return _applicationName;
        }
        set { this._applicationName = "<application name>"; }
    }

    public override MembershipUserCollection FindUsersByEmail(string emailToMatch, int pageIndex, int pageSize, out int totalRecords)
    {
        try
        {
            MembershipUserCollection coll = new MembershipUserCollection();
            int resultRecords = 0;
            int startIndex = pageIndex * pageSize;
            int endIndex = startIndex + pageSize;
            SPSecurity.RunWithElevatedPrivileges(new SPSecurity.CodeToRunElevated(delegate()
            {
                using (PrincipalContext context = new PrincipalContext(ContextType.Domain))
                {
                    using (UserPrincipal userFilter = new UserPrincipal(context))
                    {
                        userFilter.EmailAddress = emailToMatch.Replace("%", "*");
                        using (PrincipalSearcher search = new PrincipalSearcher(userFilter))
                        {
                            PrincipalSearchResult users = search.FindAll();
                            resultRecords = users.Count();
                            if (resultRecords < endIndex + 1)
                            {
                                startIndex = 0;
                                endIndex = resultRecords - 1;
                            }
                            for (int i = startIndex; i <= endIndex; i++)
                            {
                                coll.Add(GetMembershipUser(users.ElementAt(i) as UserPrincipal));
                            }
                        }
                    }
                }
            }));
            totalRecords = resultRecords;
            return coll;
        }
        catch (Exception ex)
        {
            totalRecords = 0;
            return null;
        }
    }

    public override MembershipUserCollection FindUsersByName(string usernameToMatch, int pageIndex, int pageSize, out int totalRecords)
    {
        try
        {
            MembershipUserCollection coll = new MembershipUserCollection();
            int resultRecords = 0;
            int startIndex = pageIndex * pageSize;
            int endIndex = startIndex + pageSize;
            SPSecurity.RunWithElevatedPrivileges(new SPSecurity.CodeToRunElevated(delegate()
            {
                using (PrincipalContext context = new PrincipalContext(ContextType.Domain))
                {
                    using (UserPrincipal userFilter = new UserPrincipal(context))
                    {
                        userFilter.SamAccountName = usernameToMatch.Replace("%", "*");
                        using (PrincipalSearcher search = new PrincipalSearcher(userFilter))
                        {
                            PrincipalSearchResult users = search.FindAll();
                            resultRecords = users.Count();
                            if (resultRecords < endIndex + 1)
                            {
                                startIndex = 0;
                                endIndex = resultRecords - 1;
                            }
                            for (int i = startIndex; i <= endIndex; i++)
                            {
                                coll.Add(GetMembershipUser(users.ElementAt(i) as UserPrincipal));
                            }
                        }
                    }
                }
            }));
            totalRecords = resultRecords;
            return coll;
        }
        catch (Exception ex)
        {
            totalRecords = 0;
            return null;
        }
    }

    public override MembershipUserCollection GetAllUsers(int pageIndex, int pageSize, out int totalRecords)
    {
        try
        {
            MembershipUserCollection memUserCollection = new MembershipUserCollection();
            int resultRecords = 0;
            int startIndex = pageIndex * pageSize;
            int endIndex = startIndex + pageSize;
            SPSecurity.RunWithElevatedPrivileges(new SPSecurity.CodeToRunElevated(delegate()
            {
                using (PrincipalContext context = new PrincipalContext(ContextType.Domain))
                {
                    using (UserPrincipal userFilter = new UserPrincipal(context))
                    {
                        using (PrincipalSearcher search = new PrincipalSearcher(userFilter))
                        {
                            PrincipalSearchResult user = search.FindAll();
                            resultRecords = user.Count();
                            if (resultRecords < endIndex + 1)
                            {
                                startIndex = 0;
                                endIndex = resultRecords - 1;
                            }
                            for (int i = startIndex; i <= endIndex; i++)
                            {
                                memUserCollection.Add(GetMembershipUser(user.ElementAt(i) as UserPrincipal));
                            }
                        }
                    }
                }
            }));
            totalRecords = resultRecords;
            return memUserCollection;
        }
        catch (Exception ex)
        {
            totalRecords = 0;
            return null;
        }
        
    }

    public override MembershipUser GetUser(string username, bool userIsOnline)
    {
        try
        {
            MembershipUser memUser = null;
            SPSecurity.RunWithElevatedPrivileges(new SPSecurity.CodeToRunElevated(delegate()
            {
                using (PrincipalContext context = new PrincipalContext(ContextType.Domain))
                {
                    using (UserPrincipal userFilter = new UserPrincipal(context))
                    {
                        userFilter.SamAccountName = username.Replace("%", "*");
                        using (PrincipalSearcher search = new PrincipalSearcher(userFilter))
                        {
                            UserPrincipal user = search.FindOne() as UserPrincipal;
                            if (!(user == null))
                            {
                                memUser = GetMembershipUser(user);
                            }

                        }
                    }
                }
            }));
            return memUser;
        }
        catch (Exception ex)
        {
            return null;
        }
    }

    public override string GetUserNameByEmail(string email)
    {
        try
        {
            string username = string.Empty;
            SPSecurity.RunWithElevatedPrivileges(new SPSecurity.CodeToRunElevated(delegate()
            {
                using (PrincipalContext context = new PrincipalContext(ContextType.Domain))
                {
                    using (UserPrincipal userFilter = new UserPrincipal(context))
                    {
                        userFilter.EmailAddress = email;
                        using (PrincipalSearcher search = new PrincipalSearcher(userFilter))
                        {
                            UserPrincipal user = search.FindOne() as UserPrincipal;
                            if (!(user == null))
                            {
                                username = user.SamAccountName;
                            }
                        }
                    }
                }
            }));
            return username;
        }
        catch (Exception ex)
        {
            return string.Empty;
        }
    }

    public override bool ValidateUser(string username, string password)
    {
        bool isValid = false;
        try
        {
            SPSecurity.RunWithElevatedPrivileges(new SPSecurity.CodeToRunElevated(delegate()
            {
                using (PrincipalContext context = new PrincipalContext(ContextType.Domain))
                {
                    isValid = context.ValidateCredentials(username, password);
                }
            }));
            
        }
        catch (Exception ex)
        {
            isValid = false;
        }
        return isValid;
    }

    private MembershipUser GetMembershipUser(UserPrincipal user)
    {
        return new MembershipUser("<membership provider name in web.config>";,
            user.SamAccountName, user.DistinguishedName,
            user.EmailAddress, string.Empty,
            string.Empty, user.Enabled.Value, !user.Enabled.Value,
            DateTime.MinValue, DateTime.Now, DateTime.Now,
            DateTime.MinValue, DateTime.MinValue);
    }

    #region Not Implemented Methods
    public override bool ChangePassword(string username, string oldPassword, string newPassword)
    {
        throw new NotImplementedException();
    }

    public override bool ChangePasswordQuestionAndAnswer(string username, string password, string newPasswordQuestion, string newPasswordAnswer)
    {
        throw new NotImplementedException();
    }

    public override System.Web.Security.MembershipUser CreateUser(string username, string password, string email, string passwordQuestion,
                                                                  string passwordAnswer, bool isApproved, object providerUserKey,
                                                                  out System.Web.Security.MembershipCreateStatus status)
    {
        throw new NotImplementedException();
    }

    public override bool DeleteUser(string username, bool deleteAllRelatedData)
    {
        throw new NotImplementedException();
    }

    public override bool EnablePasswordReset
    {
        get { throw new NotImplementedException(); }
    }

    public override bool EnablePasswordRetrieval
    {
        get { throw new NotImplementedException(); }
    }

    public override int GetNumberOfUsersOnline()
    {
        throw new NotImplementedException();
    }

    public override string GetPassword(string username, string answer)
    {
        throw new NotImplementedException();
    }

    public override MembershipUser GetUser(object providerUserKey, bool userIsOnline)
    {
        throw new NotImplementedException();
    }

    public override int MaxInvalidPasswordAttempts
    {
        get { throw new NotImplementedException(); }
    }

    public override int MinRequiredNonAlphanumericCharacters
    {
        get { throw new NotImplementedException(); }
    }

    public override int MinRequiredPasswordLength
    {
        get { throw new NotImplementedException(); }
    }

    public override int PasswordAttemptWindow
    {
        get { throw new NotImplementedException(); }
    }

    public override System.Web.Security.MembershipPasswordFormat PasswordFormat
    {
        get { throw new NotImplementedException(); }
    }

    public override string PasswordStrengthRegularExpression
    {
        get { throw new NotImplementedException(); }
    }

    public override bool RequiresQuestionAndAnswer
    {
        get { throw new NotImplementedException(); }
    }

    public override bool RequiresUniqueEmail
    {
        get { throw new NotImplementedException(); }
    }

    public override string ResetPassword(string username, string answer)
    {
        throw new NotImplementedException();
    }

    public override bool UnlockUser(string userName)
    {
        throw new NotImplementedException();
    }

    public override void UpdateUser(System.Web.Security.MembershipUser user)
    {
        throw new NotImplementedException();
    }
    #endregion
}

The LdapMembershipProvider makes the assumption that the application pool identity the Sharepoint site is running under has access to read Active Directory in order to check credentials against AD.

This is all well and dandy, but how about roles?  Authentication goes hand-in-hand with permissions, and permissions cannot be determined without knowing more information about the user than the membership providers supply to us.  I also needed a role provider.  The role provider follows the same logic flow as the membership provider in that it attempts to get a user’s roles from Sql first, and if that fails then get the user’s roles (aka groups) from Active Directory.

public class FBARoleProvider : SqlRoleProvider
{
    public override string ApplicationName
    {
        get { return "<application name>"; }
    }

    private LdapRoleProvider _ldapRoleProvider = null;
    private LdapRoleProvider LdapRoleProvider
    {
        get
        {
            if (_ldapRoleProvider == null)
                _ldapRoleProvider = new LdapRoleProvider();
            return _ldapRoleProvider;
        }
    }

    public override string[] GetRolesForUser(string username)
    {
        List roles = new List();
        roles.AddRange(base.GetRolesForUser(username));
        roles.AddRange(LdapRoleProvider.GetRolesForUser(username));
        return roles.ToArray();
    }

    public override bool RoleExists(string roleName)
    {
        if (base.RoleExists(roleName))
            return true;
        else
            return LdapRoleProvider.RoleExists(roleName);
    }

    public override string[] GetUsersInRole(string roleName)
    {
        if (base.GetUsersInRole(roleName).Length > 0)
            return base.GetUsersInRole(roleName);
        else
            return LdapRoleProvider.GetUsersInRole(roleName);   
    }
}

Simple enough.  The LdapRoleProvider is where the interesting stuff happens.

public class LdapRoleProvider : RoleProvider
{
    private string _applicationName = string.Empty;
    public override string ApplicationName
    {
        get
        {
            if (_applicationName == string.Empty)
            {
                _applicationName = "<application name>";
            }
            return _applicationName;
        }
        set { this._applicationName = "<application name>"; }
    }

    public override string[] GetRolesForUser(string username)
    {
        try
        {
            List userGroups = new List();
            SPSecurity.RunWithElevatedPrivileges(new SPSecurity.CodeToRunElevated(delegate()
            {
                using (PrincipalContext context = new PrincipalContext(ContextType.Domain))
                {
                    using (UserPrincipal user = UserPrincipal.FindByIdentity(context, username))
                    {
                        if (!(user == null))
                        {
                            //PrincipalSearchResult results = user.GetAuthorizationGroups();
                            //foreach (GroupPrincipal group in results)
                            //{
                            //    userGroups.Add(group.SamAccountName);
                            //}
                            var sroles = user.GetAuthorizationGroups();
                            if (sroles != null && sroles.Count() > 0)
                            {
                                int i = 0;
                                while (i < sroles.Count())
                                {
                                    try
                                    {
                                        var role = sroles.ElementAt(i);
                                        if (role != null && role.SamAccountName != null)
                                            userGroups.Add(role.SamAccountName);
                                    }
                                    catch (Exception ex)
                                    {
                                        string message = ex.Message;
                                    }
                                    i++;
                                }
                            }
                        }
                    }
                }
            }));
            return userGroups.ToArray();
        }
        catch (Exception ex)
        {
            return null;
        }
    }

    public override string[] GetUsersInRole(string roleName)
    {
        try
        {
            List groupMembers = new List();
            SPSecurity.RunWithElevatedPrivileges(new SPSecurity.CodeToRunElevated(delegate()
            {
                using (PrincipalContext context = new PrincipalContext(ContextType.Domain))
                {
                    using (GroupPrincipal groupFilter = new GroupPrincipal(context))
                    {
                        groupFilter.SamAccountName = roleName;
                        foreach (GroupPrincipal group in groupFilter.GetMembers(true))
                        {
                            groupMembers.Add(group.SamAccountName);
                        }
                    }
                }
            }));
            return groupMembers.ToArray();
        }
        catch (Exception ex)
        {
            return null;
        }
    }

    public override bool RoleExists(string roleName)
    {
        try
        {
            bool result = false;
            SPSecurity.RunWithElevatedPrivileges(new SPSecurity.CodeToRunElevated(delegate()
            {
                using (PrincipalContext context = new PrincipalContext(ContextType.Domain))
                {
                    using (GroupPrincipal groupFilter = GroupPrincipal.FindByIdentity(context, IdentityType.SamAccountName, roleName))
                    {
                        if (groupFilter != null)
                        {
                            result = groupFilter.IsSecurityGroup.Value;
                        }
                    }
                }
            }));
            return result;
        }
        catch (Exception ex)
        {
            return false;
        }
    }

    #region Not Implemented Methods
    public override void AddUsersToRoles(string[] usernames, string[] roleNames)
    {
        throw new NotImplementedException();
    }

    public override void CreateRole(string roleName)
    {
        throw new NotImplementedException();
    }

    public override bool DeleteRole(string roleName, bool throwOnPopulatedRole)
    {
        throw new NotImplementedException();
    }

    public override string[] FindUsersInRole(string roleName, string usernameToMatch)
    {
        throw new NotImplementedException();
    }

    public override string[] GetAllRoles()
    {
        throw new NotImplementedException();
    }

    public override bool IsUserInRole(string username, string roleName)
    {
        throw new NotImplementedException();
    }

    public override void RemoveUsersFromRoles(string[] usernames, string[] roleNames)
    {
        throw new NotImplementedException();
    }

    #endregion
}

Notice the commented-out code.  There is a known bug with the GetAuthorizationGroups() method that drove me up the wall when I was implementing this.  Turns out, in some cases, users in Active Directory can have membership to groups that have been deleted!  If this happens, iterating through the authorization groups of a user will crash the provider, which ultimately means very unhappy users unable to log in to their site.  To get around this, the logic flow purists in me had to cringe and I had to implement a try-catch solution that would attempt to read the group and continue on if the read throws an exception.  Not very pretty, I know, but unfortunately necessary as you have no way of knowing if the group is one of those orphaned cases until you actually try to access the group from the collection.

Once this is all in place and built, the .dll can be deployed to the GAC and the Sharepoint site can be set up with this new custom provider.  I won’t cover setting up Sharepoint for forms-based authentication, but there are plenty of guides on the internet that even those the least versed in the ways of google-fu will have no trouble finding.  Once the Sharepoint site is set up for forms authentication and the .dll is resting comfortably in its new home in the GAC, it’s a very simple matter to get the site to use your new provider.  Two web.configs need to be modified: the web.config for the Sharepoint site itself, and the web.config for the SecurityTokenServiceApplication.  Assuming your Sharepoint isntallation isn’t heavily modified from the default installation, the web.config for the Sharepoint site will be located in the standard <drive>\inetpub\wwwroot\wss\VirtualDirectory\<port> directory, while the web.config for the SecurityTokenServiceApplication can be found at <Sharepoint Installation Path>\WebServices\SecurityToken, although I usually just browse to the directory by exploring it from IIS (where it is located under the SharePoint Web Services site).  For the Sharepoint site’s web.config, there will be a section that looks similar to the following:

<roleManager defaultProvider="c" enabled="true" cacheRolesInCookie="false">
  <providers>
    <add name="SqlLdapRoleProvider" type="System.Web.Security.SqlRoleProvider, System.Web,
            Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" applicationName="/"
            connectionStringName="AspNetMembershipDB" />
    <add name="c" type="Microsoft.SharePoint.Administration.Claims.SPClaimsAuthRoleProvider,
            Microsoft.SharePoint, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
  </providers>
</roleManager>
<membership defaultProvider="i">
  <providers>
    <add name="SqlLdapMembershipProvider" type="System.Web.Security.SqlMembershipProvider, System.Web,
            Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" applicationName="/"
            connectionStringName="AspNetMembershipDB" enablePasswordReset="true"
            enablePasswordRetrieval="true" passwordFormat="Encrypted" requiresQuestionAndAnswer="false"
            requiresUniqueEmail="false" />
    <add name="i" type="Microsoft.SharePoint.Administration.Claims.SPClaimsAuthMembershipProvider,
            Microsoft.SharePoint, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" />
  </providers>
</membership>

The simplest (and highly recommended) way to switch over to your custom provider is to simply replace the type of the role and membership providers that are currently set to SqlMembershipProvider and SqlRoleProvider respectively to point to your own .dll.  This way, if you ever need to back out your changes for any reason, you can simply set the types back to their original value.

Do the same thing to the corresponding providers in the SecurityTokenServiceApplication’s web.config.

That’s it!  Do an IIS reset, then attempt to log in to the Sharepoint site.  If you still have windows authentication as a login method set for the site, you’ll be prompted to choose your login method.  Choose forms authentication, but enter some windows credentials instead of forms credentials and log in.  Most likely you’ll be given an access denied error, even if the AD user has access to the Sharepoint site.  This is an important distinction that needs to be made about this custom membership provider solution: Sharepoint treats a windows user logging in as a forms user as a whole seperate user. As far as Sharepoint is concerned, the windows-user-as-a-fake-forms-user is a different account, meaning it will need its own set of permissions in the Sharepoint site.  You must keep in mind that this fake-forms-account does not actually exist.  Sharepoint is being tricked, in a way, to thinking that the windows credentials supplied were valid for a forms account and so treats the user logging in this way as a whole new user.  All the people-picker fields that are used in Sharepoint will from this point forward return at least two accounts when searching for an Active Directory user – the real AD account and the fake form account that this custom provider is tricking Sharepoint with.

Astute readers might notice the hard-coded application name and membership provider name in the code above as well as try-catch blocks that don’t do anything meaningful if an exception it thrown.  I’ve left it up to the reader to implement logging or other logic-flows for the try-catch blocks in addition to a dynamic way to get the application and provider names.

Happy authenticating!

One thought on “Dual Login Method for Sharepoint

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s