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.):" +
"
" +
"- GroupMap=somedomaingroup:somewikigroup[,anotherwikigroup[,...]]
" +
"
" +
"Give all users membership in common wiki groups (Users, etc.):" +
"" +
"- CommonGroups=somewikigroup[,anotherwikigroup[,...]]
" +
"
" +
"Give users with no wiki group membership default wiki groups (Users, etc.):" +
"" +
"- DefaultGroups=somewikigroup[,anotherwikigroup[,...]]
" +
"
" +
"Authenticate against a domain the web server is not joined to (optional, choose one):" +
"" +
"- Domain=some.domain
" +
"- Server=somedomaincontroller.some.domain
" +
"
" +
"Query active directory as a specific user on the domain (optional):" +
"" +
"- Username=someusername
" +
"- Password=somepassword
" +
"
" +
"Automatic login without a login form (optional):" +
"" +
"- Set Authentication mode to Windows in Web.config.
" +
"- Turn on Windows authentication on the web server.
" +
"
" +
"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):" +
"" +
"- AutomaticMail=example.com
" +
"
" +
"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);
}
}
}
}