using System; using System.Collections.Generic; using System.DirectoryServices; using System.Linq; using System.Web; using ScrewTurn.Wiki.PluginFramework; using System.Text; using System.DirectoryServices.ActiveDirectory; namespace ScrewTurn.Wiki.Plugins.ActiveDirectory { /// /// Implements a Users Storage Provider for Active Directory. /// public class ActiveDirectoryProvider : IUsersStorageProviderV30 { private const string HELP_HELP = "Configuration Settings:
" + "Map one or more domain groups to wiki groups (Users, Administrators, etc.):" + "" + "Give all users membership in common wiki groups (Users, etc.):" + "" + "Give users with no wiki group membership default wiki groups (Users, etc.):" + "" + "Authenticate against a domain the web server is not joined to (optional, choose one):" + "" + "Query active directory as a specific user on the domain (optional):" + "" + "Automatic login without a login form (optional):" + "" + "In case the user doesn't have an email in his ActiveDirectory profile, sets the email to a predefined value in the form name.surname@example.com (optional):" + "" + "Comments start with a semicolon \";\"."; private IHostV30 m_Host; private IUsersStorageProviderV30 m_StorageProvider; private Random m_Random; private Config m_Config; private class Config { public string ServerName; public string DomainName; public string Username; public string Password; public string AutomaticMail; private Dictionary GroupMap = new Dictionary(StringComparer.InvariantCultureIgnoreCase); /// /// Gets the domain specified by the config or else the computer domain. /// /// The Domain object. public Domain GetDomain() { return GetDomain(Username, Password); } /// /// Gets the domain specified by the config or else the computer domain using the given credentials (if any). /// /// The username. /// The password. /// The Domain object. public Domain GetDomain(string username, string password) { DirectoryContext context = null; if(ServerName != null) { context = new DirectoryContext(DirectoryContextType.DirectoryServer, ServerName, username, password); } else { context = new DirectoryContext(DirectoryContextType.Domain, DomainName, username, password); } return Domain.GetDomain(context); } /// /// Determine if the given user credentials are valid. /// /// The username. /// The password. /// true if credentials are valid otherwise false. public bool ValidateCredentials(string username, string password) { try { using(Domain domain = GetDomain(username, password)) { return true; } } catch { return false; } } /// /// Get the wiki groups for the given domain groups, if any. /// /// The domain groups. /// The list of wiki groups. public List GetWikiGroups(List domainGroups) { // find all the wiki groups from the given domain groups var wikiGroups = domainGroups.SelectMany(t => GetGroupMap(t)).ToList(); // add groups common to all users wikiGroups.AddRange(CommonGroups); // remove duplicates wikiGroups = wikiGroups.Distinct(StringComparer.InvariantCultureIgnoreCase).ToList(); // return the groups, if any if(wikiGroups.Count > 0) return wikiGroups; // return the default groups, if any return DefaultGroups.ToList(); } /// /// Get the wiki groups for the given domain group, if any. /// /// The domain group. /// The array of string containing the wiki groups. private string[] GetGroupMap(string domainGroup) { string[] wikiGroups; if(GroupMap.TryGetValue(domainGroup, out wikiGroups)) return wikiGroups; return new string[0]; } /// /// Adds the given group map. /// /// The domain group. /// The wiki groups. public void AddGroupMap(string domainGroup, string[] wikiGroups) { if(wikiGroups == null || wikiGroups.Length == 0) GroupMap.Remove(domainGroup); else GroupMap[domainGroup] = wikiGroups; } /// /// Returns true if the group map contains at least one entry. /// /// /// true if this instance is group map set; otherwise, false. /// public bool IsGroupMapSet { get { return GroupMap.Count > 0; } } /// /// Groups that are common to all users, regardless of their domain group membership. /// /// The common groups. private static readonly string CommonKey = String.Empty; public string[] CommonGroups { get { return GetGroupMap(CommonKey); } set { AddGroupMap(CommonKey, value); } } /// /// Groups that are used when a user has no other group membership. /// /// The default groups. private static readonly string DefaultKey = ".DEFAULT!"; public string[] DefaultGroups { get { return GetGroupMap(DefaultKey); } set { AddGroupMap(DefaultKey, value); } } } /// /// Tests a Password for a User account. /// /// The User account. /// The Password to test. /// True if the Password is correct. /// If or are null. public bool TestAccount(UserInfo user, string password) { return false; } /// /// Gets the complete list of Users. /// /// All the Users, sorted by username. public UserInfo[] GetUsers() { return new UserInfo[] { }; } /// /// Adds a new User. /// /// The Username. /// The display name (can be null). /// The Password. /// The Email address. /// A value indicating whether the account is active. /// The Account creation Date/Time. /// /// The correct object or null. /// /// If , or are null. /// If , or are empty. public UserInfo AddUser(string username, string displayName, string password, string email, bool active, DateTime dateTime) { throw new NotImplementedException(); } /// /// Modifies a User. /// /// The Username of the user to modify. /// The new display name (can be null). /// The new Password (null or blank to keep the current password). /// The new Email address. /// A value indicating whether the account is active. /// /// The correct object or null. /// /// If or are null. /// If is empty. public UserInfo ModifyUser(UserInfo user, string newDisplayName, string newPassword, string newEmail, bool newActive) { throw new NotImplementedException(); } /// /// Removes a User. /// /// The User to remove. /// /// True if the User has been removed successfully. /// /// If is null. public bool RemoveUser(UserInfo user) { throw new NotImplementedException(); } /// /// Gets all the user groups. /// /// All the groups, sorted by name. public UserGroup[] GetUserGroups() { return new UserGroup[] { }; } /// /// Adds a new user group. /// /// The name of the group. /// The description of the group. /// /// The correct object or null. /// /// If or are null. /// If is empty. public UserGroup AddUserGroup(string name, string description) { throw new NotImplementedException(); } /// /// Modifies a user group. /// /// The group to modify. /// The new description of the group. /// /// The correct object or null. /// /// If or are null. public UserGroup ModifyUserGroup(UserGroup group, string description) { throw new NotImplementedException(); } /// /// Removes a user group. /// /// The group to remove. /// /// true if the group is removed, false otherwise. /// /// If is null. public bool RemoveUserGroup(UserGroup group) { throw new NotImplementedException(); } /// /// Sets the group memberships of a user account. /// /// The user account. /// The groups the user account is member of. /// /// The correct object or null. /// /// If or are null. public UserInfo SetUserMembership(UserInfo user, string[] groups) { throw new NotImplementedException(); } /// /// Tries to login a user directly through the provider. /// /// The username. /// The password. /// /// The correct UserInfo object, or null. /// /// If or are null. public UserInfo TryManualLogin(string username, string password) { var info = StorageProvider.TryManualLogin(username, password); if(info != null) return info; if(m_Config.ValidateCredentials(username, password)) return GetUser(username); return null; } /// /// Tries to login a user directly through the provider using /// the current HttpContext and without username/password. /// /// The current HttpContext. /// /// The correct UserInfo object, or null. /// /// If is null. public UserInfo TryAutoLogin(HttpContext context) { try { if(!context.User.Identity.IsAuthenticated) return null; var username = context.User.Identity.Name.Substring(context.User.Identity.Name.IndexOf(@"\") + 1); return GetUser(username); } catch { return null; } } /// /// Creates the primary group SID. /// http://dunnry.com/blog/DeterminingYourPrimaryGroupInActiveDirectoryUsingNET.aspx /// /// The user sid. /// The primary group ID. private void CreatePrimaryGroupSID(byte[] userSid, int primaryGroupID) { // convert the int into a byte array byte[] rid = BitConverter.GetBytes(primaryGroupID); // place the bytes into the user's SID byte array // overwriting them as necessary for(int i = 0; i < rid.Length; i++) { userSid.SetValue(rid[i], new long[] { userSid.Length - (rid.Length - i) }); } } /// /// Builds the octet string. /// http://dunnry.com/blog/DeterminingYourPrimaryGroupInActiveDirectoryUsingNET.aspx /// /// The bytes. /// private string BuildOctetString(byte[] bytes) { StringBuilder sb = new StringBuilder(); for(int i = 0; i < bytes.Length; i++) { sb.Append(bytes[i].ToString("X2")); } return sb.ToString(); } private class UserProperties { public byte[] ObjectSid; public string Mail; public string DisplayName; public List MemberOf; public int PrimaryGroupID; } /// /// Gets the user info object for the currently logged in user. /// /// The username. /// /// The , or null. /// /// If is null. /// If is empty. public UserInfo GetUser(string username) { try { var user = StorageProvider.GetUser(username); if(user != null) return user; // Active Directory Attributes // // http://msdn.microsoft.com/en-us/library/ms683980(VS.85).aspx // // Object-Sid -> objectSid (required, single-value) // Object-Class -> objectClass (required, multi-value) // Object-Category -> objectCategory (required, single-value) // SAM-Account-Name -> sAMAccountName (required, single-value) // E-mail-Addresses -> mail (optional, single-value) // Display-Name -> displayName (optional, single-value) // Is-Member-Of-DL -> memberOf (optional, multi-value) using(Domain domain = m_Config.GetDomain()) { SearchResult result; try { using(DirectoryEntry searchRoot = domain.GetDirectoryEntry()) { using(var searcher = new DirectorySearcher(searchRoot)) { searcher.Filter = String.Format("(&(objectClass=user)(objectCategory=person)(sAMAccountName={0}))", username); searcher.PropertiesToLoad.Add("objectSid"); searcher.PropertiesToLoad.Add("mail"); searcher.PropertiesToLoad.Add("displayName"); searcher.PropertiesToLoad.Add("memberOf"); searcher.PropertiesToLoad.Add("primaryGroupID"); result = searcher.FindOne(); } } if(result == null) return null; } catch(Exception ex) { LogEntry(LogEntryType.Error, "Unable to complete search for user \"{0}\": {1}", username, ex); return null; } UserProperties userProperties; try { userProperties = new UserProperties { ObjectSid = result.Properties["objectSid"].Cast().Single(), Mail = result.Properties["mail"].Cast().SingleOrDefault(), DisplayName = result.Properties["displayName"].Cast().SingleOrDefault(), MemberOf = result.Properties["memberOf"].Cast().ToList(), PrimaryGroupID = result.Properties["primaryGroupID"].Cast().Single(), }; } catch(Exception ex) { LogEntry(LogEntryType.Error, "Unable to access properties for user \"{0}\": {1}", username, ex); return null; } if(userProperties.Mail == null) { if(m_Config.AutomaticMail != null && !string.IsNullOrEmpty(userProperties.DisplayName)) { userProperties.Mail = userProperties.DisplayName.Replace(" ", ".") + "@" + m_Config.AutomaticMail; } else { LogEntry(LogEntryType.Error, "Cannot login user \"{0}\" because they have no email address.", username); return null; } } CreatePrimaryGroupSID(userProperties.ObjectSid, userProperties.PrimaryGroupID); string primaryGroupPath = String.Format("", BuildOctetString(userProperties.ObjectSid)); userProperties.MemberOf.Add(primaryGroupPath); var domainGroups = new List(); foreach(string memberOfPath in userProperties.MemberOf) { try { using(var memberOfEntry = domain.GetDirectoryEntry()) { string basePath = memberOfEntry.Path.Remove(memberOfEntry.Path.LastIndexOf("/")); memberOfEntry.Path = String.Format("{0}/{1}", basePath, memberOfPath); var samAccountName = memberOfEntry.Properties["sAMAccountName"].Cast().Single(); domainGroups.Add(samAccountName); } } catch(Exception ex) { LogEntry(LogEntryType.Error, "Skipping group \"{0}\" due to lookup error: {1}", memberOfPath, ex); continue; } } var wikiGroups = m_Config.GetWikiGroups(domainGroups); if(wikiGroups.Count == 0) { LogEntry(LogEntryType.Error, "Refusing to create user \"{0}\" without any groups, please check the GroupMap configuration.", username); return null; } user = StorageProvider.AddUser(username, userProperties.DisplayName, GeneratePassword(), userProperties.Mail, true, DateTime.Now); if(user == null) { LogEntry(LogEntryType.Error, "Failed to create user \"{0}\" using provider \"{1}\", but no error was given by the provider.", username, StorageProvider.GetType()); return null; } LogEntry(LogEntryType.General, "Created user \"{0}\" using provider \"{1}\", but no group membership has been set yet.", username, StorageProvider.GetType()); user = StorageProvider.SetUserMembership(user, wikiGroups.ToArray()); if(user == null) { LogEntry(LogEntryType.Error, "Failed to set user membership for user \"{0}\" using provider \"{1}\", but no error was given by the provider.", username, StorageProvider.GetType()); return null; } LogEntry(LogEntryType.General, "Set user membership for user \"{0}\" using provider \"{1}\", user is ready for use.", username, StorageProvider.GetType()); return user; } } catch(Exception ex) { LogEntry(LogEntryType.Error, "Error looking up user: {0}", ex); return null; } } /// /// Generate a random password of garbage since a non-zero length password is required /// /// The random password. private string GeneratePassword() { byte[] bytes = new byte[100]; m_Random.NextBytes(bytes); return new String(bytes.Select(t => Convert.ToChar(t)).ToArray()); } /// /// Gets a user account. /// Not Implemented - Passed Directly to the IUsersStorageProviderV30 /// /// The email address. /// /// The first user found with the specified email address, or null. /// /// If is null. /// If is empty. public UserInfo GetUserByEmail(string email) { return StorageProvider.GetUserByEmail(email); } /// /// Notifies the provider that a user has logged in through the authentication cookie. /// Not Implemented - Passed Directly to the IUsersStorageProviderV30 /// /// The user who has logged in. /// If is null. public void NotifyCookieLogin(UserInfo user) { StorageProvider.NotifyCookieLogin(user); } /// /// Notifies the provider that a user has logged out. /// Not Implemented - Passed Directly to the IUsersStorageProviderV30 /// /// The user who has logged out. /// If is null. public void NotifyLogout(UserInfo user) { StorageProvider.NotifyLogout(user); } /// /// Stores a user data element, overwriting the previous one if present. /// Not Implemented - Passed Directly to the IUsersStorageProviderV30 /// /// The user the data belongs to. /// The key of the data element (case insensitive). /// The value of the data element, null for deleting the data. /// /// true if the data element is stored, false otherwise. /// /// If or are null. /// If is empty. public bool StoreUserData(UserInfo user, string key, string value) { return StorageProvider.StoreUserData(user, key, value); } /// /// Gets a user data element, if any. /// Not Implemented - Passed Directly to the IUsersStorageProviderV30 /// /// The user the data belongs to. /// The key of the data element. /// /// The value of the data element, or null if the element is not found. /// /// If or are null. /// If is empty. public string RetrieveUserData(UserInfo user, string key) { return StorageProvider.RetrieveUserData(user, key); } /// /// Retrieves all the user data elements for a user. /// Not Implemented - Passed Directly to the IUsersStorageProviderV30 /// /// The user. /// The user data elements (key->value). /// If is null. public IDictionary RetrieveAllUserData(UserInfo user) { return StorageProvider.RetrieveAllUserData(user); } /// /// Gets all the users that have the specified element in their data. /// Not Implemented - Passed Directly to the IUsersStorageProviderV30 /// /// The key of the data. /// The users and the data. /// If is null. /// If is empty. public IDictionary GetUsersWithData(string key) { return StorageProvider.GetUsersWithData(key); } /// /// Gets a value indicating whether user accounts are read-only. /// /// True, always, we can't write back to AD public bool UserAccountsReadOnly { get { return true; } } /// /// Gets a value indicating whether user groups are read-only. If so, the provider /// should support default user groups as defined in the wiki configuration. /// /// True, always, we can't write back to AD public bool UserGroupsReadOnly { get { return true; } } /// /// Gets a value indicating whether group membership is read-only (if /// is false, then this property must be false). If this property is true, the provider /// should return membership data compatible with default user groups. /// /// True, always, we can't write back to AD public bool GroupMembershipReadOnly { get { return true; } } /// /// Gets a value indicating whether users' data is read-only. /// /// True, always, we can't write back to AD public bool UsersDataReadOnly { get { return true; } } /// /// Initializes the Storage Provider. /// /// The Host of the Component. /// The Configuration data, if any. /// If or are null. /// If is not valid or is incorrect. public void Init(IHostV30 host, string config) { m_Host = host; m_Random = new Random(); InitConfig(config); } /// /// Returns the storage provider. /// The storage provider is identified the first time it is needed, rather than at init. /// This avoids a dependency on the storage provider being loaded first, which is not guaranteed /// /// The storage provider. private IUsersStorageProviderV30 StorageProvider { get { if(m_StorageProvider == null) { lock(m_Host) { if(m_StorageProvider == null) { m_StorageProvider = (from a in m_Host.GetUsersStorageProviders(true) where a.Information.Name != this.Information.Name select a).FirstOrDefault(); if(m_StorageProvider == null) { LogEntry(LogEntryType.Error, "This provider requires an additional active storage provider for storing of active directory user information."); throw new InvalidConfigurationException("This provider requires an additional active storage provider for storing of active directory user information."); } } } } return m_StorageProvider; } } /// /// Method invoked on shutdown. /// Ignored /// /// This method might not be invoked in some cases. public void Shutdown() { StorageProvider.Shutdown(); } /// /// Gets the Information about the Provider. /// /// The information public ComponentInformation Information { get { return new ComponentInformation("Active Directory Provider", "Threeplicate Srl", "3.0.2.518", "http://www.screwturn.eu", "http://www.screwturn.eu/Version/ADProv/ADProv.txt"); } } /// /// Gets a brief summary of the configuration string format, in HTML. Returns null if no configuration is needed. /// /// public string ConfigHelpHtml { get { return HELP_HELP; } } /// /// Logs a message from this plugin. /// /// Type of the entry. /// The message. /// The args. private void LogEntry(LogEntryType entryType, string message, params object[] args) { string entry = String.Format(message, args); m_Host.LogEntry(entry, entryType, null, this); } /// /// Configures the plugin based on the configuration settings. /// /// The config. private void InitConfig(string config) { Config newConfig = ParseConfig(config); if(!newConfig.IsGroupMapSet) { LogEntry(LogEntryType.Error, "No GroupMap entries found. Please make sure at least one valid GroupMap configuration entry exists."); throw new InvalidConfigurationException("No GroupMap entries found. Please make sure at least one valid GroupMap configuration entry exists."); } if(newConfig.CommonGroups.Length > 0 && newConfig.DefaultGroups.Length > 0) { LogEntry(LogEntryType.Warning, "DefaultGroups will be ignored because CommonGroups have been configured."); newConfig.DefaultGroups = null; } if(newConfig.ServerName != null) { if(newConfig.DomainName != null) { LogEntry(LogEntryType.Warning, "Domain and Server config keys are mutually exclusive, but both were given. " + "The Domain entry will be ignored."); newConfig.DomainName = null; } LogEntry(LogEntryType.General, "Configured to use domain controller \"{0}\".", newConfig.ServerName); } else { if(newConfig.DomainName == null) { try { newConfig.DomainName = Domain.GetComputerDomain().Name; } catch(Exception ex) { LogEntry(LogEntryType.Error, "Unable to auto-detect the computer domain: {0}", ex); throw new InvalidConfigurationException("Unable to auto-detect the computer domain.", ex); } LogEntry(LogEntryType.General, "Domain \"{0}\" was determined through auto-detection.", newConfig.DomainName); } else { LogEntry(LogEntryType.General, "Configured to use domain \"{0}\".", newConfig.DomainName); } } try { using(Domain domain = newConfig.GetDomain()) { } } catch(Exception ex) { LogEntry(LogEntryType.Error, "Unable to connect to active directory with configured username and password (if any): {0}", ex); throw new InvalidConfigurationException("Unable to connect to active directory with configured username and password (if any).", ex); } m_Config = newConfig; } /// /// Parses the plugin configuration string. /// /// The config. /// A Config object representig the configuration string. private Config ParseConfig(string config) { Config newConfig = new Config(); try { string[] configLines = config.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); foreach(string configLine in configLines) { string remainingConfigLine = configLine.Split(new[] { ';' }).First(); if(remainingConfigLine.Length == 0) continue; string[] configEntry = remainingConfigLine.Split(new[] { '=' }, 2); if(configEntry.Length != 2) { LogEntry(LogEntryType.Error, "Config lines must be in the format \"Key=Value\". " + "The config line \"{0}\" will be ignored.", configLine); continue; } string key = configEntry[0].Trim().ToLowerInvariant(); string value = configEntry[1].Trim(); switch(key) { case "server": newConfig.ServerName = value; break; case "username": newConfig.Username = value; break; case "password": newConfig.Password = value; break; case "domain": newConfig.DomainName = value; break; case "groupmap": string[] groupMap = value.Split(new[] { ':' }, 2); if(groupMap.Length != 2) { LogEntry(LogEntryType.Error, "GroupMap entries must be in the format \"GroupMap=DomainGroup:WikiGroup[,...]\". " + "The config line \"{0}\" will be ignored.", configLine); break; } string fromDomainGroup = groupMap[0]; string[] toWikiGroups = groupMap[1].Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Distinct(StringComparer.InvariantCultureIgnoreCase).ToArray(); newConfig.AddGroupMap(fromDomainGroup, toWikiGroups); break; case "commongroups": string[] commonGroups = value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Distinct(StringComparer.InvariantCultureIgnoreCase).ToArray(); newConfig.CommonGroups = commonGroups; break; case "defaultgroups": string[] defaultGroups = value.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Distinct(StringComparer.InvariantCultureIgnoreCase).ToArray(); newConfig.DefaultGroups = defaultGroups; break; case "automaticmail": newConfig.AutomaticMail = value; break; default: LogEntry(LogEntryType.Error, "Invalid config key, see help for valid options. " + "The config line \"{0}\" will be ignored.", configLine); break; } } return newConfig; } catch(Exception ex) { LogEntry(LogEntryType.Error, "Error parsing the configuration: {0}", ex); throw new InvalidConfigurationException("Error parsing the configuration.", ex); } } } }