Friday, September 4, 2009

Custom SharePoint Membership/Role Provider

 

Update 09/27/2009:  I left this post here for reference but I discovered a couple of problems with this solution.  I have posted an update to this post that describes our final solution. Updated Custom Sharepoint Membership/Role Provider

The Problem

Wiring up a membership provider in SharePoint is fairly straight forward.  Especially if you have worked with .net membership providers in the past.  A quick Google search for SharePoint custom membership provider will return numerous examples on how to get this going.

We had a need to use an ASP.NET SQL Membership Provider to expose a secure SharePoint site externally to people who did not have an account in our Domain.  The problem was, people with domain accounts also needed to be able to access this sight externally and we didn’t want our users to have to remember two usernames and passwords.  Isn’t that thoughtful of us?  In an asp.net web application this would not be a big deal.  Define two membership providers in the web.config, set one as the default and if authentication failed on the default provider it would automatically try the other providers.  It doesn’t exactly work that way in SharePoint.  Apparently SharePoint is only aware of one membership provider.  We wired up a asp.net sql membership provider and we were able to make SharePoint authenticate to that, then we changed it to the built in SharePoint LDAP membership provider and we were able to authenticate against our Domain.  We just couldn’t get both to work simultaneously.

The Solution

After mucking around with config files for an entire day, it became very evident that to accomplish our goal, it would require a custom membership and role provider that would try to authenticate to the sql database and if that failed, hit the domain for authentication.  If they both fail, well, you’re not supposed to be here, go away.  As I mentioned earlier, the built in .net SQL membership provider worked well and the built in SharePoint LDAP membership provider worked well.  Our custom membership provider would inherit the SQLMemberShipProvider and SQLRoleProvider classes, so that would be our base class and our first attempt at authentication.  We would then programmatically instantiate an instance of the SharePoint LDAPMembershipProvider and LDAPRoleProvider if the first attempt at authentication failed and give that a try.  Below is the code for our custom providers.

Custom Membership Provider

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web.Security;
using Microsoft.SharePoint;
using System.DirectoryServices.AccountManagement;


namespace FBA.Extranet.MembershipProvider
{
    // Notice that our custom Forms Based Authentication Membership Provider inherits the .net SQLMembershipProvider.  This means that if we override no methods our provider will behave exactly as a sqlmembershipprovider.
    public class FBAMembershipProvider : SqlMembershipProvider
    {
        //In our case we overrode the ApplicationName property to always return the ID of the SiteCollection we were running in.  In SharePoint this will allow us to use this custom membership provider on multiple site collections if necessary and maintain all of the user information in one ASP.NET SQL Membership Database.
        public override string ApplicationName
        {
            get
            {
                return SPContext.Current.Site.ID.ToString();
            }
        }

        //This is where we programmatically instantiate the SharePoint LDAP Membership provider.
        private Microsoft.Office.Server.Security.LdapMembershipProvider _ldapProvider = null;
        private Microsoft.Office.Server.Security.LdapMembershipProvider ldapProvider
        {
            get
            {
                if (_ldapProvider == null)
                {
                    _ldapProvider = new Microsoft.Office.Server.Security.LdapMembershipProvider();
                    System.Collections.Specialized.NameValueCollection nvc = new System.Collections.Specialized.NameValueCollection();
                    nvc.Add("server", "domain.com");
                    nvc.Add("useSSL", "false");
                    nvc.Add("userDNAttribute", "distinguishedName");
                    nvc.Add("userNameAttribute", "sAMAccountName");
                    nvc.Add("userContainer", "DC=domain,DC=com");
                    nvc.Add("userObjectClass", "person");
                    nvc.Add("userFilter", "(|(ObjectCategory=group)(ObjectClass=person))");
                    nvc.Add("scope", "Subtree");
                    nvc.Add("otherRequiredUserAttributes", "sn,givenname,cn");
                    _ldapProvider.Initialize("LdapMemberProvider", nvc);
                }
                return _ldapProvider;
            }

        }

        //Now we override the necessary methods that SharePoint needs.  As you can see, each method attempts to perform its base functionality before trying to authenticate via the LDAP membership provider.
        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 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 if (ldapProvider.FindUsersByEmail(emailToMatch, pageIndex, pageSize, out totalRecords).Count > 0)
                return ldapProvider.FindUsersByEmail(emailToMatch, pageIndex, pageSize, out totalRecords);
            else
                return new MembershipUserCollection();
        }

        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 if (ldapProvider.FindUsersByName(usernameToMatch, pageIndex, pageSize, out totalRecords).Count > 0)
                return ldapProvider.FindUsersByName(usernameToMatch, pageIndex, pageSize, out totalRecords);
            else
                return new MembershipUserCollection();    
        }

        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 if (ldapProvider.GetAllUsers(pageIndex, pageSize, out totalRecords).Count > 0)
                return ldapProvider.GetAllUsers(pageIndex, pageSize, out totalRecords);
            else
                return new MembershipUserCollection();    
        }

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

Custom Role Provider

The role provider is implemented exactly as the membership provider was.

using System;
using System.Collections.Generic;
using System.DirectoryServices.AccountManagement;
using System.Linq;
using System.Text;
using System.Web.Security;
using Microsoft.SharePoint;

namespace FBA.Extranet.MembershipProvider
{
    public class FBARoleProvider : SqlRoleProvider
    {
        public override string ApplicationName
        {
            get
            {
                return SPContext.Current.Site.ID.ToString();
            }
        }

        private Microsoft.Office.Server.Security.LdapRoleProvider _ldapProvider = null;
        private Microsoft.Office.Server.Security.LdapRoleProvider ldapProvider
        {
            get
            {
                if (_ldapProvider == null)
                {
                    _ldapProvider = new Microsoft.Office.Server.Security.LdapRoleProvider();
                    System.Collections.Specialized.NameValueCollection nvc = new System.Collections.Specialized.NameValueCollection();
                    nvc.Add("server", "domain.com");
                    nvc.Add("useSSL", "false");
                    nvc.Add("groupContainer", "DC=domain,DC=com");
                    nvc.Add("groupNameAttribute", "cn");
                    nvc.Add("groupMemberAttribute", "member");
                    nvc.Add("userNameAttribute", "sAMAccountName");
                    nvc.Add("dnAttribute", "distinguishedName");
                    nvc.Add("groupFilter", "(ObjectClass=group)");
                    nvc.Add("scope", "Subtree");
                    _ldapProvider.Initialize("LdapRoleProvider", nvc);
                }
                return _ldapProvider;
            }

        }

        public override string[] GetRolesForUser(string username)
        {
            List<string> test = new List<string>();
            test.AddRange(base.GetRolesForUser(username));
            test.AddRange(ldapProvider.GetRolesForUser(username));
            return test.ToArray();
        }

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

        }
    }
}

Implementation

Now that we have the code for the role provider it is a simple matter of deploying the .dll to the GAC and referencing the membership and role provider in the web.config.  You also need to do all the necessary configurations in SharePoint Central Admin, but as I said, that information is readily available via google.  The entries for the web.config are as follows:

Connection String
<connectionStrings>
  <add name="FBAMembershipConnectionString" connectionString="Data Source=.\OfficeServers;Initial Catalog=FBASQLMembership; Integrated Security=True" />
</connectionStrings>
Providers
<!-- membership provider -->
<membership defaultProvider="FBAMemberProvider">
  <providers>
    <add name="FBAMemberProvider" connectionStringName="FBAMembershipConnectionString" 
         enablePasswordRetrieval="false" enablePasswordReset="true" requiresQuestionAndAnswer="false" requiresUniqueEmail="false" passwordFormat="Hashed" maxInvalidPasswordAttempts="5" minRequiredPasswordLength="1" minRequiredNonalphanumericCharacters="0" passwordAttemptWindow="10" passwordStrengthRegularExpression="" type="FBA.Extranet.MembershipProvider.FBAMembershipProvider,FBA.Extranet.MembershipProvider, Version=1.0.0.0, Culture=neutral, PublicKeyToken=<token>" />
  </providers>
</membership>
<!-- role provider -->
<roleManager enabled="true" defaultProvider="FBARoleProvider">
  <providers>
    <add connectionStringName="FBAMembershipConnectionString" name="FBARoleProvider" type="FBA.Extranet.MembershipProvider.FBARoleProvider,FBA.Extranet.MembershipProvider, Version=1.0.0.0, Culture=neutral, PublicKeyToken=<token>" />
  </providers>
</roleManager>

Conclusion

I hope this post helps someone avoid some frustration and I look forward to your comments.

6 comments:

  1. Sheezus, this totally is what I needed code and all. I have been freaking out on the weekend here trying to figure out 1 why My SQL authentication that happens in my global wont allow me access to CA, second why the role over to the Domain Auth was totally and uterlly failing. Was really frustrating as i certainly have done it before in a standard asp.NET app.


    Your the.. man/woman

    ReplyDelete
  2. Mike,

    I am getting a reference error as follows
    Error 102 The type or namespace name 'Office' does not exist in the namespace 'Microsoft' (are you missing an assembly reference?)

    I do not see a Microsoft.Office.Security assembly to pull in from the "Add Reference" dialog in VS. If the dll exists on my machine somewhere and i just need to fish for it so be it but where is it?

    inet search is turning up dry. MOSS on on the dev machines so I am at a loss as to what i need to do here. I just need to know what assembly to ref in the project.

    ReplyDelete
  3. ...and for my 3rd post.

    So Yes i found the Office components in common.
    Thanks again.

    Now I will have to figure out how to get this to work on WSS as well.

    ReplyDelete
  4. Jo. Give me a few minutes to post an update. I had to change the way I was doing this slightly. I couldn't get some of the methods in the SharePoint LDAP membership provider to work so I wrote my own membership provider for AD Authentication.

    ReplyDelete
  5. Hi,
    I have problem with Server.Security.LdapMembershipProvider; it could't find the assembly reference despite adding any I can think of!
    I've tried using office.common as suggested above but no luck!
    Do you have any ideas? Or even a solution file with the references included if you used VS?
    Thanks for the code!

    ReplyDelete
  6. Jurijus,

    The Server.Security.LdapMembershipProvider namespace is in C:\Program Files\Common Files\Microsoft Shared\web server extensions\12\ISAPI\Microsoft.Office.Server.dll. However, I have since posted another article regarding this topic. I was having difficulty getting some of the methods in the LdapMembershipProvider to work when I manually instantiated the provider. As a result I wrote my own LDAP Provider and called that instead of the built in SharePoint provider. Please see the article found here: http://www.geekoncode.com/2009/09/updated-custom-sharepoint.html.

    Hope that helps.

    ReplyDelete