Creating a SharePoint Custom Claim Provider to Enhance the ...



Creating a SharePoint Custom Claim Provider to Enhance the SharePoint People PickerThis article describes how to build a custom claim provider that allows to the picking of active directory people and groups in a multi-domain environment. The source code for this is located here Before we are able to use this custom claim provider, we must modify a few values to suit a specific organization. Let’s look at these default values…internal const string ClaimProviderDisplayName = "Custom";internal const string ClaimProviderDescription = "Custom Claim Provider from Learn.";const string SPTrustedIdentityTokenIssuerName = "Custom Identity Source"; const string userLDAPUniqueIdentifier = "mail";const string usersClaim = "Users";const string groupsClaim = "Groups";const string forestDN = "DC=contoso,DC=com";const string forestName = "contoso";const string corporateDomainName = "contoso";My organization’s name is Catholic Health Initiatives. We have multiple domains inside of a single forest. Our forest distinguished name is DC=catholichealth,DC=net, and most of our users are stored inside of a domain called CHI. For us, the following configuration is used…internal const string ClaimProviderDisplayName = "CHI Directory";internal const string ClaimProviderDescription = "Claims provider that searches Users/Groups in CHI's Enterprise Active Directory";const string SPTrustedIdentityTokenIssuerName = "Catholic Health Federation Services";const string userLDAPUniqueIdentifier = "mail";const string usersClaim = "Users";const string groupsClaim = "Groups";const string forestDN = "DC=catholichealth,DC=net";const string forestName = "";const string corporateDomainName = "chi";The ClaimProviderDisplayName is the value shown in the popup window left tree view, as well as after search results in the people editor control to disambiguate results. Figure 3 is screenshots of the popup window and the people editor disambiguation.318135029527500Figure 3 – ClaimProviderName Appearing in the Popup Window and the People EditorThe ClaimProviderDescription appears in powershellFigure 4 – PowerShell Showing the Description ValueThe SPTrustedIdentityTokenIssuerName is used to build the full claim string. For example, my full claim string is i:05.t|catholic health federation services|(my work email address). For more information on the full claim string, check out other configuration values will be explained later.Now let’s look at the two claim typesconst string IdentityClaimType = "";const string GroupClaimType = "";It’s not uncommon to change the IdentityClaimType to match the claim coming from your token provider, but this email claim is the default identity claim for ADFS. It’s very uncommon to change the GroupClaimType. This codebase is written to only support two claims, users and groups. Extending the code to support other claims is beyond the scope of this article. For the remaining fields, the developer should not need to modify any values unless undergoing an advanced customization.const string StringTypeClaim = Microsoft.IdentityModel.Claims.ClaimValueTypes.String;public override string Name { get { return ClaimProviderDisplayName; } }public override bool SupportsEntityInformation { get { return false; } }public override bool SupportsHierarchy { get { return true; } }public override bool SupportsResolve { get { return true; } }public override bool SupportsSearch { get { return true; } }For the methods FillClaimTypes, FillClaimValueTypes and FillEntityTypes, there are two important things to note. The first is that we actually add the StringTypeClaim twice, once for email value and once for group SID value. The second is that we use the value SPClaimEntityTypes.SecurityGroup for the group claim. Different people editor controls support different kinds of SPClaimEntityTypes. The two most common are User and SecurityGroup.protected override void FillClaimTypes(List<string> claimTypes){ claimTypes.Add(IdentityClaimType); claimTypes.Add(GroupClaimType);}protected override void FillClaimValueTypes(List<string> claimValueTypes){ // EmailAddress claimValueTypes.Add(StringTypeClaim); // Group claimValueTypes.Add(StringTypeClaim);}protected override void FillEntityTypes(List<string> entityTypes){ // EmailAddress entityTypes.Add(SPClaimEntityTypes.User); // Group entityTypes.Add(SPClaimEntityTypes.FormsRole);}We do not perform any claims augmentation, so the method FillClaimsForEntity does nothingprotected override void FillClaimsForEntity(System.Uri context, SPClaim entity, List<SPClaim> claims){}The FillHierarchy method is called recursively to build the hierarchy on the left side of the details view. For our claim provider we only add users and groups as children to the root of the claim provider.protected override void FillHierarchy(System.Uri context, string[] entityTypes, string hierarchyNodeID, int numberOfLevels, SPProviderHierarchyTree hierarchy){ //inherited from MSDN code sample if (!EntityTypesContain(entityTypes, SPClaimEntityTypes.FormsRole)) return; switch (hierarchyNodeID) { //Add our users and groups nodes - we don't build a hierarchy but this is possible case null: hierarchy.AddChild(new SPProviderHierarchyNode(CustomClaimProvider.ClaimProviderDisplayName, usersClaim, usersClaim, true)); hierarchy.AddChild(new SPProviderHierarchyNode(CustomClaimProvider.ClaimProviderDisplayName, groupsClaim, groupsClaim, true)); break; default: break; }}Figure 9 – The Claim HierarchyNow we’ll examine the methods FillSearch and FillResolve. Both of these can really be combined into a single method FillSearchAndResolve, which will be explained later.protected override void FillSearch(System.Uri context, string[] entityTypes, string searchPattern, string hierarchyNodeID, int maxCount, SPProviderHierarchyTree searchTree){ //we actually tackle searching and resolving in one method... FillSearchAndResolve(searchPattern, searchTree, null, hierarchyNodeID, maxCount);}protected override void FillResolve(System.Uri context, string[] entityTypes, string resolveInput, List<PickerEntity> resolved){ //we actually tackle searching and resolving in one method... FillSearchAndResolve(resolveInput, null, resolved, null, 30);}The FillSchema method is used to add information to the detail and list view of the people picker. protected override void FillSchema(SPProviderSchema schema){ //Lets show the users title in the details view, just for giggles schema.AddSchemaElement(new SPSchemaElement(PeopleEditorEntityDataKeys.JobTitle, "Title", SPSchemaElementType.DetailViewOnly));}Figure 11 – Job Title Added to the Details ViewThere is a second FillResolve method from the base class that passes in a claim instead of a search string. This method runs whenever a user views a people editor control with existing values inside it. Each entry is passed through this method to make sure all entries are still valid. If the claim type coming in for validation is a user claim, we search all of AD for a single entry with objectclass user and a value equal to whatever we choose for userLDAPUniqueIdentifier, in this case mail. If the incoming claim type is a group, we search all of AD for a single entry with objectclass group and a value equal to the group SID.protected override void FillResolve(System.Uri context, string[] entityTypes, SPClaim resolveInput, List<PickerEntity> resolved){ try { //Dunno exactly why we do this, but MSFT written codeplex peoplepicker does it too... SPSecurity.RunWithElevatedPrivileges(delegate() { // null means all domains - we search all domains here because its fast and theres no hint on which domain to use // when resolving var directorySearcher = CreateDirectorySearcher(null); SearchResult result = null; switch (resolveInput.ClaimType) { //Open a people editor with an existing user case IdentityClaimType: directorySearcher.Filter = "(&(objectClass=user)(" + userLDAPUniqueIdentifier + "=" + resolveInput.Value + "))"; LoadDSProperties(directorySearcher); //we don't worry if there are multiple results - we assume totally unique values for email across domains result = directorySearcher.FindOne(); resolved.Add(CreatePickerEntityForUser(result)); break; //Open a people editor with an existing group case GroupClaimType: var sid = new SecurityIdentifier(resolveInput.Value); byte[] objSID = new byte[sid.BinaryLength]; sid.GetBinaryForm(objSID, 0); StringBuilder hexSID = new StringBuilder(); for (int i = 0; i < objSID.Length; i++) { hexSID.AppendFormat("\\{0:x2}", objSID[i]); } directorySearcher.Filter = "(&(objectClass=group)(objectsid=" + hexSID.ToString() + "))"; LoadDSProperties(directorySearcher); result = directorySearcher.FindOne(); resolved.Add(CreatePickerEntityForGroup(result)); break; default: break; } }); } catch (Exception ex) { SPDiagnosticsService.Local.WriteEvent( 0, new SPDiagnosticsCategory("CustomClaimProvider", TraceSeverity.High, EventSeverity.Error), EventSeverity.Error, "Unknown Error occurred inside FillSearchAndResolve - " + ex.ToString()); }}Now we come to the most important method in the class, FillSearchAndResolve. Let’s take this in pieces. First we look at the input string to see if someone entered a domain name such as “CHI\toddwilder”. In this case we restrict our AD search to the CHI domain. If we don’t see any domain specified, we search all domains.// look for domain names in the searchstring - set FoundDomain and trim searchstring further // if there is a domain found...var domains = Forest.GetCurrentForest().Domains;Domain foundDomain = null;foreach (Domain domain in domains){ if (trimmedSearchPattern.StartsWith(domain.Name + "\\", StringComparison.CurrentCultureIgnoreCase)) { trimmedSearchPattern = Regex.Replace(trimmedSearchPattern, "^" + domain.Name + "\\\\", "", RegexOptions.IgnoreCase); foundDomain = domain; break; } var domainName = domain.Name.Split('.')[0]; if (trimmedSearchPattern.StartsWith(domainName + "\\", StringComparison.CurrentCultureIgnoreCase)) { trimmedSearchPattern = Regex.Replace(trimmedSearchPattern, "^" + domainName + "\\\\", "", RegexOptions.IgnoreCase); foundDomain = domain; break; }}Now we create the directorysearcher object using the line var directorySearcher = CreateDirectorySearcher(foundDomain);Lets take a look at this method and its supporting objects. The purpose of this code is to create either a directorysearcher that searches all domains in the forest, or only searches a single domain.//shamelessly lifted from // DirectoryConstants{ public static string RootDSE { get { return @"GC://rootDSE"; } } public static string RootDomainNamingContext { get { return "rootDomainNamingContext"; } } public static string GlobalCatalogProtocol { get { return @"GC://"; } }}private static DirectorySearcher CreateDirectorySearcher(Domain domain){ var directorySearcher = new DirectorySearcher(); if (domain == null) { //Search all domains using (DirectoryEntry directoryEntry = new DirectoryEntry(DirectoryConstants.RootDSE)) { // Create a Global Catalog Directory Service Searcher string strRootName = directoryEntry.Properties[DirectoryConstants.RootDomainNamingContext].Value.ToString(); using (DirectoryEntry usersBinding = new DirectoryEntry(DirectoryConstants.GlobalCatalogProtocol + strRootName)) { directorySearcher.SearchRoot = usersBinding; } } } else { //just use one domain directorySearcher.SearchRoot = domain.GetDirectoryEntry(); } return directorySearcher;}Now let’s continue down the FillSearchAndResolve method. When a user is searching, they can specify whether to search users, groups or both. Let’s first examine the scenario when they do not specify either users or groups. This happens when they search inside the people editor control, or they search in the popup window without selecting either the users or groups node.if (string.IsNullOrEmpty(hierarchyId)){ //If the user is not clicking on Users or Groups in the directory popup directorySearcher.Filter = GetFilterStringForUserOrGroup(trimmedSearchPattern);}Let’s jump right to the GetFilterStringForUserOrGroup method for examination. Here we ask for AD results that are not disabled (userAccountControl:1.2.840.113556.1.4.803:=2), are either a person or group, and who’s display name, given name, surname, common name or email start with the search string. The complex string replacement involving {1},{2} and {3} are in case someone entered a user name in the format “Todd Wilder". We then remove all newlines from the string and the filter string is ready to go.private string GetFilterStringForUserOrGroup(string searchPattern){ var returnValue = @"(&(!(userAccountControl:1.2.840.113556.1.4.803:=2))( | (objectClass=User) (objectClass=Group))( | (sn={0}*) (displayName={0}*)" + (searchPattern.Contains(" ") ? @" (displayName={1}*) ( & (givenname={2}) (sn={3}*) )" : "") + @" (cn={0}*) (mail={0}*) (givenName={0}*)))"; returnValue = Regex.Replace(returnValue, @"[ \r\n]", "", RegexOptions.None); if (searchPattern.Contains(" ")) { returnValue = ModifyReturnValueForPossibleFirstNameLastName(searchPattern, returnValue); } else { returnValue = string.Format(returnValue, searchPattern); } return returnValue;}GetFilterStringForUser and GetFilterStringForGroup are both variations of the filter shown above. These methods are called when we know the user is specifically searching for a user or group (they’ve selected either the user or group node in the popup window).Let keep moving down the FillSearchAndResolve method. We Set the size limit, sort and load the AD properties to return in the search resultsdirectorySearcher.SizeLimit = maxCount;directorySearcher.Sort = new SortOption("displayname", SortDirection.Ascending);LoadDSProperties(directorySearcher);var results = directorySearcher.FindAll();Lets take a quick look at LoadDSProperties. These properties are needed to create the PickerEntity objects that get rendered in the people picker.private static void LoadDSProperties(DirectorySearcher ds){ ds.PropertiesToLoad.Add("distinguishedName"); ds.PropertiesToLoad.Add("objectsid"); ds.PropertiesToLoad.Add("objectClass"); ds.PropertiesToLoad.Add("cn"); ds.PropertiesToLoad.Add("displayName"); ds.PropertiesToLoad.Add("description"); ds.PropertiesToLoad.Add("mail"); ds.PropertiesToLoad.Add("telephoneNumber"); ds.PropertiesToLoad.Add("title"); ds.PropertiesToLoad.Add("department"); ds.PropertiesToLoad.Add("chiportalid");}The two sorted lists deserve some explanation…List<PickerEntity> sortedCorporateEntities = new List<PickerEntity>();List<PickerEntity> sortedNonCorporateEntities = new List<PickerEntity>();These lists allow us to render all sorted results from the “Corporate Domain” and then render all sorted results from all other domains. It’s important to note there is a bug in the people picker where this sorting is not always obeyed.Let’s now take a look at how we render a user inside the people picker. We do this by creating a PickerEntity class from the SearchResult object.if ((string)result.Properties["objectClass"][1] == "group"){ pe = CreatePickerEntityForGroup(result);}Let’s dive into the CreatePickerEntityForGroup method. We create a PickerEntity object from the superclass method and get the group’s domain to show in the display name. We try to get the groups display name but use the common name as a “Plan B” display name. We then build the claim object based off the groups SID. The group SID is better than the group name, because the SID will (almost) never change and it does not have any exotic characters.private PickerEntity CreatePickerEntityForGroup(SearchResult result){ //Create and initialize new Picker Entity PickerEntity entity = CreatePickerEntity(); var domainName = GetDomainName(result); string groupId = (string)result.Properties["cn"][0]; string groupName = domainName + groupId; if (result.Properties["displayName"] != null && result.Properties["displayName"].Count > 0) { groupName = domainName + (string)result.Properties["displayName"][0]; } else { // is this possible? } var sid = new SecurityIdentifier((byte[])result.Properties["objectsid"][0], 0); entity.Claim = new SPClaim(GroupClaimType, sid.ToString(), StringTypeClaim, SPOriginalIssuers.Format(SPOriginalIssuerType.TrustedProvider, SPTrustedIdentityTokenIssuerName)); entity.Description = ClaimProviderDisplayName + ":" + groupName; entity.DisplayText = groupName; entity.EntityType = SPClaimEntityTypes.FormsRole; entity.IsResolved = true; entity.EntityGroupName = "Security Group"; entity.EntityData[PeopleEditorEntityDataKeys.DisplayName] = groupName; return entity;}Lets take a quick look at the GetDomainName method, becauase its where forestDN and forestName come into play. forestDN is necessary to determine domain name from distinguishedName, and forestName is the value we give PickerEntities coming from the root domain.private object GetDomainName(SearchResult result){ var returnValue = ""; if (result.Properties["distinguishedName"] != null && result.Properties["distinguishedName"].Count > 0) { //try to pull the domain name into the display name of the result... returnValue = Regex.Match((string)result.Properties["distinguishedName"][0], @"(?<=,DC\=).*?(?=," + forestDN + ")", RegexOptions.IgnoreCase).Value + "\\"; if (returnValue == "\\") { returnValue = forestName + "\\"; } } else { // is this possible? } return returnValue.ToUpper();}Lets also examine the CreatePickerEntityForUser method. This method is very similar to the CreatePickerEntityForGroup method, but at the end of the method, we build out some additional properties for users to display in the list view of the popup window.We now choose whether to add the PickerEntity to the corporate sorted list (which is shown first) or the non-corporate sorted list (which is shown last). Here is where the corporateDomainName field comes into play, it determines if a PickerEntity is shown first or not.if (pe.DisplayText.StartsWith(corporateDomainName + "\\",StringComparison.CurrentCultureIgnoreCase)){ sortedCorporateEntities.Add(pe);}else{ sortedNonCorporateEntities.Add(pe);}Here is a fun little feature. If there is both a Daniel Williams and Danielle Williams, Daniel Williams can type Williams, Daniel into the people editor control and not have to choose between the two users. Instead, it allows for an exact match.// If i'm not viewing the directory browser popup, but instead using the peopeeditor control...if (searchTree == null){ // this is so Williams, Daniel resolves successfully instead of complaining of ambiguity between // Williams, Daniel and Williams, Danielle var exactMatch = pe.DisplayText.Substring(pe.DisplayText.IndexOf(@"\") + 1); if (searchPattern.Equals(exactMatch, StringComparison.CurrentCultureIgnoreCase)) { break; }}Now we come to the end of the all-important FillSearchAndResolve method. We sort each list and then add the PickerEntities to either the search tree (if we are searching using the popup window) or the resolved list (if we are searching using the people editor).//Sort each separately by display namesortedCorporateEntities.Sort((x, y) => x.pareTo(y.DisplayText));sortedNonCorporateEntities.Sort((x, y) => x.pareTo(y.DisplayText));AddEntitiesToTreeOrList(sortedCorporateEntities, searchTree, resolvedList);AddEntitiesToTreeOrList(sortedNonCorporateEntities, searchTree, resolvedList);-952517970500This concludes the explanation of the CustomPeoplePicker. Feel free to send any questions to ................
................

In order to avoid copyright disputes, this page is only a partial summary.

Google Online Preview   Download