using System.Security.Claims;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Auth.AspNetCore;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
///
/// Authenticates interactive dashboard logins against LDAP. The bind/search
/// mechanics are delegated to the shared
/// (ZB.MOM.WW.Auth.Ldap), which performs bind-then-search, fails closed,
/// and never throws — returning the user's display name and LDAP groups on
/// success. This class keeps the dashboard-specific policy: groups are resolved
/// to dashboard roles via , a login with no
/// matching role is denied, and the resulting is
/// shaped exactly as before (see ).
///
/// Shared LDAP bind-then-search provider.
/// Maps LDAP groups to dashboard roles (Task 1.1 seam).
/// Logger for diagnostic, credential-free login outcomes.
public sealed class DashboardAuthenticator(
ILdapAuthService ldapAuthService,
IGroupRoleMapper roleMapper,
ILogger logger) : IDashboardAuthenticator
{
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
///
public async Task AuthenticateAsync(
string? username,
string? password,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
string normalizedUsername = username.Trim();
// The shared service owns connect/bind/search and the fail-closed contract:
// it returns Fail(Disabled) when LDAP is off, enforces TLS-or-AllowInsecure via
// its startup validator, and never throws. We only translate its outcome into a
// dashboard principal here.
LdapAuthResult ldapResult = await ldapAuthService
.AuthenticateAsync(normalizedUsername, password, cancellationToken)
.ConfigureAwait(false);
if (!ldapResult.Succeeded)
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
GroupRoleMapping mapping = await roleMapper
.MapAsync(ldapResult.Groups, cancellationToken)
.ConfigureAwait(false);
IReadOnlyList roles = mapping.Roles;
if (roles.Count == 0)
{
// Preserve the long-standing "no roles matched -> login denied" rule.
logger.LogInformation(
"LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
ldapResult.Username);
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
return DashboardAuthenticationResult.Success(CreatePrincipal(
ldapResult.Username,
ldapResult.DisplayName,
ldapResult.Groups,
roles));
}
///
/// Builds the dashboard from the LDAP outcome.
///
///
/// The (trimmed) login name. Emitted as (kept for
/// back-compat reads) and as the canonical ("zb:username").
///
///
/// The user's display name. Emitted as (=
/// so Identity.Name resolves) and as ("zb:displayname")
/// for cross-app consistency.
///
///
/// The user's LDAP groups, as returned by . NOTE
/// (review C1): these are already-normalized short RDN names (e.g.
/// GwAdmin), not raw distinguished names. The shared
/// ZB.MOM.WW.Auth.Ldap provider strips each group DN to its first RDN
/// value before returning it, so the
/// claim carries the short name. This differs from the pre-cutover behaviour,
/// which surfaced the raw memberOf values (full DNs) on the claim; the
/// claim is informational only (no policy or UI reads its value — authorization
/// is role-based), so the shape change is non-breaking for dashboard consumers.
///
/// The dashboard roles resolved from .
private static ClaimsPrincipal CreatePrincipal(
string username,
string displayName,
IEnumerable groups,
IEnumerable roles)
{
List claims =
[
// Keep NameIdentifier so any existing read-site that uses it continues to work.
new Claim(ClaimTypes.NameIdentifier, username),
// Canonical login-username claim (Task 1.5).
new Claim(ZbClaimTypes.Username, username),
// ZbClaimTypes.Name == ClaimTypes.Name — drives Identity.Name resolution.
new Claim(ZbClaimTypes.Name, displayName),
// Canonical display-name claim for cross-app consistency (Task 1.5).
new Claim(ZbClaimTypes.DisplayName, displayName),
];
// ZbClaimTypes.Role == ClaimTypes.Role — drives IsInRole and [Authorize(Roles=...)].
claims.AddRange(roles.Select(role => new Claim(ZbClaimTypes.Role, role)));
// Groups are short RDN names from ILdapAuthService (see param doc above), so
// this claim value is the short group name, not the original DN.
// LdapGroupClaimType is MxGateway-specific ("mxgateway:ldap_group") — no ZbClaimType for groups.
claims.AddRange(groups.Select(group => new Claim(
DashboardAuthenticationDefaults.LdapGroupClaimType,
group)));
ClaimsIdentity claimsIdentity = new(
claims,
DashboardAuthenticationDefaults.AuthenticationScheme,
ZbClaimTypes.Name,
ZbClaimTypes.Role);
return new ClaimsPrincipal(claimsIdentity);
}
}