feat(auth): MxGateway dashboard adopt ZbClaimTypes + ZbCookieDefaults, keep cookie name (Task 1.5)
- 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.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -75,8 +76,15 @@ public sealed class DashboardAuthenticator(
|
||||
/// <summary>
|
||||
/// Builds the dashboard <see cref="ClaimsPrincipal"/> from the LDAP outcome.
|
||||
/// </summary>
|
||||
/// <param name="username">The (trimmed) login name → <see cref="ClaimTypes.NameIdentifier"/>.</param>
|
||||
/// <param name="displayName">The user's display name → <see cref="ClaimTypes.Name"/>.</param>
|
||||
/// <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.
|
||||
@@ -97,13 +105,21 @@ public sealed class DashboardAuthenticator(
|
||||
{
|
||||
List<Claim> claims =
|
||||
[
|
||||
// Keep NameIdentifier so any existing read-site that uses it continues to work.
|
||||
new Claim(ClaimTypes.NameIdentifier, username),
|
||||
new Claim(ClaimTypes.Name, displayName),
|
||||
// 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),
|
||||
];
|
||||
|
||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
// 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)));
|
||||
@@ -111,8 +127,8 @@ public sealed class DashboardAuthenticator(
|
||||
ClaimsIdentity claimsIdentity = new(
|
||||
claims,
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
ClaimTypes.Name,
|
||||
ClaimTypes.Role);
|
||||
ZbClaimTypes.Name,
|
||||
ZbClaimTypes.Role);
|
||||
|
||||
return new ClaimsPrincipal(claimsIdentity);
|
||||
}
|
||||
|
||||
@@ -57,23 +57,25 @@ public static class DashboardServiceCollectionExtensions
|
||||
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme, cookieOptions =>
|
||||
{
|
||||
// Hardened defaults (HttpOnly, SameSite=Strict, SecurePolicy, SlidingExpiration,
|
||||
// ExpireTimeSpan) via the shared ZbCookieDefaults.Apply. requireHttps is set to
|
||||
// its default (true / Always) here and overridden per-environment by the
|
||||
// PostConfigure below; the 8-hour idle timeout is preserved (not the 30-min default).
|
||||
ZbCookieDefaults.Apply(cookieOptions, requireHttps: true, idleTimeout: TimeSpan.FromHours(8));
|
||||
// Cookie name, path, and redirect paths are MxGateway-specific — set after Apply
|
||||
// so they are never overwritten by the shared helper (Apply intentionally skips name).
|
||||
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
|
||||
cookieOptions.Cookie.HttpOnly = true;
|
||||
cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
|
||||
// SecurePolicy is bound via PostConfigure below so it can honour
|
||||
// DashboardOptions.RequireHttpsCookie (default Always; dev HTTP
|
||||
// deployments set RequireHttpsCookie=false to use SameAsRequest).
|
||||
cookieOptions.Cookie.Path = "/";
|
||||
cookieOptions.LoginPath = "/login";
|
||||
cookieOptions.LogoutPath = "/logout";
|
||||
cookieOptions.AccessDeniedPath = "/denied";
|
||||
cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||
cookieOptions.SlidingExpiration = true;
|
||||
})
|
||||
.AddScheme<AuthenticationSchemeOptions, HubTokenAuthenticationHandler>(
|
||||
DashboardAuthenticationDefaults.HubAuthenticationScheme,
|
||||
_ => { });
|
||||
|
||||
// Honour DashboardOptions.RequireHttpsCookie (default true / Always; set false for dev
|
||||
// HTTP deployments → SameAsRequest). This overrides the Apply default above.
|
||||
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
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;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
@@ -125,6 +126,59 @@ public sealed class DashboardAuthenticatorTests
|
||||
Assert.Equal(["GwAdmin", "GwReader"], groupClaims);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task 1.5: the principal emits canonical ZbClaimTypes.Username ("zb:username") with
|
||||
/// the login username, ZbClaimTypes.Role (= ClaimTypes.Role) for each resolved role, and
|
||||
/// ZbClaimTypes.DisplayName ("zb:displayname") with the display name — while keeping
|
||||
/// ClaimTypes.NameIdentifier, ClaimTypes.Name, and mxgateway:ldap_group claims intact.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AuthenticateAsync_Success_EmitsCanonicalZbClaims()
|
||||
{
|
||||
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
||||
username: "jsmith",
|
||||
displayName: "John Smith",
|
||||
groups: ["GwAdmin", "GwReader"]));
|
||||
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
||||
"jsmith",
|
||||
"pw",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
|
||||
|
||||
// ZbClaimTypes.Username ("zb:username") carries the login username.
|
||||
Assert.Equal("jsmith", principal.FindFirstValue(ZbClaimTypes.Username));
|
||||
|
||||
// ZbClaimTypes.DisplayName ("zb:displayname") carries the display name.
|
||||
Assert.Equal("John Smith", principal.FindFirstValue(ZbClaimTypes.DisplayName));
|
||||
|
||||
// ZbClaimTypes.Role (= ClaimTypes.Role) is used for each role claim.
|
||||
IReadOnlyList<string> roleClaims = principal
|
||||
.FindAll(ZbClaimTypes.Role)
|
||||
.Select(c => c.Value)
|
||||
.OrderBy(r => r)
|
||||
.ToList();
|
||||
Assert.Contains(DashboardRoles.Admin, roleClaims);
|
||||
Assert.Contains(DashboardRoles.Viewer, roleClaims);
|
||||
|
||||
// IsInRole still works (identity built with roleType = ZbClaimTypes.Role).
|
||||
Assert.True(principal.IsInRole(DashboardRoles.Admin));
|
||||
Assert.True(principal.IsInRole(DashboardRoles.Viewer));
|
||||
|
||||
// ClaimTypes.Name still resolves as Identity.Name (nameType = ZbClaimTypes.Name = ClaimTypes.Name).
|
||||
Assert.Equal("John Smith", principal.Identity?.Name);
|
||||
|
||||
// mxgateway:ldap_group claims are preserved.
|
||||
IReadOnlyList<string> groups = principal
|
||||
.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)
|
||||
.Select(c => c.Value)
|
||||
.ToList();
|
||||
Assert.Equal(["GwAdmin", "GwReader"], groups);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the user authenticates but none of their groups map to a dashboard role,
|
||||
/// the login is denied (the long-standing "no roles matched → denied" rule).
|
||||
|
||||
Reference in New Issue
Block a user