using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.MxGateway.Server.Configuration;
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.
///
/// Gateway options supplying MxGateway:Dashboard:GroupToTag, the map that turns the user's
/// LDAP groups into the dashboard visibility tags stamped on the cookie principal.
///
/// Logger for diagnostic, credential-free login outcomes.
public sealed class DashboardAuthenticator(
ILdapAuthService ldapAuthService,
IGroupRoleMapper roleMapper,
IOptions options,
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,
options.Value.Dashboard.GroupToTag));
}
///
/// 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 .
///
/// The configured Dashboard:GroupToTag map. The tags it grants are stamped as
/// claims so a
/// cookie-authenticated circuit carries its grant without a hub-token round-trip — the
/// session-details page's in-process subscribe seam reads exactly these claims.
///
private static ClaimsPrincipal CreatePrincipal(
string username,
string displayName,
IEnumerable groups,
IEnumerable roles,
IReadOnlyDictionary groupToTag)
{
// Materialized because the groups are read twice below (group claims and tag mapping) and
// the source is only guaranteed to be enumerable.
string[] groupNames = groups as string[] ?? [.. groups];
List claims =
[
// Keep NameIdentifier so any existing read-site that uses it continues to work.
new Claim(ClaimTypes.NameIdentifier, username),
// Canonical login-username claim.
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.
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(groupNames.Select(group => new Claim(
DashboardAuthenticationDefaults.LdapGroupClaimType,
group)));
// Dashboard event-visibility tags. Visibility only — never a data-access grant —
// and never logged: only the decision, never the tag values, reaches diagnostics.
claims.AddRange(DashboardGroupTagMapping
.MapGroupsToTags(groupNames, groupToTag)
.Select(tag => new Claim(DashboardAuthenticationDefaults.DashboardTagClaimType, tag)));
ClaimsIdentity claimsIdentity = new(
claims,
DashboardAuthenticationDefaults.AuthenticationScheme,
ZbClaimTypes.Name,
ZbClaimTypes.Role);
return new ClaimsPrincipal(claimsIdentity);
}
}