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