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); } }