Files
jdescopingtool/NEW/src/JdeScoping.Infrastructure/Auth/LdapAuthService.cs
T
Joseph Doherty 26ff8d9b4f Initial commit: JDE Scoping Tool migration project
Set up repository with legacy .NET Framework 4.8 source (OLD/),
new .NET 10 Blazor solution (NEW/), OpenSpec specifications,
documentation, and project configuration.
2026-01-02 07:43:29 -05:00

243 lines
8.5 KiB
C#

using System.DirectoryServices.Protocols;
using System.Net;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Options;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace JdeScoping.Infrastructure.Auth;
/// <summary>
/// LDAP-based authentication service implementation using System.DirectoryServices.Protocols
/// </summary>
public sealed class LdapAuthService : IAuthService
{
private const string LdapLookupFormat = "(sAMAccountName={0})";
private readonly LdapOptions _options;
private readonly AuthOptions _authOptions;
private readonly ILogger<LdapAuthService> _logger;
public LdapAuthService(
IOptions<LdapOptions> options,
IOptions<AuthOptions> authOptions,
ILogger<LdapAuthService> logger)
{
_options = options.Value;
_authOptions = authOptions.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task<AuthResult> AuthenticateAsync(
string username,
string password,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return new AuthResult(false, null, "Username and password are required");
}
// Check if user is in admin bypass list
var isAdminBypass = _authOptions.AdminBypassUsers
.Any(u => string.Equals(u, username, StringComparison.OrdinalIgnoreCase));
// Try each configured LDAP server
string? lastError = null;
foreach (var serverUrl in _options.ServerUrls)
{
try
{
ct.ThrowIfCancellationRequested();
// Attempt authentication
if (!await TryBindAsync(serverUrl, username, password, ct))
{
return new AuthResult(false, null, "Incorrect username or password");
}
// Verify group membership (unless admin bypass)
if (!isAdminBypass && !string.IsNullOrEmpty(_options.GroupDn))
{
if (!await IsInGroupInternalAsync(serverUrl, username, password, _options.GroupDn, ct))
{
return new AuthResult(false, null, "User is not a member of the required security group");
}
}
// Lookup user info
var userInfo = await LookupUserAsync(serverUrl, username, password, ct);
if (userInfo is null)
{
return new AuthResult(false, null, "Failed to retrieve user information");
}
_logger.LogInformation("User {Username} authenticated successfully via {Server}", username, serverUrl);
return new AuthResult(true, userInfo, null);
}
catch (LdapException ex)
{
_logger.LogWarning(ex, "LDAP authentication failed for server {Server}", serverUrl);
lastError = ex.Message;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error during LDAP authentication for server {Server}", serverUrl);
lastError = ex.Message;
}
}
return new AuthResult(false, null, lastError ?? "Unable to connect to directory server");
}
/// <inheritdoc />
public Task<UserInfo?> GetUserInfoAsync(string username, CancellationToken ct = default)
{
// Not implemented for LDAP - user info is only available during authentication
throw new NotSupportedException("GetUserInfoAsync requires password for LDAP lookup");
}
/// <inheritdoc />
public async Task<bool> IsInGroupAsync(
string username,
string groupName,
CancellationToken ct = default)
{
// This method requires stored credentials or service account - not supported
// Group membership is checked during authentication when credentials are available
throw new NotSupportedException("IsInGroupAsync requires password for LDAP lookup. Use AuthenticateAsync instead.");
}
private async Task<bool> TryBindAsync(
string serverUrl,
string username,
string password,
CancellationToken ct)
{
using var connection = CreateConnection(serverUrl);
var credential = new NetworkCredential(username, password);
connection.Credential = credential;
connection.AuthType = AuthType.Negotiate;
try
{
await Task.Run(() => connection.Bind(), ct);
return true;
}
catch (LdapException ex) when (ex.ErrorCode == 49) // Invalid credentials
{
return false;
}
}
private async Task<bool> IsInGroupInternalAsync(
string serverUrl,
string username,
string password,
string groupDn,
CancellationToken ct)
{
using var connection = CreateConnection(serverUrl);
connection.Credential = new NetworkCredential(username, password);
connection.AuthType = AuthType.Negotiate;
await Task.Run(() => connection.Bind(), ct);
var searchRequest = new SearchRequest(
_options.SearchBase,
string.Format(LdapLookupFormat, EscapeLdapSearchFilter(username)),
SearchScope.Subtree,
"memberOf");
var response = (SearchResponse)await Task.Run(
() => connection.SendRequest(searchRequest), ct);
foreach (SearchResultEntry entry in response.Entries)
{
var memberOf = entry.Attributes["memberOf"];
if (memberOf != null)
{
foreach (var group in memberOf.GetValues(typeof(string)))
{
if (string.Equals((string)group, groupDn, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
}
return false;
}
private async Task<UserInfo?> LookupUserAsync(
string serverUrl,
string username,
string password,
CancellationToken ct)
{
using var connection = CreateConnection(serverUrl);
connection.Credential = new NetworkCredential(username, password);
connection.AuthType = AuthType.Negotiate;
await Task.Run(() => connection.Bind(), ct);
var searchRequest = new SearchRequest(
_options.SearchBase,
string.Format(LdapLookupFormat, EscapeLdapSearchFilter(username)),
SearchScope.Subtree,
"distinguishedName", "givenName", "sn", "mail", "title");
var response = (SearchResponse)await Task.Run(
() => connection.SendRequest(searchRequest), ct);
if (response.Entries.Count == 0) return null;
var entry = response.Entries[0];
return new UserInfo
{
Dn = GetAttribute(entry, "distinguishedName"),
Username = username.ToLowerInvariant(),
FirstName = GetAttribute(entry, "givenName"),
LastName = GetAttribute(entry, "sn"),
EmailAddress = GetAttribute(entry, "mail"),
Title = GetAttribute(entry, "title")
};
}
private LdapConnection CreateConnection(string serverUrl)
{
var connection = new LdapConnection(serverUrl);
connection.SessionOptions.ProtocolVersion = 3;
connection.SessionOptions.SecureSocketLayer = false;
connection.Timeout = TimeSpan.FromSeconds(_options.ConnectionTimeoutSeconds);
return connection;
}
private static string GetAttribute(SearchResultEntry entry, string name)
{
var attr = entry.Attributes[name];
return attr?.Count > 0 ? (string)attr[0] : string.Empty;
}
/// <summary>
/// Escapes special characters in LDAP search filter values
/// </summary>
private static string EscapeLdapSearchFilter(string value)
{
if (string.IsNullOrEmpty(value))
return value;
return value
.Replace("\\", "\\5c")
.Replace("*", "\\2a")
.Replace("(", "\\28")
.Replace(")", "\\29")
.Replace("\0", "\\00");
}
}