f40409a688
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
160 lines
7.7 KiB
C#
160 lines
7.7 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Authenticates interactive dashboard logins against LDAP. The bind/search
|
|
/// mechanics are delegated to the shared <see cref="ILdapAuthService"/>
|
|
/// (<c>ZB.MOM.WW.Auth.Ldap</c>), 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 <see cref="IGroupRoleMapper{TRole}"/>, a login with no
|
|
/// matching role is denied, and the resulting <see cref="ClaimsPrincipal"/> is
|
|
/// shaped exactly as before (see <see cref="CreatePrincipal"/>).
|
|
/// </summary>
|
|
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param>
|
|
/// <param name="roleMapper">Maps LDAP groups to dashboard roles.</param>
|
|
/// <param name="options">
|
|
/// Gateway options supplying <c>MxGateway:Dashboard:GroupToTag</c>, the map that turns the user's
|
|
/// LDAP groups into the dashboard visibility tags stamped on the cookie principal.
|
|
/// </param>
|
|
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
|
|
public sealed class DashboardAuthenticator(
|
|
ILdapAuthService ldapAuthService,
|
|
IGroupRoleMapper<string> roleMapper,
|
|
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)
|
|
{
|
|
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<string> mapping = await roleMapper
|
|
.MapAsync(ldapResult.Groups, cancellationToken)
|
|
.ConfigureAwait(false);
|
|
|
|
IReadOnlyList<string> 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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the dashboard <see cref="ClaimsPrincipal"/> from the LDAP outcome.
|
|
/// </summary>
|
|
/// <param name="username">
|
|
/// The (trimmed) login name. Emitted as <see cref="ClaimTypes.NameIdentifier"/> (kept for
|
|
/// back-compat reads) and as the canonical <see cref="ZbClaimTypes.Username"/> ("zb:username").
|
|
/// </param>
|
|
/// <param name="displayName">
|
|
/// The user's display name. Emitted as <see cref="ZbClaimTypes.Name"/> (= <see cref="ClaimTypes.Name"/>
|
|
/// so <c>Identity.Name</c> resolves) and as <see cref="ZbClaimTypes.DisplayName"/> ("zb:displayname")
|
|
/// for cross-app consistency.
|
|
/// </param>
|
|
/// <param name="groups">
|
|
/// The user's LDAP groups, as returned by <see cref="ILdapAuthService"/>. NOTE
|
|
/// (review C1): these are <b>already-normalized short RDN names</b> (e.g.
|
|
/// <c>GwAdmin</c>), not raw distinguished names. The shared
|
|
/// <c>ZB.MOM.WW.Auth.Ldap</c> provider strips each group DN to its first RDN
|
|
/// value before returning it, so the <see cref="DashboardAuthenticationDefaults.LdapGroupClaimType"/>
|
|
/// claim carries the short name. This differs from the pre-cutover behaviour,
|
|
/// which surfaced the raw <c>memberOf</c> 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.
|
|
/// </param>
|
|
/// <param name="roles">The dashboard roles resolved from <paramref name="groups"/>.</param>
|
|
/// <param name="groupToTag">
|
|
/// The configured <c>Dashboard:GroupToTag</c> map. The tags it grants are stamped as
|
|
/// <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> 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.
|
|
/// </param>
|
|
private static ClaimsPrincipal CreatePrincipal(
|
|
string username,
|
|
string displayName,
|
|
IEnumerable<string> groups,
|
|
IEnumerable<string> roles,
|
|
IReadOnlyDictionary<string, string[]> 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<Claim> 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);
|
|
}
|
|
}
|