Created
January 18, 2016 00:55
-
-
Save MJRichardson/7110d574f38ec7994c27 to your computer and use it in GitHub Desktop.
Octopus Deploy Active Directory Membership
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| using System; | |
| using System.Collections.Generic; | |
| using System.ComponentModel; | |
| using System.DirectoryServices.AccountManagement; | |
| using System.Linq; | |
| using System.Runtime.InteropServices; | |
| using System.Security.Principal; | |
| using Octopus.Core.Model.Events; | |
| using Octopus.Core.Model.Users; | |
| using Octopus.Core.RelationalStorage; | |
| using Octopus.Shared.Configuration; | |
| using Octopus.Shared.Diagnostics; | |
| using Octopus.Shared.Util; | |
| namespace Octopus.Server.Web.Infrastructure.Authentication | |
| { | |
| public class ActiveDirectoryMembership : IMembership, IPrincipalAwareMembership | |
| { | |
| const string NTAccountUsernamePrefix = "nt:"; | |
| /// <summary> | |
| /// This logon type is intended for high performance servers to authenticate plaintext passwords. | |
| /// The LogonUser function does not cache credentials for this logon type. | |
| /// </summary> | |
| const int LOGON32_LOGON_NETWORK = 3; | |
| /// <summary> | |
| /// Use the standard logon provider for the system. | |
| /// The default security provider is negotiate, unless you pass NULL for the domain name and the user name | |
| /// is not in UPN format. In this case, the default provider is NTLM. | |
| /// NOTE: Windows 2000/NT: The default security provider is NTLM. | |
| /// </summary> | |
| const int LOGON32_PROVIDER_DEFAULT = 0; | |
| readonly IRelationalStore relationalStore; | |
| readonly ILog log; | |
| readonly Lazy<IWebPortalConfiguration> adConfiguration; | |
| readonly Lazy<IImplicitUserCreationPolicy> creationPolicy; | |
| public ActiveDirectoryMembership(IRelationalStore relationalStore, ILog log, Lazy<IWebPortalConfiguration> adConfiguration, Lazy<IImplicitUserCreationPolicy> creationPolicy) | |
| { | |
| this.relationalStore = relationalStore; | |
| this.log = log; | |
| this.adConfiguration = adConfiguration; | |
| this.creationPolicy = creationPolicy; | |
| } | |
| public User ValidateCredentials(string username, string password) | |
| { | |
| if (!adConfiguration.Value.IsFormsAuthAllowed()) | |
| { | |
| return null; | |
| } | |
| if (username == null) throw new ArgumentNullException("username"); | |
| log.VerboseFormat("Validating credentials provided for '{0}'...", username); | |
| string domain; | |
| NormalizeCredentials(username, out username, out domain); | |
| using (var context = GetContext(domain)) | |
| { | |
| var principal = UserPrincipal.FindByIdentity(context, username); | |
| if (principal == null) | |
| { | |
| var searchedContext = domain ?? context.Name ?? context.ConnectedServer; | |
| log.InfoFormat("A principal identifiable by '{0}' was not found in '{1}'", username, searchedContext); | |
| return null; | |
| } | |
| var hToken = IntPtr.Zero; | |
| try | |
| { | |
| var logon = domain == null ? principal.UserPrincipalName : username; | |
| log.VerboseFormat("Calling LogonUser(\"{0}\", \"{1}\", ...)", logon, domain); | |
| if (!LogonUser(logon, domain, password, LOGON32_LOGON_NETWORK, LOGON32_PROVIDER_DEFAULT, out hToken)) | |
| { | |
| var error = new Win32Exception(); | |
| log.WarnFormat(error, "Principal '{0}' (Domain: '{1}') could not be logged on via WIN32: 0x{2:X8}.", logon, domain, error.NativeErrorCode); | |
| return null; | |
| } | |
| } | |
| finally | |
| { | |
| if (hToken != IntPtr.Zero) CloseHandle(hToken); | |
| } | |
| log.VerboseFormat("Credentials for '{0}' validated, mapped to principal '{1}'", username, principal.UserPrincipalName ?? ("(NTAccount)" + principal.Name)); | |
| bool unused; | |
| return GetOrCreateUser(principal, username, domain, out unused); | |
| } | |
| } | |
| PrincipalContext GetContext(string domain) | |
| { | |
| var adContainer = adConfiguration.Value.ActiveDirectoryContainer; | |
| adContainer = string.IsNullOrEmpty(adContainer) ? null : adContainer; | |
| return new PrincipalContext(ContextType.Domain, domain, adContainer); | |
| } | |
| public IList<string> GetMemberExternalSecurityGroupIds(string username) | |
| { | |
| if (username == null) throw new ArgumentNullException("username"); | |
| log.VerboseFormat("Finding external security groups for '{0}'...", username); | |
| string domain; | |
| NormalizeCredentials(username, out username, out domain); | |
| var groups = new List<string>(); | |
| using (var context = GetContext(domain)) | |
| { | |
| var principal = UserPrincipal.FindByIdentity(context, username); | |
| if (principal == null) | |
| { | |
| var searchedContext = domain ?? context.Name ?? context.ConnectedServer; | |
| log.TraceFormat("While loading security groups, a principal identifiable by '{0}' was not found in '{1}'", username, searchedContext); | |
| return new List<string>(); | |
| } | |
| try | |
| { | |
| // Reads inherited groups - this fails in some situations | |
| ReadAuthorizationGroups(principal, groups); | |
| } | |
| catch (Exception ex) | |
| { | |
| // Don't log it as an Error, it's expected to fail in some situations | |
| log.Verbose(ex); | |
| try | |
| { | |
| // Reads just the groups they are a member of - more reliable but not ideal | |
| ReadUserGroups(principal, groups); | |
| } | |
| catch (Exception ex2) | |
| { | |
| // Only log an error if both methods fail to read the groups | |
| log.Error(ex2); | |
| return groups; | |
| } | |
| } | |
| } | |
| return groups; | |
| } | |
| static void ReadAuthorizationGroups(UserPrincipal principal, ICollection<string> groups) | |
| { | |
| ReadGroups(principal.GetAuthorizationGroups(), groups); | |
| } | |
| static void ReadUserGroups(Principal principal, ICollection<string> groups) | |
| { | |
| ReadGroups(principal.GetGroups(), groups); | |
| } | |
| static void ReadGroups(IEnumerable<Principal> groupPrincipals, ICollection<string> groups) | |
| { | |
| var iterGroup = groupPrincipals.GetEnumerator(); | |
| using (iterGroup) | |
| { | |
| while (iterGroup.MoveNext()) | |
| { | |
| try | |
| { | |
| var p = iterGroup.Current; | |
| groups.Add(p.Sid.Value); | |
| } | |
| catch (NoMatchingPrincipalException) | |
| { | |
| } | |
| } | |
| } | |
| } | |
| public IList<ExternalSecurityGroup> FindExternalSecurityGroups(string name) | |
| { | |
| var results = new List<ExternalSecurityGroup>(); | |
| string domain; | |
| string username; | |
| NormalizeCredentials(name, out username, out domain); | |
| using (var context = GetContext(domain)) | |
| { | |
| var searcher = new PrincipalSearcher(); | |
| searcher.QueryFilter = new GroupPrincipal(context) {Name = name + "*"}; | |
| var iterGroup = searcher.FindAll().GetEnumerator(); | |
| using (iterGroup) | |
| { | |
| while (iterGroup.MoveNext()) | |
| { | |
| try | |
| { | |
| var p = iterGroup.Current as GroupPrincipal; | |
| if (p == null || !(p.IsSecurityGroup ?? false)) | |
| continue; | |
| results.Add(new ExternalSecurityGroup {Id = p.Sid.ToString(), DisplayName = p.Name}); | |
| } | |
| catch (NoMatchingPrincipalException) | |
| { | |
| } | |
| } | |
| } | |
| } | |
| return results.OrderBy(o => o.DisplayName).ToList(); | |
| } | |
| public User GetOrCreateUser(string username) | |
| { | |
| bool unused; | |
| return GetOrCreateUser(username, out unused); | |
| } | |
| public User GetOrCreateUser(string username, out bool wasCreated) | |
| { | |
| string domain; | |
| NormalizeCredentials(username, out username, out domain); | |
| using (var context = GetContext(domain)) | |
| { | |
| var principal = UserPrincipal.FindByIdentity(context, username); | |
| if (principal == null) | |
| { | |
| var searchedContext = domain ?? context.Name ?? context.ConnectedServer; | |
| throw new ArgumentException(string.Format("A principal identifiable by '{0}' was not found in '{1}'", username, searchedContext)); | |
| } | |
| return GetOrCreateUser(principal, username, domain ?? Environment.UserDomainName, out wasCreated); | |
| } | |
| } | |
| User GetOrCreateUser(UserPrincipal principal, string fallbackUsername, string fallbackDomain, out bool wasCreated) | |
| { | |
| wasCreated = false; | |
| var name = principal.UserPrincipalName; | |
| if (name == null) | |
| { | |
| log.WarnFormat("The user name (UPN) could not be determined for principal {0} - falling back to NT-style '{1}\\{2}'", principal, fallbackDomain, fallbackUsername); | |
| if (string.IsNullOrWhiteSpace(fallbackDomain)) | |
| throw new InvalidOperationException("No fallback domain was provided"); | |
| if (string.IsNullOrWhiteSpace(fallbackUsername)) | |
| throw new InvalidOperationException("No fallback username was provided"); | |
| name = NTAccountUsernamePrefix + fallbackDomain.Trim() + "\\" + fallbackUsername.Trim(); | |
| } | |
| using (var transaction = relationalStore.BeginTransaction()) | |
| { | |
| var user = transaction.Query<User>().Where("Username = @username").Parameter("username", name).First(); | |
| if (user == null) | |
| { | |
| EnsureNotTooManyUsers(); | |
| // We implicitly provsion a Windows user for a username | |
| user = new User(name); | |
| user.DisplayName = string.IsNullOrWhiteSpace(principal.DisplayName) ? principal.Name : principal.DisplayName; | |
| user.EmailAddress = principal.EmailAddress; | |
| user.SetPassword(RandomStringGenerator.Generate(16)); | |
| transaction.Insert(user); | |
| var team = transaction.Load<Team>(Team.EveryoneTeamId); | |
| team.MemberUserIds.Add(user.Id); | |
| transaction.Update(team); | |
| transaction.Insert(Event.Build(EventCategory.Modified).Append("User ").AppendReference(user.Username, user.Id).Append(" was added to the team ").AppendReference(team.Name, team.Id).By(user.Username, user.Id).Build()); | |
| wasCreated = true; | |
| transaction.Commit(); | |
| } | |
| return user; | |
| } | |
| } | |
| void EnsureNotTooManyUsers() | |
| { | |
| if (!creationPolicy.Value.CanCreateUser()) | |
| { | |
| throw new ArgumentException("Unfortunately this Octopus server license is limited to a specific number of users, and adding a user account for you would put the server beyond this limit. Please contact your Octopus administrator to either disable some users, or increase the license limit."); | |
| } | |
| } | |
| public static void NormalizeCredentials(string username, out string usernamePart, out string domainPart) | |
| { | |
| if (username == null) throw new ArgumentNullException("username"); | |
| if (username.StartsWith(NTAccountUsernamePrefix)) | |
| username = username.Remove(0, NTAccountUsernamePrefix.Length); | |
| if (!TryParseDownLevelLogonName(username, out usernamePart, out domainPart)) | |
| { | |
| usernamePart = username; | |
| domainPart = null; | |
| } | |
| } | |
| // If the return value is true, dlln was a valid down-level logon name, and username/domain | |
| // contain precisely the component username and domain name values. Note, we don't split | |
| // UPNs this way because the suffix part of a UPN is not necessarily a domain, and in | |
| // the default LogonUser case should be passed whole to the function with a null domain. | |
| public static bool TryParseDownLevelLogonName(string dlln, out string username, out string domain) | |
| { | |
| if (dlln == null) throw new ArgumentNullException("dlln"); | |
| username = null; | |
| domain = null; | |
| var slash = dlln.IndexOf('\\'); | |
| if (slash == -1 || slash == dlln.Length - 1 || slash == 0) | |
| return false; | |
| domain = dlln.Substring(0, slash).Trim(); | |
| username = dlln.Substring(slash + 1).Trim(); | |
| return !string.IsNullOrWhiteSpace(domain) && !string.IsNullOrWhiteSpace(username); | |
| } | |
| [DllImport("advapi32.dll", SetLastError = true)] | |
| public static extern bool LogonUser( | |
| string lpszUsername, | |
| string lpszDomain, | |
| string lpszPassword, | |
| int dwLogonType, | |
| int dwLogonProvider, | |
| out IntPtr phToken | |
| ); | |
| [DllImport("kernel32.dll", SetLastError = true)] | |
| [return: MarshalAs(UnmanagedType.Bool)] | |
| static extern bool CloseHandle(IntPtr hObject); | |
| public User GetOrCreateUser(IPrincipal principal) | |
| { | |
| return GetOrCreateUser(principal.Identity.Name); | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment