a8aafdf974
Fixes code-review findings Server-001 (Critical) and Server-003 (High). Server-001: the dashboard Razor components were mapped with no authorization policy, so every dashboard page — including the API Keys page — was reachable unauthenticated. MapRazorComponents<App>() now requires DashboardAuthenticationDefaults.AuthorizationPolicy; unauthenticated requests are challenged by the cookie scheme and redirected to the login page. Server-003: DashboardAuthenticator.CreatePrincipal never issued the 'scope' claim that DashboardAuthorizationHandler checks when Dashboard:RequireAdminScope is enabled, so enforcing the policy would have denied every LDAP login. CreatePrincipal (reached only after the required-group check passes) now emits the admin scope claim. Replaces the GatewayApplicationTests case that asserted dashboard routes allow anonymous access — it encoded the bug as expected behavior — with tests that verify component routes require the policy and the login/logout/denied endpoints allow anonymous. All 309 MxGateway.Tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
267 lines
9.0 KiB
C#
267 lines
9.0 KiB
C#
using System.Security.Claims;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Options;
|
|
using MxGateway.Server.Configuration;
|
|
using MxGateway.Server.Security.Authorization;
|
|
using Novell.Directory.Ldap;
|
|
|
|
namespace MxGateway.Server.Dashboard;
|
|
|
|
public sealed class DashboardAuthenticator(
|
|
IOptions<GatewayOptions> options,
|
|
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
|
|
{
|
|
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
|
|
|
|
/// <inheritdoc />
|
|
public async Task<DashboardAuthenticationResult> AuthenticateAsync(
|
|
string? username,
|
|
string? password,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
LdapOptions ldapOptions = options.Value.Ldap;
|
|
if (!ldapOptions.Enabled
|
|
|| string.IsNullOrWhiteSpace(username)
|
|
|| string.IsNullOrWhiteSpace(password))
|
|
{
|
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
}
|
|
|
|
if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap)
|
|
{
|
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
}
|
|
|
|
string normalizedUsername = username.Trim();
|
|
|
|
try
|
|
{
|
|
using LdapConnection connection = new();
|
|
connection.SecureSocketLayer = ldapOptions.UseTls;
|
|
|
|
await Task.Run(
|
|
() => connection.Connect(ldapOptions.Server, ldapOptions.Port),
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
|
|
LdapEntry? candidate = await SearchUserAsync(
|
|
connection,
|
|
ldapOptions,
|
|
normalizedUsername,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (candidate is null)
|
|
{
|
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
}
|
|
|
|
await Task.Run(
|
|
() => connection.Bind(candidate.Dn, password),
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
|
|
LdapEntry? authenticatedEntry = await SearchUserAsync(
|
|
connection,
|
|
ldapOptions,
|
|
normalizedUsername,
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
if (authenticatedEntry is null)
|
|
{
|
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
}
|
|
|
|
string displayName = ReadAttribute(authenticatedEntry, ldapOptions.DisplayNameAttribute)
|
|
?? normalizedUsername;
|
|
IReadOnlyList<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute);
|
|
|
|
if (!IsMemberOfRequiredGroup(groups, ldapOptions.RequiredGroup))
|
|
{
|
|
logger.LogInformation(
|
|
"LDAP dashboard login denied for user {User}: missing required group {RequiredGroup}.",
|
|
normalizedUsername,
|
|
ldapOptions.RequiredGroup);
|
|
|
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
}
|
|
|
|
return DashboardAuthenticationResult.Success(CreatePrincipal(
|
|
normalizedUsername,
|
|
displayName,
|
|
groups));
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (LdapException ex)
|
|
{
|
|
logger.LogInformation(
|
|
"LDAP dashboard login rejected for user {User}: result code {ResultCode}.",
|
|
normalizedUsername,
|
|
ex.ResultCode);
|
|
|
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Unexpected LDAP dashboard login error for user {User}.", normalizedUsername);
|
|
|
|
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
|
}
|
|
}
|
|
|
|
internal static string EscapeLdapFilter(string value)
|
|
{
|
|
StringBuilder builder = new(value.Length);
|
|
foreach (char character in value)
|
|
{
|
|
builder.Append(character switch
|
|
{
|
|
'\\' => @"\5c",
|
|
'*' => @"\2a",
|
|
'(' => @"\28",
|
|
')' => @"\29",
|
|
'\0' => @"\00",
|
|
_ => character.ToString()
|
|
});
|
|
}
|
|
|
|
return builder.ToString();
|
|
}
|
|
|
|
internal static bool IsMemberOfRequiredGroup(IEnumerable<string> groups, string requiredGroup)
|
|
{
|
|
string normalizedRequiredGroup = requiredGroup.Trim();
|
|
if (string.IsNullOrWhiteSpace(normalizedRequiredGroup))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
foreach (string group in groups)
|
|
{
|
|
string normalizedGroup = group.Trim();
|
|
if (string.Equals(normalizedGroup, normalizedRequiredGroup, StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(
|
|
ExtractFirstRdnValue(normalizedGroup),
|
|
normalizedRequiredGroup,
|
|
StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
internal static string ExtractFirstRdnValue(string distinguishedName)
|
|
{
|
|
int equalsIndex = distinguishedName.IndexOf('=');
|
|
if (equalsIndex < 0)
|
|
{
|
|
return distinguishedName;
|
|
}
|
|
|
|
int valueStart = equalsIndex + 1;
|
|
int commaIndex = distinguishedName.IndexOf(',', valueStart);
|
|
|
|
return commaIndex > valueStart
|
|
? distinguishedName[valueStart..commaIndex]
|
|
: distinguishedName[valueStart..];
|
|
}
|
|
|
|
private static Task BindServiceAccountAsync(
|
|
LdapConnection connection,
|
|
LdapOptions ldapOptions,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
return Task.Run(
|
|
() => connection.Bind(ldapOptions.ServiceAccountDn, ldapOptions.ServiceAccountPassword),
|
|
cancellationToken);
|
|
}
|
|
|
|
private static async Task<LdapEntry?> SearchUserAsync(
|
|
LdapConnection connection,
|
|
LdapOptions ldapOptions,
|
|
string username,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
string filter = $"({ldapOptions.UserNameAttribute}={EscapeLdapFilter(username)})";
|
|
ILdapSearchResults results = await Task.Run(
|
|
() => connection.Search(
|
|
ldapOptions.SearchBase,
|
|
LdapConnection.ScopeSub,
|
|
filter,
|
|
attrs: null,
|
|
typesOnly: false),
|
|
cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
LdapEntry? entry = null;
|
|
while (results.HasMore())
|
|
{
|
|
LdapEntry next = results.Next();
|
|
if (entry is not null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
entry = next;
|
|
}
|
|
|
|
return entry;
|
|
}
|
|
|
|
private static string? ReadAttribute(LdapEntry entry, string attributeName)
|
|
{
|
|
return ReadLdapAttribute(entry, attributeName)?.StringValue;
|
|
}
|
|
|
|
private static IReadOnlyList<string> ReadAttributeValues(LdapEntry entry, string attributeName)
|
|
{
|
|
LdapAttribute? attribute = ReadLdapAttribute(entry, attributeName);
|
|
return attribute?.StringValueArray ?? [];
|
|
}
|
|
|
|
private static LdapAttribute? ReadLdapAttribute(LdapEntry entry, string attributeName)
|
|
{
|
|
return entry.GetAttribute(attributeName)
|
|
?? entry.GetAttribute(attributeName.ToLowerInvariant())
|
|
?? entry.GetAttribute(attributeName.ToUpperInvariant());
|
|
}
|
|
|
|
private static ClaimsPrincipal CreatePrincipal(
|
|
string username,
|
|
string displayName,
|
|
IEnumerable<string> groups)
|
|
{
|
|
// CreatePrincipal is reached only after IsMemberOfRequiredGroup passed,
|
|
// so the authenticated user is authorized for the dashboard. Emit the
|
|
// admin scope claim that DashboardAuthorizationHandler checks when
|
|
// Dashboard:RequireAdminScope is enabled — without it, every LDAP login
|
|
// would be denied once route-level authorization is enforced.
|
|
List<Claim> claims =
|
|
[
|
|
new Claim(ClaimTypes.NameIdentifier, username),
|
|
new Claim(ClaimTypes.Name, displayName),
|
|
new Claim(DashboardAuthenticationDefaults.ScopeClaimType, GatewayScopes.Admin)
|
|
];
|
|
|
|
claims.AddRange(groups.Select(group => new Claim(
|
|
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
|
group)));
|
|
|
|
ClaimsIdentity claimsIdentity = new(
|
|
claims,
|
|
DashboardAuthenticationDefaults.AuthenticationScheme,
|
|
ClaimTypes.Name,
|
|
DashboardAuthenticationDefaults.LdapGroupClaimType);
|
|
|
|
return new ClaimsPrincipal(claimsIdentity);
|
|
}
|
|
}
|