Add SGU credential provider and authentication broker
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.DirectoryServices;
|
||||
using SGU.AuthBroker.Core.Directory;
|
||||
using SGU.AuthBroker.Core.Identity;
|
||||
using SGU.AuthBroker.Options;
|
||||
|
||||
namespace SGU.AuthBroker.Services;
|
||||
|
||||
public sealed class ActiveDirectorySynchronizer(BrokerOptions options) : IActiveDirectorySynchronizer
|
||||
{
|
||||
private const int AccountDisabled = 0x0002;
|
||||
private const int NormalAccount = 0x0200;
|
||||
private static readonly AuthenticationTypes BindFlags =
|
||||
AuthenticationTypes.Secure | AuthenticationTypes.Signing | AuthenticationTypes.Sealing;
|
||||
|
||||
private readonly ActiveDirectoryOptions options = options.Directory;
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> userLocks =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public async Task<DirectorySyncResult> SynchronizeAsync(
|
||||
UserIdentity identity,
|
||||
string password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SemaphoreSlim gate = userLocks.GetOrAdd(identity.UserName, static _ => new SemaphoreSlim(1, 1));
|
||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
return await Task.Run(
|
||||
() => Synchronize(identity, password),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
if (gate.CurrentCount == 1)
|
||||
{
|
||||
userLocks.TryRemove(new KeyValuePair<string, SemaphoreSlim>(identity.UserName, gate));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private DirectorySyncResult Synchronize(UserIdentity identity, string password)
|
||||
{
|
||||
string targetOuDn = options.GetOuDn(identity.Role);
|
||||
using DirectoryEntry root = Bind(options.BaseDn);
|
||||
using DirectoryEntry targetOu = BindOrCreateOu(targetOuDn, root);
|
||||
|
||||
using DirectorySearcher searcher = new(root)
|
||||
{
|
||||
Filter = $"(&(objectCategory=person)(objectClass=user)(sAMAccountName={EscapeLdapFilter(identity.UserName)}))",
|
||||
SearchScope = SearchScope.Subtree,
|
||||
PageSize = 1,
|
||||
SizeLimit = 1
|
||||
};
|
||||
searcher.PropertiesToLoad.Add("distinguishedName");
|
||||
|
||||
SearchResult? result = searcher.FindOne();
|
||||
bool created = result is null;
|
||||
bool moved = false;
|
||||
DirectoryEntry? user = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (created)
|
||||
{
|
||||
user = targetOu.Children.Add($"CN={EscapeRdn(identity.UserName)}", "user");
|
||||
user.Properties["sAMAccountName"].Value = identity.UserName;
|
||||
user.Properties["userPrincipalName"].Value = $"{identity.UserName}@{options.UpnSuffix}";
|
||||
user.Properties["displayName"].Value = identity.UserName;
|
||||
user.CommitChanges();
|
||||
}
|
||||
else
|
||||
{
|
||||
user = result!.GetDirectoryEntry();
|
||||
string distinguishedName = Convert.ToString(user.Properties["distinguishedName"].Value) ?? string.Empty;
|
||||
string parentDn = ParentDn(distinguishedName);
|
||||
if (!string.Equals(parentDn, targetOuDn, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
user.MoveTo(targetOu);
|
||||
moved = true;
|
||||
}
|
||||
|
||||
user.Properties["userPrincipalName"].Value = $"{identity.UserName}@{options.UpnSuffix}";
|
||||
user.CommitChanges();
|
||||
}
|
||||
|
||||
// The exact institutional password received by the broker is passed to AD.
|
||||
// It is not derived, transformed, written to disk, or included in logs.
|
||||
user.Invoke("SetPassword", [password]);
|
||||
int flags = user.Properties["userAccountControl"].Value is int currentFlags
|
||||
? currentFlags
|
||||
: NormalAccount;
|
||||
user.Properties["userAccountControl"].Value = (flags | NormalAccount) & ~AccountDisabled;
|
||||
user.Properties["pwdLastSet"].Value = -1;
|
||||
user.CommitChanges();
|
||||
|
||||
return new DirectorySyncResult(
|
||||
options.DomainNetbios,
|
||||
identity.UserName,
|
||||
$"{identity.UserName}@{options.UpnSuffix}",
|
||||
created,
|
||||
moved);
|
||||
}
|
||||
finally
|
||||
{
|
||||
user?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private DirectoryEntry BindOrCreateOu(string ouDn, DirectoryEntry root)
|
||||
{
|
||||
try
|
||||
{
|
||||
DirectoryEntry existing = Bind(ouDn);
|
||||
_ = existing.NativeObject;
|
||||
return existing;
|
||||
}
|
||||
catch (DirectoryServicesCOMException) when (options.CreateMissingOus)
|
||||
{
|
||||
string parent = ParentDn(ouDn);
|
||||
if (!ouDn.EndsWith($",{options.BaseDn}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("Automatic OU creation is limited to descendants of BaseDn.");
|
||||
}
|
||||
|
||||
string rdn = ouDn[..FirstUnescapedComma(ouDn)];
|
||||
DirectoryEntry? parentEntry = null;
|
||||
try
|
||||
{
|
||||
DirectoryEntry container = root;
|
||||
if (!string.Equals(parent, options.BaseDn, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
parentEntry = BindOrCreateOu(parent, root);
|
||||
container = parentEntry;
|
||||
}
|
||||
|
||||
DirectoryEntry created = container.Children.Add(rdn, "organizationalUnit");
|
||||
created.CommitChanges();
|
||||
return created;
|
||||
}
|
||||
finally
|
||||
{
|
||||
parentEntry?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private DirectoryEntry Bind(string distinguishedName) =>
|
||||
new($"LDAP://{options.LdapHost}/{distinguishedName}", null, null, BindFlags);
|
||||
|
||||
private static string ParentDn(string distinguishedName)
|
||||
{
|
||||
int comma = FirstUnescapedComma(distinguishedName);
|
||||
return comma < 0 ? string.Empty : distinguishedName[(comma + 1)..];
|
||||
}
|
||||
|
||||
private static int FirstUnescapedComma(string value)
|
||||
{
|
||||
bool escaped = false;
|
||||
for (int i = 0; i < value.Length; i++)
|
||||
{
|
||||
if (escaped)
|
||||
{
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value[i] == '\\')
|
||||
{
|
||||
escaped = true;
|
||||
}
|
||||
else if (value[i] == ',')
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static string EscapeLdapFilter(string value) => value
|
||||
.Replace("\\", "\\5c", StringComparison.Ordinal)
|
||||
.Replace("*", "\\2a", StringComparison.Ordinal)
|
||||
.Replace("(", "\\28", StringComparison.Ordinal)
|
||||
.Replace(")", "\\29", StringComparison.Ordinal)
|
||||
.Replace("\0", "\\00", StringComparison.Ordinal);
|
||||
|
||||
private static string EscapeRdn(string value) => value
|
||||
.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace(",", "\\,", StringComparison.Ordinal)
|
||||
.Replace("+", "\\+", StringComparison.Ordinal)
|
||||
.Replace("\"", "\\\"", StringComparison.Ordinal)
|
||||
.Replace("<", "\\<", StringComparison.Ordinal)
|
||||
.Replace(">", "\\>", StringComparison.Ordinal)
|
||||
.Replace(";", "\\;", StringComparison.Ordinal)
|
||||
.Replace("=", "\\=", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Net;
|
||||
using SGU.AuthBroker.Core.Authentication;
|
||||
using SGU.AuthBroker.Options;
|
||||
|
||||
namespace SGU.AuthBroker.Services;
|
||||
|
||||
public sealed class NtlmCredentialValidator(BrokerOptions options) : INtlmCredentialValidator
|
||||
{
|
||||
private readonly NtlmOptions options = options.Ntlm;
|
||||
|
||||
public async Task<NtlmValidationResult> ValidateAsync(
|
||||
string userName,
|
||||
string password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Uri current = new(this.options.Endpoint, UriKind.Absolute);
|
||||
HashSet<string> allowedHosts = new(
|
||||
this.options.AllowedRedirectHosts,
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
NetworkCredential credential = new(userName, password, this.options.Domain);
|
||||
CredentialCache credentialCache = new();
|
||||
HashSet<string> credentialedAuthorities = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
using HttpClientHandler handler = new()
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
CheckCertificateRevocationList = true,
|
||||
Credentials = credentialCache,
|
||||
MaxConnectionsPerServer = 4,
|
||||
MaxResponseHeadersLength = 64,
|
||||
PreAuthenticate = false,
|
||||
UseCookies = false,
|
||||
UseDefaultCredentials = false,
|
||||
UseProxy = false
|
||||
};
|
||||
|
||||
using HttpClient client = new(handler)
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan,
|
||||
DefaultRequestVersion = HttpVersion.Version11,
|
||||
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
|
||||
};
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("SGU-AuthBroker/1.0");
|
||||
|
||||
try
|
||||
{
|
||||
for (int hop = 0; hop <= this.options.MaxRedirects; hop++)
|
||||
{
|
||||
if (!IsAllowedHttpsUri(current, allowedHosts))
|
||||
{
|
||||
return NtlmValidationResult.Unavailable("NTLM_REDIRECT_REJECTED");
|
||||
}
|
||||
|
||||
string authority = current.GetLeftPart(UriPartial.Authority);
|
||||
if (credentialedAuthorities.Add(authority))
|
||||
{
|
||||
credentialCache.Add(new Uri(authority + "/"), "NTLM", credential);
|
||||
}
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, current);
|
||||
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(this.options.TimeoutSeconds));
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, timeout.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return NtlmValidationResult.Unavailable("NTLM_TIMEOUT");
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return NtlmValidationResult.Unavailable();
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
{
|
||||
return NtlmValidationResult.Invalid();
|
||||
}
|
||||
|
||||
int statusCode = (int)response.StatusCode;
|
||||
if (statusCode >= 500)
|
||||
{
|
||||
return NtlmValidationResult.Unavailable("NTLM_UPSTREAM_ERROR");
|
||||
}
|
||||
|
||||
if (statusCode is >= 300 and < 400)
|
||||
{
|
||||
Uri? location = response.Headers.Location;
|
||||
if (location is null)
|
||||
{
|
||||
return NtlmValidationResult.Unavailable("NTLM_INVALID_REDIRECT");
|
||||
}
|
||||
|
||||
current = location.IsAbsoluteUri ? location : new Uri(current, location);
|
||||
continue;
|
||||
}
|
||||
|
||||
return statusCode is >= 200 and < 300
|
||||
? NtlmValidationResult.Valid()
|
||||
: NtlmValidationResult.Invalid();
|
||||
}
|
||||
}
|
||||
|
||||
return NtlmValidationResult.Unavailable("NTLM_REDIRECT_LIMIT");
|
||||
}
|
||||
finally
|
||||
{
|
||||
credential.Password = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsAllowedHttpsUri(Uri uri, HashSet<string> allowedHosts) =>
|
||||
uri.Scheme == Uri.UriSchemeHttps &&
|
||||
string.IsNullOrEmpty(uri.UserInfo) &&
|
||||
allowedHosts.Contains(uri.IdnHost);
|
||||
}
|
||||
Reference in New Issue
Block a user