Sunday, September 27, 2009

Updated Custom SharePoint Membership/Role Provider

 

Note:  This is an update to my previous post describing a custom SharePoint Membership/Role Provider.  I have left the other post intact in the event you need to see what I did that didn’t work.  This solution seems to work better.

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 on it’s own.  However, when I tried to programmatically instantiate the SharePoint LDAP Provider, there were a couple of methods that I could not get to work.  Apparently this class wasn’t meant to used in another provider.  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 a custom developed 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;
using System.Collections.Specialized;


namespace FBA.Extranet.MembershipProvider
{
    public class FBAMembershipProvider : SqlMembershipProvider
    {

        public override string ApplicationName
        {
            get
            {
                return SPContext.Current.Site.ID.ToString();
            }
        }

        private FBALDAPMembershipProvider _fbaLDAPProvider = null;
        private FBALDAPMembershipProvider FBALDAPProvider
        {
            get
            {
                if (_fbaLDAPProvider == null)
                    _fbaLDAPProvider = new FBALDAPMembershipProvider();
                return _fbaLDAPProvider;
            }
        }

        public override bool ValidateUser(string username, string password)
        {
            if (base.ValidateUser(username, password))
                return true;
            else if (FBALDAPProvider.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 FBALDAPProvider.GetUser(username, userIsOnline);
        }

        public override MembershipUser GetUser(object providerUserKey, bool userIsOnline)
        {
            if (base.GetUser(providerUserKey, userIsOnline) != null)
                return base.GetUser(providerUserKey, userIsOnline);
            else
                return FBALDAPProvider.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 FBALDAPProvider.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 FBALDAPProvider.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 FBALDAPProvider.GetAllUsers(pageIndex, pageSize, out totalRecords);

        }

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

        }

    }
}

Custom Role Provider

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 FBALDAPRoleProvider _fbaLDAPRoleProvider = null;
        private FBALDAPRoleProvider FBALDAPRoleProvider
        {
            get
            {
                if (_fbaLDAPRoleProvider == null)
                    _fbaLDAPRoleProvider = new FBALDAPRoleProvider();
                return _fbaLDAPRoleProvider;
            }
        }

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

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

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

Custom LDAP Membership Provider

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

namespace FBA.Extranet.MembershipProvider
{
    public class FBALDAPMembershipProvider : System.Web.Security.MembershipProvider
    {

        private string _applicationName = string.Empty;
        public override string ApplicationName
        {
            get
            {
                if (_applicationName == string.Empty)
                {
                    _applicationName = SPContext.Current.Site.ID.ToString();
                }
                return _applicationName;
            }
            set
            {
                this._applicationName = SPContext.Current.Site.ID.ToString();
            }
        }

        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<Principal> 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)
            {
                //TODO: Log Something
                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<Principal> 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)
            {
                //TODO: Log Something.
                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<Principal> 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)
            {
                //Log something.
                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)
            {
                //Log something.
                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)
            {
                //log something.
                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)
            {
                //log something.
                isValid = false;
            }
            return isValid;
        }

        private MembershipUser GetMembershipUser(UserPrincipal user)
        {
            return new MembershipUser("FBAMemberProvider",
                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
    }
}

Custom LDAP Role Provider

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

namespace FBA.Extranet.MembershipProvider
{
    public class FBALDAPRoleProvider : RoleProvider
    {

        private string _applicationName = string.Empty;
        public override string ApplicationName
        {
            get
            {
                if (_applicationName == string.Empty)
                {
                    _applicationName = SPContext.Current.Site.ID.ToString();
                }
                return _applicationName;
            }
            set
            {
                this._applicationName = SPContext.Current.Site.ID.ToString();
            }
        }

        public override string[] GetRolesForUser(string username)
        {
            try
            {
                List<string> userGroups = new List<string>();
                SPSecurity.RunWithElevatedPrivileges(new SPSecurity.CodeToRunElevated(delegate()
                {
                    using (PrincipalContext context = new PrincipalContext(ContextType.Domain))
                    {
                        using (UserPrincipal user = UserPrincipal.FindByIdentity(context, username))
                        {
                            if (!(user == null))
                            {
                                PrincipalSearchResult<Principal> results = user.GetAuthorizationGroups();
                                foreach (GroupPrincipal group in results)
                                {
                                    userGroups.Add(group.SamAccountName);
                                }
                            }
                        }
                    }
                }));
                return userGroups.ToArray();
            }
            catch (Exception ex)
            {
                //Log Something
                return null;
            }
        }

        public override string[] GetUsersInRole(string roleName)
        {
            try
            {
                List<string> groupMembers = new List<string>();
                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)
            {
                //Log something.
                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)
            {
                //Log something.
                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
    }
}

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.

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.

Saturday, August 1, 2009

Using the calendar control on a public facing SharePoint Publishing Site

 

The Problem

If you have ever put a SharePoint publishing site on the internet, you know there can be numerous challenges.  One challenge we recently encountered revolved around exposing a SharePoint calendar to the public.  The calendar would show up just fine but when you would hit the site anonymously and click on one of the calendar event, you would receive a login prompt.  We had written a custom calendar list definition, with a custom content type and a custom display form.  Turns out the issue was because we had enabled the ViewFormPagesLockDown feature to prevent anonymous users from browsing to the SharePoint back end (See this post for more information http://technet.microsoft.com/en-us/library/cc263468.aspx).  Enabling the lock down feature disables access to view Application Pages which is what the dispform.aspx file for a calendar definition is.   After much Googling, most of the posts we could find recommended disabling the ViewFormPagesLockDown feature but that was not a viable option for us.  We attempted to change the display form for the calendar using SharePoint Designer but it turns out it was still posting to the dispform.aspx application page and then redirecting to the value we set using designer.  This of course still did not resolve the issue because it still required hitting an application page. 

The Solution

Our solution turned out to be two web parts, one to present the calendar and one to display the detail of the calendar event.

Presenting the calendar

We were actually quite pleased with the presentation of the calendar and only wanted to change the target of the link created for each calendar event to point to a publishing page rather than an application page.  I also did not want to battle how to expand all of the recurring items.  Turns out using the SharePoint object model there is a SPCalendarView object to accomplish the same thing that is accomplished by adding a calendar to a publishing page like a web part.  Essentially, when you do this, you are just putting a calendar view on the page.  So our solution for this piece was to write a web part that would instantiate a SPCalendarView object and build a SPCalendarItemCollection object that binds to the view.  By building the collection of calendar items we are able to manually set the display form url of each calendar item. 

To avoid the problem of manually expanding recurring items, SharePoint offers an object to accomplish this.  The secret is to use a SPQuery object with the query set to the CAML shown in the following code snippet.  In addition, you set the ExpandRecurrence property to true.  This automatically expands the recurrence in to all the appropriate individual events for you to add to your calendar item collection.

using System;
using System.Runtime.InteropServices;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Xml.Serialization;

using Microsoft.SharePoint;
using Microsoft.SharePoint.WebControls;
using Microsoft.SharePoint.WebPartPages;
using System.ComponentModel;
using System.Xml;
using System.Xml.Xsl;
using System.Net;
using System.Xml.XPath;
using System.Text;
using System.IO;
using System.Web;

namespace CalendarPresentation
{

    [Guid("b6e3affc-9d86-41ce-b323-cd74164947f3")]
    public class CalendarPresentation : System.Web.UI.WebControls.WebParts.WebPart
    {

        [Personalizable(PersonalizationScope.Shared),
        WebBrowsable(true),
        WebDisplayName("Calendar URL"),
        WebDescription("Provide the url for the target calendar. This is only needed if the calendar is in another site."),
        Category("Calendar Presentation Settings")]
        public string calendarURL { get; set; }

        [Personalizable(PersonalizationScope.Shared),
        WebBrowsable(true),
        WebDisplayName("Calendar Name"),
        WebDescription("Provide Name of the target calendar"),
        Category("Calendar Presentation Settings")]
        public string calendarName { get; set; }

        [Personalizable(PersonalizationScope.Shared),
        WebBrowsable(true),
        WebDisplayName("Display Form URL"),
        WebDescription("Provide Name URL to the display form."),
        Category("Calendar Presentation Settings")]
        public string dispFormURL { get; set; }

        XmlDocument doc = new XmlDocument();
        Literal lit = new Literal();
        SPCalendarItemCollection calItemCollection = new SPCalendarItemCollection();
        SPListItemCollection calBase = null;

        public CalendarPresentation()
        {
        }

        protected override void OnInit(EventArgs e)
        {
            base.OnInit(e);

            if (!String.IsNullOrEmpty(calendarName) && !String.IsNullOrEmpty(dispFormURL))
            {
                try
                {
                    //We need to run with elevated permissions if the 
                    //document library containing
                    //the transform does not have anonmymous read access.


                    /*  The following block sets up the SharePoint Site and Web depending on
                     * a calendarURL was specified or not.  If no calendar URL was specified
                     * the current SPContext site and web are used.  If a calendar URL was specified
                     * that URL is used to determine the site and web.  This allows the web part
                     * to present a calendar across sites.
                     */
                    SPSite siteColl = null;
                    SPWeb site = null;
                    if (string.IsNullOrEmpty(calendarURL))
                    {
                        siteColl = SPContext.Current.Site;
                        site = SPContext.Current.Web;
                    }
                    else
                    {
                        siteColl = new SPSite(calendarURL);
                        site = siteColl.OpenWeb();
                    }

                    /*Now that we have our site and web we need to instantiate a new instance
                     * with elevated permissions.  This is what allows calendars to be accessed
                     * from a different site than where they reside.
                     */
                    SPSecurity.RunWithElevatedPrivileges(new SPSecurity.CodeToRunElevated(delegate()
                    {
                        using (SPSite ElevatedsiteColl = new SPSite(siteColl.ID))
                        {
                            using (SPWeb ElevatedSite = ElevatedsiteColl.OpenWeb(site.ID))
                            {
                                /* Now that we have our context, we need to query the calendar list
                                 * and get the events.  This is where we create a SPQuery object that expands
                                 * all of the recurrences.  You will also see where we set a default CalendarDate
                                 * and update it if there is a CalendarDate specified in the querystring.
                                 * This is to accommodate the existing calendar view functionality that allows
                                 * you to changes months/days etc.
                                 */
                                SPQuery query = new SPQuery();
                                query.Query = "<Where><DateRangesOverlap><FieldRef Name=\"EventDate\" /><FieldRef Name=\"EndDate\" /><FieldRef Name=\"RecurrenceID\" /><Value Type=\"DateTime\"><Month /></Value></DateRangesOverlap></Where>";
                                query.ExpandRecurrence = true;
                                DateTime calDate = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1);
                                DateTime.TryParse(HttpContext.Current.Request.QueryString["CalendarDate"], out calDate);
                                query.CalendarDate = calDate;
                               
                                /*The following line actually queries the calendar with the SPquery object
                                 * created above.  This returns a SPListItemCollection.
                                 */
                                calBase = ElevatedSite.Lists[calendarName].GetItems(query);
                               
                                /*Now we are going to iterate the SPListItemCollection and create a 
                                 * SPCalendarItemCollection.
                                 */
                                foreach (SPListItem item in calBase)
                                {
                                    //Instantiage a calendar item for each iteration.
                                    SPCalendarItem calItem = new SPCalendarItem();
                                    
                                    /* Some of the information required to create a calendar item are found
                                     * in the SPListItem XML property.  We need to get this information into
                                     * a XPathNavigator so we can easily retrieve it.
                                     */
                                    XmlDocument doc = new XmlDocument();
                                    doc.LoadXml(item.Xml);
                                    XPathNavigator xDoc = doc.CreateNavigator();
                                    xDoc.MoveToRoot();
                                    xDoc.MoveToFirstChild();

                                    //Start Building the calendar item.
                                    calItem.ItemID = item.ID.ToString();
                                    // Here is an example of how to get the info out of the XPathNavigator.
                                    calItem.Title = xDoc.GetAttribute("ows_Title", "");  
                                    calItem.Location = xDoc.GetAttribute("ows_Location", "");

                                    DateTime myStartDate = DateTime.MinValue;
                                    DateTime.TryParse(xDoc.GetAttribute("ows_EventDate", ""), out myStartDate);
                                    calItem.StartDate = myStartDate;

                                    DateTime myEndDate = DateTime.MinValue;
                                    DateTime.TryParse(xDoc.GetAttribute("ows_EndDate", ""), out myEndDate);
                                    calItem.EndDate = myEndDate;

                                    if (calItem.EndDate != DateTime.MinValue)
                                        calItem.hasEndDate = true;
                                    else
                                        calItem.hasEndDate = false;

                                    int myIsAllDayItem = 0;
                                    int.TryParse(xDoc.GetAttribute("ows_fAllDayEvent", ""), out myIsAllDayItem);
                                    calItem.IsAllDayEvent = Convert.ToBoolean(myIsAllDayItem);

                                    int myIsRecurrence = 0;
                                    int.TryParse(xDoc.GetAttribute("ows_fRecurrence", ""), out myIsRecurrence);
                                    calItem.IsRecurrence = Convert.ToBoolean(myIsRecurrence);

                                    /* The following line is the ENTIRE reason for jumping through all these hoops.
                                     * We are assigning the displayformurl which is where the link created for each
                                     * calendar event will be targeted.  The dispFormURL variable is defined at the 
                                     * time the web part is added to the page, allowing you to point a calendar view
                                     * to any presentation page you want.  In addition we are putting the ListID and 
                                     * the item id (ID) in the querystring.  This is for the detail display web part 
                                     * to use to query the appropriate list and find the appropriate item.  One very
                                     * important not is the '&' included at the end of the query string we built.  This
                                     * is a bit of a hack because the SharePoint calendar view automatically appends the 
                                     * item ID and the source to the querystring using javascript.  By putting the '&' at
                                     * the end of the string, all of our query string values will be returned as we expect
                                     * when we pull them using the detail display form.  I know this sounds wonky but
                                     * you can try it with and without and see what I mean.
                                     */
                                    calItem.DisplayFormUrl = String.Format("{0}?ListID={1}&ID={2}&", dispFormURL, item.ParentList.ID, item.ID);

                                    //Add the created calendar item to the collection and do it again.
                                    calItemCollection.Add(calItem);
                                    
                                }
                            }
                        }
                    }));

                }
                catch (Exception ex)
                {
                    //TODO: Something went wrong, log something for troubleshooting purposes.
                }
            }
        }

        protected override void CreateChildControls()
        {
            base.CreateChildControls();

            //This is where we instantiate the calendar view.
            SPCalendarView calview = new SPCalendarView();
            calview.ListName = calendarName;

            /* This line determines what 'view' the calendar is displayed as.  We want
             * the month view to be the default.  If the user clicks on 'day' for example
             * the CalendarPeriod value is added to the query string and we use that instead.
             */
            calview.ViewType = "month";
            if (!String.IsNullOrEmpty(HttpContext.Current.Request.QueryString["CalendarPeriod"]))
            {
                calview.ViewType = HttpContext.Current.Request.QueryString["CalendarPeriod"];
            }
            
            //Set the calendar view datasource to the collection we just created.
            calview.DataSource = calItemCollection;
            //Bind it.
            calview.DataBind();

            /*Add the view to the page, unless we haven't configured the web part correctly.
            * If it hasn't been configured properly then we add a message to remind the content 
            * editor to do so.
            */
            if (!String.IsNullOrEmpty(calendarName))
            {
                base.Controls.Add(calview);
            }
            else
            {
                lit.Text = "The Calendar Presentation web part is not properly configured.";
                base.Controls.Add(lit);
            }
        }

    }
}

Showing item detail

To display the detail of an item when we clicked on it required another web part.  This web part was considerably simpler because all it had to do was query the list indicated in the query string for the item indicated in the query string and display the value of the description field.  I might add that the description field was a RichHTMLPublishing field that we added to our custom calendar content type.  We disabled the default description field for our custom calendar.

using System;
using System.Runtime.InteropServices;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Xml.Serialization;

using Microsoft.SharePoint;
using Microsoft.SharePoint.WebControls;
using Microsoft.SharePoint.WebPartPages;
using System.Web;
using System.ComponentModel;

namespace CalendarPresentation
{
    [Guid("5297e36f-0de9-417a-892a-e970b68b2d40")]
    public class CalendarDisplayForm : System.Web.UI.WebControls.WebParts.WebPart
    {

        [Personalizable(PersonalizationScope.Shared),
        WebBrowsable(true),
        WebDisplayName("Item Not Found Text"),
        WebDescription("Provide text to be displayed when item is not found."),
        Category("Calendar Detail Presentation Settings")]
        public string itemNotFoundText { get; set; }

        public CalendarDisplayForm()
        {
        }

        protected override void CreateChildControls()
        {
            base.CreateChildControls();

            try
            {
                /* Get the values from the query string that we built using the Calendar
                 * Presentation web part.
                 */
                string listID = HttpContext.Current.Request.QueryString["ListID"];
                string returnURL = HttpContext.Current.Request.QueryString["Source"];
                string itemID = HttpContext.Current.Request.QueryString["ID"];
                
                Literal lit = new Literal();

                // Get the site and web using the url passed by the presentation web part.
                SPSite siteColl = new SPSite(returnURL);
                SPWeb site = siteColl.OpenWeb();

                /* Create a new instance of the site and web using elevated permissions in the 
                 * event we are accessing a calendar on another site.
                 */
                SPSecurity.RunWithElevatedPrivileges(new SPSecurity.CodeToRunElevated(delegate()
                {
                    using (SPSite ElevatedsiteColl = new SPSite(siteColl.ID))
                    {
                        using (SPWeb ElevatedSite = ElevatedsiteColl.OpenWeb(site.ID))
                        {
                            /*Get the list item from the calendar using the listID and itemID passed by the
                             * presentation web part.
                             */
                            SPListItem listItem = ElevatedSite.Lists.GetList(new Guid(listID), false).GetItemById(Convert.ToInt32(itemID));
                            //Get the value of the description field.
                            lit.Text = getFieldValue(listItem, "Description");
                        }
                    }
                }));

                //Add the value of the description field to the page if it is not null.
                if (String.IsNullOrEmpty(lit.Text))
                {
                    lit.Text = itemNotFoundText;
                }

                /* This section adds a button to the page that has a post backurl of the return url
                 * passed by the presentation form.  This allows the user to get back to the calendar.
                 */
                base.Controls.Add(lit);
                base.Controls.Add(new LiteralControl("<br/>"));
                Button btnReturn = new Button();
                btnReturn.PostBackUrl = returnURL;
                btnReturn.Text = "Return to Calendar";
                base.Controls.Add(btnReturn);
            }
            catch (Exception ex)
            {
                
            }            
        }

        // This helper method gets the value from any list item field.
        public static string getFieldValue(SPListItem listItem, string fieldName)
        {
            string text = string.Empty;
            if (fieldName == string.Empty)
            {
                return text;
            }
            try
            {
                object myObj = listItem[fieldName];
                return ((myObj != null) ? myObj.ToString() : string.Empty);
            }
            catch
            {
                return string.Empty;
            }
        } 
    }
}

Conclusion

I hope this post can help point some of you in the right direction for your public facing SharePoint publishing sites.  Even though it is a long way around a problem, I think it ultimately results in a more secure site because it allows you to keep the lock down feature activated and still use the calendar control.  I look forward to your comments.

Monday, March 2, 2009

Handle Network Availability Changes.

I have recently been working on an application that has multiple instances of a WPF application that interfaces with a database. Each of the applications relies on SQLDependency notifications to stay in sync when a change is made by one. The database notifies all applications that are subscribed to notifications when one of the applications makes an update.

This is all well and good, until the test team got ahold of the application. They always have a knack for thinking of things that I have not handled, guess that is their job. This particular event was a very simple and distinctly possible occurrence, they unplugged the network cable. Doing this threw the message notification into all sorts of disarray, and the application would only recover after it was shut down and restarted. Bad programming on my part. Back to the drawing board.

Turns out the .NET framework has a method for handling just this sort of event. The NetworkChange class contains two events that can easily be subscribed to and handled; NetworkAvailabilityChange and NetworkAddressChange.

So, we start by creating an eventhandler when we load out application. It looks something like this.

NetworkChange.NetworkAvailabilityChanged += new NetworkAvailabilityChangedEventHandler (NetworkChange_NetworkAvailabilityChanged);

After subscribing to the event we of course have to define what happens when the event fires. My method looks like the following.

void NetworkChange_NetworkAvailabilityChanged(object sender, NetworkAvailabilityEventArgs e)
        {
            if (e.IsAvailable)
            {
                try
                {
                    // This event will occur on a thread pool thread.
                    // Updating the UI from a worker thread is not permitted.
                    // The following code checks to see if it is safe to
                    // update the UI.
                    DispatcherObject i = (DispatcherObject)this;

                    // If CheckAccess returns True, the code
                    // is executing on a worker thread.
                    if (!this.Dispatcher.CheckAccess())
                    {
                        // Create a delegate to perform the thread switch.
                        NetworkAvailabilityChangedEventHandler tempDelegate =
                            new NetworkAvailabilityChangedEventHandler NetworkChange_NetworkAvailabilityChanged);

                        object[] args = { sender, e };

                        // Marshal the data from the worker thread
                        // to the UI thread.
                        i.Dispatcher.BeginInvoke(tempDelegate, args);

                        return;
                    }

                    //Drop and restart the SqlDependency Cache to recover from the network outage.  The
                    //subscription will be rebuilt after the data is refreshed in the GetData() method.
                    netDown = false;
                    SqlDependency.Stop(Utils.GetConnectionString("ConnectionString"));
                    SqlDependency.Start(Utils.GetConnectionString("ConnectionString"));

                    // Reload the data for the list view.
                    GetData();
                }
                catch (Exception ex)
                {
                    System.Diagnostics.EventLog.WriteEntry("Terminal", ex.Message);
                }
            }
            else
            {
                netDown = true;
            }
        }

The NetworkChange_NetworkAvailibiltyChanged event will occur on a worker thread. Updating the UI from a worker thread is not permitted. The code in this method checks to see if you have permission to update the ui and if not will create a delegate to perform the thread switch and update the UI. In this case my GetData() method updates the user interface so we have to call the NetworkAvailibilityChangedEventHandler via a delegate.