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 System.Security.Claims;
|
||||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
|
using ZB.MOM.WW.Auth.AspNetCore;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
@@ -75,8 +76,15 @@ public sealed class DashboardAuthenticator(
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the dashboard <see cref="ClaimsPrincipal"/> from the LDAP outcome.
|
/// Builds the dashboard <see cref="ClaimsPrincipal"/> from the LDAP outcome.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="username">The (trimmed) login name → <see cref="ClaimTypes.NameIdentifier"/>.</param>
|
/// <param name="username">
|
||||||
/// <param name="displayName">The user's display name → <see cref="ClaimTypes.Name"/>.</param>
|
/// 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">
|
/// <param name="groups">
|
||||||
/// The user's LDAP groups, as returned by <see cref="ILdapAuthService"/>. NOTE
|
/// 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.
|
/// (review C1): these are <b>already-normalized short RDN names</b> (e.g.
|
||||||
@@ -97,13 +105,21 @@ public sealed class DashboardAuthenticator(
|
|||||||
{
|
{
|
||||||
List<Claim> claims =
|
List<Claim> claims =
|
||||||
[
|
[
|
||||||
|
// Keep NameIdentifier so any existing read-site that uses it continues to work.
|
||||||
new Claim(ClaimTypes.NameIdentifier, username),
|
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
|
// Groups are short RDN names from ILdapAuthService (see param doc above), so
|
||||||
// this claim value is the short group name, not the original DN.
|
// 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(
|
claims.AddRange(groups.Select(group => new Claim(
|
||||||
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
||||||
group)));
|
group)));
|
||||||
@@ -111,8 +127,8 @@ public sealed class DashboardAuthenticator(
|
|||||||
ClaimsIdentity claimsIdentity = new(
|
ClaimsIdentity claimsIdentity = new(
|
||||||
claims,
|
claims,
|
||||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||||
ClaimTypes.Name,
|
ZbClaimTypes.Name,
|
||||||
ClaimTypes.Role);
|
ZbClaimTypes.Role);
|
||||||
|
|
||||||
return new ClaimsPrincipal(claimsIdentity);
|
return new ClaimsPrincipal(claimsIdentity);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,23 +57,25 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
|
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme, cookieOptions =>
|
.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.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.Cookie.Path = "/";
|
||||||
cookieOptions.LoginPath = "/login";
|
cookieOptions.LoginPath = "/login";
|
||||||
cookieOptions.LogoutPath = "/logout";
|
cookieOptions.LogoutPath = "/logout";
|
||||||
cookieOptions.AccessDeniedPath = "/denied";
|
cookieOptions.AccessDeniedPath = "/denied";
|
||||||
cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8);
|
|
||||||
cookieOptions.SlidingExpiration = true;
|
|
||||||
})
|
})
|
||||||
.AddScheme<AuthenticationSchemeOptions, HubTokenAuthenticationHandler>(
|
.AddScheme<AuthenticationSchemeOptions, HubTokenAuthenticationHandler>(
|
||||||
DashboardAuthenticationDefaults.HubAuthenticationScheme,
|
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)
|
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
|
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
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.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
|
|
||||||
@@ -125,6 +126,59 @@ public sealed class DashboardAuthenticatorTests
|
|||||||
Assert.Equal(["GwAdmin", "GwReader"], groupClaims);
|
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>
|
/// <summary>
|
||||||
/// When the user authenticates but none of their groups map to a dashboard role,
|
/// 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).
|
/// the login is denied (the long-standing "no roles matched → denied" rule).
|
||||||
|
|||||||
Reference in New Issue
Block a user