7e1af37eb1
- DashboardAuthenticator.CreatePrincipal: emit ZbClaimTypes.Username ("zb:username") with
the login username, ZbClaimTypes.DisplayName ("zb:displayname") with the display name,
ZbClaimTypes.Name (== ClaimTypes.Name) for Identity.Name resolution, ZbClaimTypes.Role
(== ClaimTypes.Role) for IsInRole/[Authorize]. Keep ClaimTypes.NameIdentifier for back-compat
read-sites; keep mxgateway:ldap_group unchanged (MxGateway-specific, no ZbClaimType for groups).
ClaimsIdentity built with nameType=ZbClaimTypes.Name, roleType=ZbClaimTypes.Role.
- DashboardServiceCollectionExtensions.AddGatewayDashboard: route cookie hardening through
ZbCookieDefaults.Apply(requireHttps:true, idleTimeout:8h); set cookie name/path/redirects
after Apply; PostConfigure still overrides SecurePolicy per RequireHttpsCookie setting.
- DashboardAuthenticatorTests: add AuthenticateAsync_Success_EmitsCanonicalZbClaims asserting
zb:username, zb:displayname, ZbClaimTypes.Role per role, Identity.Name, and ldap_group preserved.
136 lines
6.3 KiB
C#
136 lines
6.3 KiB
C#
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;
|
|
|
|
/// <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 (Task 1.1 seam).</param>
|
|
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
|
|
public sealed class DashboardAuthenticator(
|
|
ILdapAuthService ldapAuthService,
|
|
IGroupRoleMapper<string> roleMapper,
|
|
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));
|
|
}
|
|
|
|
/// <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>
|
|
private static ClaimsPrincipal CreatePrincipal(
|
|
string username,
|
|
string displayName,
|
|
IEnumerable<string> groups,
|
|
IEnumerable<string> roles)
|
|
{
|
|
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 (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);
|
|
}
|
|
}
|