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.
311 lines
13 KiB
C#
311 lines
13 KiB
C#
using System.Security.Claims;
|
|
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;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
|
|
|
/// <summary>
|
|
/// Parity tests for <see cref="DashboardAuthenticator"/> after the cutover to the
|
|
/// shared <see cref="ILdapAuthService"/>. The library now owns connect/bind/search
|
|
/// (covered by its own tests); these tests fake the shared service to return known
|
|
/// groups and assert the dashboard-specific policy is unchanged: groups → roles via
|
|
/// <see cref="IGroupRoleMapper{TRole}"/>, no-roles-matched is denied, and the
|
|
/// resulting principal/claims keep their exact shape.
|
|
/// </summary>
|
|
public sealed class DashboardAuthenticatorTests
|
|
{
|
|
/// <summary>A blank username is rejected without touching the LDAP provider.</summary>
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public async Task AuthenticateAsync_BlankUsername_FailsWithoutCallingLdap(string? username)
|
|
{
|
|
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
|
|
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
|
username,
|
|
"password",
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Null(result.Principal);
|
|
Assert.False(ldap.WasCalled);
|
|
}
|
|
|
|
/// <summary>A blank password is rejected without touching the LDAP provider.</summary>
|
|
[Fact]
|
|
public async Task AuthenticateAsync_BlankPassword_FailsWithoutCallingLdap()
|
|
{
|
|
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
|
|
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
|
"admin",
|
|
" ",
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Null(result.Principal);
|
|
Assert.False(ldap.WasCalled);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A failed LDAP outcome (any failure bucket, including <see cref="LdapAuthFailure.Disabled"/>)
|
|
/// maps to the generic dashboard failure without leaking the raw password.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData(LdapAuthFailure.Disabled)]
|
|
[InlineData(LdapAuthFailure.BadCredentials)]
|
|
[InlineData(LdapAuthFailure.UserNotFound)]
|
|
[InlineData(LdapAuthFailure.ServiceAccountBindFailed)]
|
|
public async Task AuthenticateAsync_LdapFailure_ReturnsFailureWithoutRawCredentials(
|
|
LdapAuthFailure failure)
|
|
{
|
|
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(failure));
|
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
|
|
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
|
"admin",
|
|
"admin123",
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Null(result.Principal);
|
|
Assert.DoesNotContain("admin123", result.FailureMessage, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// On success the principal carries the resolved roles, the LDAP-group claims
|
|
/// (short RDN names as returned by <see cref="ILdapAuthService"/>), the display
|
|
/// name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier), under the
|
|
/// dashboard authentication scheme.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims()
|
|
{
|
|
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
|
username: "admin",
|
|
displayName: "Administrator",
|
|
groups: ["GwAdmin", "GwReader"]));
|
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
|
|
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
|
"admin",
|
|
"admin123",
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
|
|
|
|
Assert.Equal("admin", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
|
Assert.Equal("Administrator", principal.FindFirst(ClaimTypes.Name)?.Value);
|
|
|
|
// Identity is built with the dashboard scheme and Name/Role claim types so
|
|
// IsInRole and Identity.Name keep working.
|
|
ClaimsIdentity identity = Assert.IsType<ClaimsIdentity>(principal.Identity);
|
|
Assert.Equal(DashboardAuthenticationDefaults.AuthenticationScheme, identity.AuthenticationType);
|
|
Assert.Equal("Administrator", identity.Name);
|
|
|
|
// Both groups mapped → both roles present.
|
|
Assert.True(principal.IsInRole(DashboardRoles.Admin));
|
|
Assert.True(principal.IsInRole(DashboardRoles.Viewer));
|
|
|
|
// LDAP groups (already short RDN names from the service) are surfaced under
|
|
// the dedicated group claim type.
|
|
IReadOnlyList<string> groupClaims = principal.FindAll(
|
|
DashboardAuthenticationDefaults.LdapGroupClaimType)
|
|
.Select(claim => claim.Value)
|
|
.ToList();
|
|
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).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AuthenticateAsync_NoRolesMatched_DeniesLogin()
|
|
{
|
|
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
|
username: "nobody",
|
|
displayName: "No Body",
|
|
groups: ["SomeUnmappedGroup"]));
|
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
|
|
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
|
"nobody",
|
|
"pw",
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Null(result.Principal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Direct-injection path only (review C1): when an <see cref="ILdapAuthService"/>
|
|
/// implementation hands the authenticator a full distinguished name as a group, the
|
|
/// mapper's leading-RDN fallback still resolves the role, and whatever string the
|
|
/// service supplied is surfaced verbatim on the group claim.
|
|
/// <para>
|
|
/// This is NOT the real production flow. The shared <c>ZB.MOM.WW.Auth.Ldap</c>
|
|
/// provider strips each group DN to its short RDN name before returning it, so the
|
|
/// authenticator never receives a full DN from the real library and the group claim
|
|
/// in production carries the short name (e.g. <c>GwAdmin</c>), not the DN. This test
|
|
/// uses a fake service to exercise only the authenticator's own pass-through of group
|
|
/// values; see <see cref="AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims"/>
|
|
/// for the realistic (already-short) group shape.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task AuthenticateAsync_GroupAsDistinguishedNameFromService_ResolvesRoleAndSurfacesServiceValue()
|
|
{
|
|
const string groupDn = "ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local";
|
|
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
|
username: "admin",
|
|
displayName: "admin",
|
|
groups: [groupDn]));
|
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
|
|
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
|
"admin",
|
|
"admin123",
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
|
|
// Role resolves via the leading-RDN fallback in DashboardGroupRoleMapping.
|
|
Assert.True(principal.IsInRole(DashboardRoles.Admin));
|
|
// The authenticator surfaces the value the (fake) service returned, verbatim.
|
|
// With the real library this value would already be the short RDN ("GwAdmin").
|
|
Assert.Contains(
|
|
principal.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType),
|
|
claim => claim.Value == groupDn);
|
|
}
|
|
|
|
/// <summary>The (already-trimmed) username from the LDAP result flows onto the principal.</summary>
|
|
[Fact]
|
|
public async Task AuthenticateAsync_UsesUsernameFromLdapResult()
|
|
{
|
|
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
|
username: "canonical",
|
|
displayName: "Canonical User",
|
|
groups: ["GwReader"]));
|
|
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
|
|
|
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
|
" canonical ",
|
|
"pw",
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
|
|
Assert.Equal("canonical", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
|
// The authenticator trims before delegating, so the provider sees the canonical value.
|
|
Assert.Equal("canonical", ldap.LastUsername);
|
|
}
|
|
|
|
private static DashboardAuthenticator CreateAuthenticator(
|
|
ILdapAuthService ldapAuthService,
|
|
Dictionary<string, string> groupToRole)
|
|
{
|
|
GatewayOptions options = new()
|
|
{
|
|
Dashboard = new DashboardOptions
|
|
{
|
|
GroupToRole = groupToRole,
|
|
},
|
|
};
|
|
|
|
IGroupRoleMapper<string> roleMapper = new DashboardGroupRoleMapper(Options.Create(options));
|
|
|
|
return new DashboardAuthenticator(
|
|
ldapAuthService,
|
|
roleMapper,
|
|
NullLogger<DashboardAuthenticator>.Instance);
|
|
}
|
|
|
|
private static Dictionary<string, string> StandardMapping() => new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["GwAdmin"] = DashboardRoles.Admin,
|
|
["GwReader"] = DashboardRoles.Viewer,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Fake <see cref="ILdapAuthService"/> returning a fixed result, recording whether it was
|
|
/// invoked and with what username so the authenticator's pre-checks and trimming can be asserted.
|
|
/// </summary>
|
|
private sealed class FakeLdapAuthService(LdapAuthResult result) : ILdapAuthService
|
|
{
|
|
public bool WasCalled { get; private set; }
|
|
|
|
public string? LastUsername { get; private set; }
|
|
|
|
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
|
|
{
|
|
WasCalled = true;
|
|
LastUsername = username;
|
|
return Task.FromResult(result);
|
|
}
|
|
}
|
|
}
|