15752f8c2d
The auth cookie name was hardcoded to ZB.MOM.WW.ScadaBridge.Auth. Because browser cookies are scoped by host+path but NOT by port, two ScadaBridge clusters on the same host (the local docker stack on localhost:9000 and docker-env2 on localhost:9100) shared one cookie jar: signing into one overwrote the other's cookie, and since the clusters use different JWT signing keys + separate Data Protection key rings, the overwritten side could no longer validate its cookie and the session died. Add SecurityOptions.CookieName (default = canonical ZB.MOM.WW.ScadaBridge.Auth, blank falls back to the default) applied via the SecurityOptions-bound cookie PostConfigure. Override it to ...Auth.env2 in both docker-env2 Central nodes so the two local clusters no longer collide; the primary cluster keeps the default so its live sessions and production are unaffected. Adds 3 Security.Tests cases.
1430 lines
60 KiB
C#
1430 lines
60 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
|
using ZB.MOM.WW.Auth.AspNetCore;
|
|
using ZB.MOM.WW.Auth.Ldap;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
|
|
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Security.Tests;
|
|
|
|
#region WP-6: LdapAuthService Tests (re-aimed at the shared ZB.MOM.WW.Auth.Ldap service)
|
|
|
|
// Task 1.2 cutover: ScadaBridge's bespoke LdapAuthService was replaced by the shared
|
|
// ZB.MOM.WW.Auth.Ldap.LdapAuthService (ScadaBridge was the donor for its hardened
|
|
// bind-then-search / RFC-4514+4515 escaping / fail-closed / service-account-bind-
|
|
// distinction / per-op-timeout semantics). The deep hygiene parity now lives in the
|
|
// LIBRARY's own test suite (ZB.MOM.WW.Auth.Ldap.Tests: LdapAuthServiceTests,
|
|
// LdapAuthServiceFailureTests, LdapEscapingTests), which exercises those paths through
|
|
// the library's internal ILdapConnection seam. Re-aiming them here would require that
|
|
// internal seam, which ScadaBridge cannot reach. The tests below instead pin the
|
|
// LIBRARY service's public, network-free surface and ScadaBridge's adopter wiring.
|
|
public class LdapAuthServiceTests
|
|
{
|
|
private static LdapOptions CreateOptions(LdapTransport transport = LdapTransport.Ldaps, bool allowInsecure = false) => new()
|
|
{
|
|
Enabled = true,
|
|
Server = "ldap.example.com",
|
|
Port = 636,
|
|
Transport = transport,
|
|
AllowInsecure = allowInsecure,
|
|
SearchBase = "dc=example,dc=com",
|
|
ServiceAccountDn = "cn=admin,dc=example,dc=com",
|
|
};
|
|
|
|
[Fact]
|
|
public async Task AuthenticateAsync_Disabled_FailsWithDisabled_NoNetwork()
|
|
{
|
|
var options = CreateOptions() with { Enabled = false };
|
|
var service = new LdapAuthService(options);
|
|
|
|
var result = await service.AuthenticateAsync("user", "password", CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(LdapAuthFailure.Disabled, result.Failure);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public async Task AuthenticateAsync_EmptyUsername_FailsBadCredentials_NoNetwork(string username)
|
|
{
|
|
// An empty/whitespace username is rejected before any connection is attempted,
|
|
// mapped to the enumeration-safe BadCredentials bucket (no "user is required"
|
|
// string leak). This is the donor's empty-username guard, preserved.
|
|
var service = new LdapAuthService(CreateOptions());
|
|
|
|
var result = await service.AuthenticateAsync(username, "password", CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AuthenticateAsync_ConnectionFailure_FailsClosed_NeverThrows()
|
|
{
|
|
// Point at a non-existent server: the library fails closed (never throws) and
|
|
// maps the unreachable directory to the system-side ServiceAccountBindFailed
|
|
// bucket — preserving the donor's "directory unavailable ⇒ login fails" rule.
|
|
var options = CreateOptions(LdapTransport.None, allowInsecure: true) with
|
|
{
|
|
Server = "nonexistent.invalid",
|
|
Port = 9999,
|
|
ConnectionTimeoutMs = 2_000,
|
|
};
|
|
var service = new LdapAuthService(options);
|
|
|
|
var result = await service.AuthenticateAsync("user", "password", CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region WP-7: JwtTokenService Tests
|
|
|
|
public class JwtTokenServiceTests
|
|
{
|
|
private static SecurityOptions CreateOptions() => new()
|
|
{
|
|
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
|
|
JwtExpiryMinutes = 15,
|
|
IdleTimeoutMinutes = 30,
|
|
JwtRefreshThresholdMinutes = 5
|
|
};
|
|
|
|
private static JwtTokenService CreateService(SecurityOptions? options = null)
|
|
{
|
|
return new JwtTokenService(
|
|
Options.Create(options ?? CreateOptions()),
|
|
NullLogger<JwtTokenService>.Instance);
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateToken_ContainsCorrectClaims()
|
|
{
|
|
var service = CreateService();
|
|
var token = service.GenerateToken(
|
|
"John Doe", "johnd",
|
|
new[] { "Administrator", "Designer" },
|
|
new[] { "1", "2" });
|
|
|
|
var principal = service.ValidateToken(token);
|
|
Assert.NotNull(principal);
|
|
|
|
Assert.Equal("John Doe", principal!.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value);
|
|
Assert.Equal("johnd", principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value);
|
|
|
|
var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
|
|
Assert.Contains("Administrator", roles);
|
|
Assert.Contains("Designer", roles);
|
|
|
|
var siteIds = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToList();
|
|
Assert.Contains("1", siteIds);
|
|
Assert.Contains("2", siteIds);
|
|
|
|
Assert.NotNull(principal.FindFirst(JwtTokenService.LastActivityClaimType));
|
|
}
|
|
|
|
[Fact]
|
|
public void GenerateToken_NullSiteIds_NoSiteIdClaims()
|
|
{
|
|
var service = CreateService();
|
|
var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
|
|
var principal = service.ValidateToken(token);
|
|
|
|
Assert.NotNull(principal);
|
|
Assert.Empty(principal!.FindAll(JwtTokenService.SiteIdClaimType));
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateToken_InvalidToken_ReturnsNull()
|
|
{
|
|
var service = CreateService();
|
|
var result = service.ValidateToken("invalid.token.here");
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateToken_WrongKey_ReturnsNull()
|
|
{
|
|
var service1 = CreateService();
|
|
var token = service1.GenerateToken("User", "user", new[] { "Administrator" }, null);
|
|
|
|
var service2 = CreateService(new SecurityOptions
|
|
{
|
|
JwtSigningKey = "a-completely-different-signing-key-for-hmac-sha256-validation",
|
|
JwtExpiryMinutes = 15,
|
|
IdleTimeoutMinutes = 30,
|
|
JwtRefreshThresholdMinutes = 5
|
|
});
|
|
var result = service2.ValidateToken(token);
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateToken_UsesHmacSha256()
|
|
{
|
|
var service = CreateService();
|
|
var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
|
|
|
|
// Decode header to verify algorithm
|
|
var parts = token.Split('.');
|
|
var headerJson = System.Text.Encoding.UTF8.GetString(
|
|
Convert.FromBase64String(parts[0].PadRight((parts[0].Length + 3) & ~3, '=')));
|
|
Assert.Contains("HS256", headerJson);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShouldRefresh_TokenNearExpiry_ReturnsTrue()
|
|
{
|
|
var options = CreateOptions();
|
|
options.JwtExpiryMinutes = 3; // Token expires in 3 min, threshold is 5 min
|
|
var service = CreateService(options);
|
|
|
|
var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
|
|
var principal = service.ValidateToken(token);
|
|
|
|
Assert.True(service.ShouldRefresh(principal!));
|
|
}
|
|
|
|
[Fact]
|
|
public void ShouldRefresh_TokenFarFromExpiry_ReturnsFalse()
|
|
{
|
|
var service = CreateService(); // 15 min expiry, 5 min threshold
|
|
var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
|
|
var principal = service.ValidateToken(token);
|
|
|
|
Assert.False(service.ShouldRefresh(principal!));
|
|
}
|
|
|
|
[Fact]
|
|
public void IsIdleTimedOut_RecentActivity_ReturnsFalse()
|
|
{
|
|
var service = CreateService();
|
|
var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
|
|
var principal = service.ValidateToken(token);
|
|
|
|
Assert.False(service.IsIdleTimedOut(principal!));
|
|
}
|
|
|
|
[Fact]
|
|
public void IsIdleTimedOut_NoLastActivityClaim_ReturnsTrue()
|
|
{
|
|
var service = CreateService();
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.DisplayNameClaimType, "User")
|
|
}));
|
|
|
|
Assert.True(service.IsIdleTimedOut(principal));
|
|
}
|
|
|
|
[Fact]
|
|
public void RefreshToken_ReturnsNewTokenWithUpdatedClaims()
|
|
{
|
|
var service = CreateService();
|
|
var originalToken = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
|
|
var principal = service.ValidateToken(originalToken);
|
|
|
|
var newToken = service.RefreshToken(principal!, new[] { "Administrator", "Designer" }, new[] { "1" });
|
|
Assert.NotNull(newToken);
|
|
|
|
var newPrincipal = service.ValidateToken(newToken!);
|
|
var roles = newPrincipal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
|
|
Assert.Contains("Designer", roles);
|
|
}
|
|
|
|
[Fact]
|
|
public void RefreshToken_MissingClaims_ReturnsNull()
|
|
{
|
|
var service = CreateService();
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity());
|
|
|
|
var result = service.RefreshToken(principal, new[] { "Administrator" }, null);
|
|
Assert.Null(result);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region WP-8: RoleMapper Tests
|
|
|
|
public class RoleMapperTests : IDisposable
|
|
{
|
|
private readonly ScadaBridgeDbContext _context;
|
|
private readonly SecurityRepository _securityRepo;
|
|
private readonly RoleMapper _roleMapper;
|
|
|
|
public RoleMapperTests()
|
|
{
|
|
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
|
|
.UseSqlite("DataSource=:memory:")
|
|
.Options;
|
|
|
|
_context = new ScadaBridgeDbContext(options);
|
|
_context.Database.OpenConnection();
|
|
_context.Database.EnsureCreated();
|
|
_securityRepo = new SecurityRepository(_context);
|
|
_roleMapper = new RoleMapper(_securityRepo);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_context.Database.CloseConnection();
|
|
_context.Dispose();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MapGroupsToRoles_MultiRoleExtraction()
|
|
{
|
|
// Add mappings (note: seed data adds SCADA-Admins -> Administrator)
|
|
_context.LdapGroupMappings.Add(new LdapGroupMapping("Designers", Roles.Designer));
|
|
_context.LdapGroupMappings.Add(new LdapGroupMapping("Deployers", Roles.Deployer));
|
|
await _context.SaveChangesAsync();
|
|
|
|
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Admins", "Designers" });
|
|
|
|
Assert.Contains(Roles.Administrator, result.Roles);
|
|
Assert.Contains(Roles.Designer, result.Roles);
|
|
Assert.DoesNotContain(Roles.Deployer, result.Roles);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MapGroupsToRoles_SiteScopedDeployment()
|
|
{
|
|
var site1 = new Site("Site1", "S-001");
|
|
var site2 = new Site("Site2", "S-002");
|
|
_context.Sites.AddRange(site1, site2);
|
|
_context.LdapGroupMappings.Add(new LdapGroupMapping("SiteDeployers", Roles.Deployer));
|
|
await _context.SaveChangesAsync();
|
|
|
|
var mapping = await _context.LdapGroupMappings.SingleAsync(m => m.LdapGroupName == "SiteDeployers");
|
|
_context.SiteScopeRules.AddRange(
|
|
new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site1.Id },
|
|
new SiteScopeRule { LdapGroupMappingId = mapping.Id, SiteId = site2.Id });
|
|
await _context.SaveChangesAsync();
|
|
|
|
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SiteDeployers" });
|
|
|
|
Assert.Contains(Roles.Deployer, result.Roles);
|
|
Assert.False(result.IsSystemWideDeployment);
|
|
Assert.Contains(site1.Id.ToString(), result.PermittedSiteIds);
|
|
Assert.Contains(site2.Id.ToString(), result.PermittedSiteIds);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MapGroupsToRoles_UserInBothSystemWideAndScopedDeploymentGroup_IsSystemWide()
|
|
{
|
|
// Security-016: a user in BOTH an unscoped Deployer mapping
|
|
// (SCADA-Deploy-All, Id=3) AND a scoped Deployer mapping
|
|
// (SCADA-Deploy-SiteA, Id=4) used to be silently narrowed to the site-A
|
|
// grant. The union semantics now preserve the broader grant: the
|
|
// unscoped mapping wins, PermittedSiteIds is empty, system-wide.
|
|
var siteA = new Site("SiteA", "S-A");
|
|
_context.Sites.Add(siteA);
|
|
await _context.SaveChangesAsync();
|
|
|
|
// Mapping Id=4 (SCADA-Deploy-SiteA) is seeded; attach a scope rule for siteA.
|
|
_context.SiteScopeRules.Add(new SiteScopeRule { LdapGroupMappingId = 4, SiteId = siteA.Id });
|
|
await _context.SaveChangesAsync();
|
|
|
|
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "SCADA-Deploy-All", "SCADA-Deploy-SiteA" });
|
|
|
|
Assert.Contains(Roles.Deployer, result.Roles);
|
|
Assert.True(result.IsSystemWideDeployment);
|
|
Assert.Empty(result.PermittedSiteIds);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MapGroupsToRoles_SystemWideDeployment_NoScopeRules()
|
|
{
|
|
_context.LdapGroupMappings.Add(new LdapGroupMapping("GlobalDeployers", Roles.Deployer));
|
|
await _context.SaveChangesAsync();
|
|
|
|
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "GlobalDeployers" });
|
|
|
|
Assert.Contains(Roles.Deployer, result.Roles);
|
|
Assert.True(result.IsSystemWideDeployment);
|
|
Assert.Empty(result.PermittedSiteIds);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MapGroupsToRoles_UnrecognizedGroups_Ignored()
|
|
{
|
|
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "NonExistentGroup", "AnotherRandom" });
|
|
|
|
Assert.Empty(result.Roles);
|
|
Assert.Empty(result.PermittedSiteIds);
|
|
Assert.False(result.IsSystemWideDeployment);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MapGroupsToRoles_NoMatchingGroups_NoRoles()
|
|
{
|
|
var result = await _roleMapper.MapGroupsToRolesAsync(Array.Empty<string>());
|
|
|
|
Assert.Empty(result.Roles);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MapGroupsToRoles_CaseInsensitiveGroupMatch()
|
|
{
|
|
// "SCADA-Admins" is seeded (role canonicalized to Administrator, Task 1.7)
|
|
var result = await _roleMapper.MapGroupsToRolesAsync(new[] { "scada-admins" });
|
|
|
|
Assert.Contains(Roles.Administrator, result.Roles);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Code Review Regression Tests
|
|
|
|
/// <summary>
|
|
/// Regression tests for Security-001 (StartTLS dead code), Security-002 (cookie not
|
|
/// marked Secure), and Security-003 (JWT signing key length never validated).
|
|
/// </summary>
|
|
public class SecurityReviewRegressionTests
|
|
{
|
|
// --- Security-003: JWT signing key length validation ---
|
|
|
|
private static SecurityOptions JwtOptions(string signingKey) => new()
|
|
{
|
|
JwtSigningKey = signingKey,
|
|
JwtExpiryMinutes = 15,
|
|
IdleTimeoutMinutes = 30,
|
|
JwtRefreshThresholdMinutes = 5
|
|
};
|
|
|
|
[Fact]
|
|
public void JwtTokenService_EmptySigningKey_ThrowsAtConstruction()
|
|
{
|
|
var ex = Assert.Throws<InvalidOperationException>(() =>
|
|
new JwtTokenService(Options.Create(JwtOptions("")), NullLogger<JwtTokenService>.Instance));
|
|
Assert.Contains("JwtSigningKey", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void JwtTokenService_ShortSigningKey_ThrowsAtConstruction()
|
|
{
|
|
// 31 bytes — one short of the 256-bit HMAC-SHA256 minimum.
|
|
var shortKey = new string('k', 31);
|
|
var ex = Assert.Throws<InvalidOperationException>(() =>
|
|
new JwtTokenService(Options.Create(JwtOptions(shortKey)), NullLogger<JwtTokenService>.Instance));
|
|
Assert.Contains("32", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void JwtTokenService_AdequateSigningKey_ConstructsSuccessfully()
|
|
{
|
|
var key = new string('k', 32);
|
|
var service = new JwtTokenService(Options.Create(JwtOptions(key)), NullLogger<JwtTokenService>.Instance);
|
|
var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
|
|
Assert.False(string.IsNullOrEmpty(token));
|
|
}
|
|
|
|
// --- Security-002: authentication cookie must be marked Secure ---
|
|
|
|
[Fact]
|
|
public void AddSecurity_AuthCookie_IsMarkedSecureAlways()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddDataProtection();
|
|
services.AddSecurity();
|
|
// Explicitly set RequireHttpsCookie=true so the test asserts SecurePolicy.Always
|
|
// without relying on the SecurityOptions default value.
|
|
services.Configure<SecurityOptions>(o => o.RequireHttpsCookie = true);
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var cookieOptions = provider
|
|
.GetRequiredService<IOptionsMonitor<Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions>>()
|
|
.Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
Assert.Equal(
|
|
Microsoft.AspNetCore.Http.CookieSecurePolicy.Always,
|
|
cookieOptions.Cookie.SecurePolicy);
|
|
Assert.True(cookieOptions.Cookie.HttpOnly);
|
|
}
|
|
|
|
// --- CentralUI-005: cookie auth must use a sliding session window ---
|
|
// Documented policy (CLAUDE.md Security & Auth): sliding refresh with a
|
|
// 30-minute idle timeout. The cookie middleware must enable SlidingExpiration
|
|
// so an active session is renewed on activity and an idle session expires.
|
|
|
|
[Fact]
|
|
public void AddSecurity_AuthCookie_UsesSlidingExpiration()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddDataProtection();
|
|
services.AddSecurity();
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var cookieOptions = provider
|
|
.GetRequiredService<IOptionsMonitor<Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions>>()
|
|
.Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
Assert.True(cookieOptions.SlidingExpiration);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddSecurity_AuthCookie_ExpireTimeSpanMatchesIdleTimeout()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddDataProtection();
|
|
services.AddSecurity();
|
|
// The idle timeout drives the cookie's expiry window.
|
|
services.Configure<SecurityOptions>(o => o.IdleTimeoutMinutes = 30);
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var cookieOptions = provider
|
|
.GetRequiredService<IOptionsMonitor<Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions>>()
|
|
.Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
Assert.Equal(TimeSpan.FromMinutes(30), cookieOptions.ExpireTimeSpan);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddSecurity_AuthCookie_ExpireTimeSpanIsConfigurable()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddDataProtection();
|
|
services.AddSecurity();
|
|
services.Configure<SecurityOptions>(o => o.IdleTimeoutMinutes = 45);
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var cookieOptions = provider
|
|
.GetRequiredService<IOptionsMonitor<Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions>>()
|
|
.Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
Assert.Equal(TimeSpan.FromMinutes(45), cookieOptions.ExpireTimeSpan);
|
|
Assert.True(cookieOptions.SlidingExpiration);
|
|
}
|
|
|
|
// --- Configurable cookie name: two ScadaBridge environments sharing a hostname
|
|
// (browser cookies are scoped by host+path, NOT port) must be able to use
|
|
// distinct cookie names so signing into one does not clobber the other's session. ---
|
|
|
|
[Fact]
|
|
public void AddSecurity_AuthCookie_DefaultsToCanonicalName()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddDataProtection();
|
|
services.AddSecurity();
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var cookieOptions = provider
|
|
.GetRequiredService<IOptionsMonitor<Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions>>()
|
|
.Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
// Unconfigured deployments (incl. production and the primary docker cluster) keep the
|
|
// canonical name, so existing sessions survive and only an explicit override diverges.
|
|
Assert.Equal(SecurityOptions.DefaultCookieName, cookieOptions.Cookie.Name);
|
|
Assert.Equal("ZB.MOM.WW.ScadaBridge.Auth", cookieOptions.Cookie.Name);
|
|
}
|
|
|
|
[Fact]
|
|
public void AddSecurity_AuthCookie_CookieNameIsConfigurable()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddDataProtection();
|
|
services.AddSecurity();
|
|
// The per-environment override the docker-env2 cluster uses to avoid clobbering the
|
|
// primary cluster's cookie on localhost.
|
|
services.Configure<SecurityOptions>(o => o.CookieName = "ZB.MOM.WW.ScadaBridge.Auth.env2");
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var cookieOptions = provider
|
|
.GetRequiredService<IOptionsMonitor<Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions>>()
|
|
.Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
Assert.Equal("ZB.MOM.WW.ScadaBridge.Auth.env2", cookieOptions.Cookie.Name);
|
|
// The name override must be purely additive — it must not reset the hardened defaults.
|
|
Assert.True(cookieOptions.Cookie.HttpOnly);
|
|
Assert.Equal(Microsoft.AspNetCore.Http.SameSiteMode.Strict, cookieOptions.Cookie.SameSite);
|
|
Assert.True(cookieOptions.SlidingExpiration);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public void AddSecurity_AuthCookie_BlankCookieName_FallsBackToDefault(string blank)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddDataProtection();
|
|
services.AddSecurity();
|
|
services.Configure<SecurityOptions>(o => o.CookieName = blank);
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var cookieOptions = provider
|
|
.GetRequiredService<IOptionsMonitor<Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions>>()
|
|
.Get(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
|
|
|
|
// A blank value must never produce an unnamed cookie.
|
|
Assert.Equal(SecurityOptions.DefaultCookieName, cookieOptions.Cookie.Name);
|
|
}
|
|
|
|
// --- Security-001: transport security (now owned by the shared LdapOptions) ---
|
|
|
|
[Fact]
|
|
public void LdapOptions_Transport_DefaultsToLdaps()
|
|
{
|
|
// The shared LdapOptions keeps the donor's secure-by-default transport.
|
|
var options = new LdapOptions();
|
|
Assert.Equal(LdapTransport.Ldaps, options.Transport);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AuthenticateAsync_StartTlsTransport_NotBlockedByInsecureGuard()
|
|
{
|
|
// With StartTLS selected the service is NOT rejected as insecure: it reaches the
|
|
// connection stage (which fails against a dead host) and fails closed to the
|
|
// system-side bucket — proving the StartTLS path is reachable, not dead code.
|
|
var options = new LdapOptions
|
|
{
|
|
Enabled = true,
|
|
Server = "nonexistent.invalid",
|
|
Port = 389,
|
|
Transport = LdapTransport.StartTls,
|
|
SearchBase = "dc=example,dc=com",
|
|
ServiceAccountDn = "cn=admin,dc=example,dc=com",
|
|
ConnectionTimeoutMs = 2_000,
|
|
};
|
|
var service = new LdapAuthService(options);
|
|
|
|
var result = await service.AuthenticateAsync("user", "password", CancellationToken.None);
|
|
|
|
// Connection fails (host invalid) — fail-closed to ServiceAccountBindFailed, NOT Disabled.
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
|
|
}
|
|
|
|
[Fact]
|
|
public void LdapOptionsValidator_NoTlsTransport_RejectedWithoutAllowInsecure()
|
|
{
|
|
// The donor blocked plaintext LDAP (Transport=None) unless AllowInsecure was set.
|
|
// That guard moved to the shared LdapOptionsValidator (registered with
|
|
// ValidateOnStart by AddZbLdapAuth), which now fails fast at boot.
|
|
var options = new LdapOptions
|
|
{
|
|
Server = "ldap.example.com",
|
|
Port = 389,
|
|
Transport = LdapTransport.None,
|
|
AllowInsecure = false,
|
|
SearchBase = "dc=example,dc=com",
|
|
ServiceAccountDn = "cn=admin,dc=example,dc=com",
|
|
};
|
|
var validator = new LdapOptionsValidator();
|
|
|
|
var result = validator.Validate(name: null, options);
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains("AllowInsecure", result.FailureMessage);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Code Review Regression Tests — Security-004/005/006/007
|
|
|
|
/// <summary>
|
|
/// Regression tests for Security-006 (JWT issuer/audience checks disabled) and
|
|
/// Security-007 (idle-timeout claim reset on every token refresh).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Task 1.2 cutover: the former Security-004 (uid/cn attribute parity) and
|
|
/// Security-005 (no-service-account fallback-DN injection) cases tested ScadaBridge's
|
|
/// bespoke <c>LdapAuthService.BuildFallbackUserDn</c> / <c>EscapeLdapDn</c> static
|
|
/// helpers. The shared <c>ZB.MOM.WW.Auth.Ldap</c> service is search-then-bind only (no
|
|
/// no-service-account fallback DN) and its RFC-4514/4515 escaping parity is covered by
|
|
/// the library's own <c>LdapEscapingTests</c> / <c>LdapAuthServiceTests</c>. Those cases
|
|
/// were therefore removed here rather than re-aimed.
|
|
/// </remarks>
|
|
public class SecurityReviewRegressionTests2
|
|
{
|
|
private static SecurityOptions JwtOptions() => new()
|
|
{
|
|
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
|
|
JwtExpiryMinutes = 15,
|
|
IdleTimeoutMinutes = 30,
|
|
JwtRefreshThresholdMinutes = 5
|
|
};
|
|
|
|
private static JwtTokenService CreateJwtService(SecurityOptions? options = null) =>
|
|
new(Options.Create(options ?? JwtOptions()), NullLogger<JwtTokenService>.Instance);
|
|
|
|
// --- Security-006: JWT issuer/audience must be bound and validated ---
|
|
|
|
[Fact]
|
|
public void GenerateToken_SetsIssuerAndAudience()
|
|
{
|
|
var service = CreateJwtService();
|
|
var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
|
|
|
|
var jwt = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().ReadJwtToken(token);
|
|
Assert.Equal(JwtTokenService.TokenIssuer, jwt.Issuer);
|
|
Assert.Contains(JwtTokenService.TokenAudience, jwt.Audiences);
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateToken_RejectsTokenWithWrongIssuer()
|
|
{
|
|
// A token signed with the same key but a foreign issuer must be rejected.
|
|
var options = JwtOptions();
|
|
var key = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey(
|
|
System.Text.Encoding.UTF8.GetBytes(options.JwtSigningKey));
|
|
var creds = new Microsoft.IdentityModel.Tokens.SigningCredentials(
|
|
key, Microsoft.IdentityModel.Tokens.SecurityAlgorithms.HmacSha256);
|
|
var foreign = new System.IdentityModel.Tokens.Jwt.JwtSecurityToken(
|
|
issuer: "some-other-system",
|
|
audience: JwtTokenService.TokenAudience,
|
|
claims: new[] { new Claim(JwtTokenService.UsernameClaimType, "user") },
|
|
expires: DateTime.UtcNow.AddMinutes(10),
|
|
signingCredentials: creds);
|
|
var foreignToken = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler().WriteToken(foreign);
|
|
|
|
var service = CreateJwtService(options);
|
|
Assert.Null(service.ValidateToken(foreignToken));
|
|
}
|
|
|
|
[Fact]
|
|
public void ValidateToken_AcceptsOwnIssuerAndAudience()
|
|
{
|
|
var service = CreateJwtService();
|
|
var token = service.GenerateToken("User", "user", new[] { "Administrator" }, null);
|
|
Assert.NotNull(service.ValidateToken(token));
|
|
}
|
|
|
|
// --- Security-007: refresh must preserve the original LastActivity timestamp ---
|
|
|
|
[Fact]
|
|
public void RefreshToken_PreservesOriginalLastActivityClaim()
|
|
{
|
|
var service = CreateJwtService();
|
|
|
|
// Mint a token whose LastActivity is 20 minutes in the past (still inside the
|
|
// 30-minute idle window). Refresh must NOT move it forward to "now".
|
|
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
|
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
|
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
|
|
}, "test"));
|
|
|
|
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
|
|
Assert.NotNull(refreshed);
|
|
|
|
var refreshedPrincipal = service.ValidateToken(refreshed!);
|
|
Assert.NotNull(refreshedPrincipal);
|
|
var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
|
|
Assert.NotNull(claim);
|
|
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
|
|
// The carried timestamp must equal the original, not "now".
|
|
Assert.True(Math.Abs((carried - staleActivity).TotalSeconds) < 2,
|
|
$"LastActivity was reset on refresh: expected ~{staleActivity:o}, got {carried:o}");
|
|
}
|
|
|
|
[Fact]
|
|
public void RefreshToken_DoesNotResetIdleTimeoutWhenUserIsActuallyIdle()
|
|
{
|
|
// A user idle for 25 of the 30-minute window: a refresh fired by some background
|
|
// request must not make IsIdleTimedOut flip back to false forever.
|
|
var service = CreateJwtService();
|
|
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-25);
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
|
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
|
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
|
|
}, "test"));
|
|
|
|
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
|
|
var refreshedPrincipal = service.ValidateToken(refreshed!);
|
|
|
|
// Still 25 min idle after refresh — not reset to 0.
|
|
Assert.False(service.IsIdleTimedOut(refreshedPrincipal!)); // 25 < 30, still valid
|
|
var claim = refreshedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
|
|
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var carried));
|
|
Assert.True((DateTimeOffset.UtcNow - carried).TotalMinutes > 20,
|
|
"Refresh wrongly reset the idle clock to ~now");
|
|
}
|
|
|
|
[Fact]
|
|
public void RecordActivity_UpdatesLastActivityToNow()
|
|
{
|
|
// Genuine user activity (a real request) — distinct from a token refresh —
|
|
// updates LastActivity to the current time.
|
|
var service = CreateJwtService();
|
|
var staleActivity = DateTimeOffset.UtcNow.AddMinutes(-20);
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
|
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
|
new Claim(JwtTokenService.LastActivityClaimType, staleActivity.ToString("o"))
|
|
}, "test"));
|
|
|
|
var touched = service.RecordActivity(principal, new[] { "Administrator" }, null);
|
|
Assert.NotNull(touched);
|
|
|
|
var touchedPrincipal = service.ValidateToken(touched!);
|
|
var claim = touchedPrincipal!.FindFirst(JwtTokenService.LastActivityClaimType);
|
|
Assert.True(DateTimeOffset.TryParse(claim!.Value, out var updated));
|
|
Assert.True((DateTimeOffset.UtcNow - updated).TotalSeconds < 5,
|
|
"RecordActivity should set LastActivity to ~now");
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Code Review Regression Tests — Security-009/011
|
|
|
|
/// <summary>
|
|
/// Regression tests for Security-009 (no LDAP connection timeout — a hung server can
|
|
/// pin a thread-pool thread indefinitely because <c>ct</c> only guards work-item
|
|
/// scheduling) and the remaining Security-011 coverage gaps.
|
|
/// </summary>
|
|
public class SecurityReviewRegressionTests3
|
|
{
|
|
// --- Security-009: LDAP connection timeout must be configurable and bounded ---
|
|
|
|
[Fact]
|
|
public void LdapOptions_ConnectionTimeout_HasSaneDefault()
|
|
{
|
|
var options = new LdapOptions();
|
|
// A positive, finite default so a hung LDAP server cannot pin a thread forever
|
|
// (the donor's Security-009 safeguard, preserved on the shared LdapOptions).
|
|
Assert.True(options.ConnectionTimeoutMs > 0);
|
|
Assert.True(options.ConnectionTimeoutMs <= 60_000,
|
|
"Default LDAP connection timeout should be bounded (<= 60s).");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AuthenticateAsync_UnreachableHost_FailsWithinConfiguredTimeout()
|
|
{
|
|
// A routable-but-non-responsive address would otherwise hang for the OS default
|
|
// (often minutes). With ConnectionTimeoutMs applied to the LdapConnection the call
|
|
// must give up promptly. 198.51.100.0/24 (TEST-NET-2, RFC 5737) is reserved and not
|
|
// routed, so the connect attempt stalls until the timeout fires.
|
|
var options = new LdapOptions
|
|
{
|
|
Enabled = true,
|
|
Server = "198.51.100.1",
|
|
Port = 389,
|
|
Transport = LdapTransport.None,
|
|
AllowInsecure = true,
|
|
SearchBase = "dc=example,dc=com",
|
|
ServiceAccountDn = "cn=admin,dc=example,dc=com",
|
|
ConnectionTimeoutMs = 2_000,
|
|
};
|
|
var service = new LdapAuthService(options);
|
|
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
var result = await service.AuthenticateAsync("user", "password", CancellationToken.None);
|
|
sw.Stop();
|
|
|
|
Assert.False(result.Succeeded);
|
|
// Generous ceiling: the 2s timeout plus scheduling/CI overhead, far below the
|
|
// multi-minute OS default that an unconfigured connection would incur.
|
|
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(30),
|
|
$"Authentication did not honour the LDAP connection timeout: took {sw.Elapsed}.");
|
|
}
|
|
|
|
// Note: the former Security-011 no-service-account fallback-DN / DN-escape static-helper
|
|
// cases (BuildFallbackUserDn / EscapeLdapDn) were removed: the shared service is
|
|
// search-then-bind only (no fallback DN), and its escaping parity is covered by the
|
|
// library's own LdapEscapingTests.
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Code Review Regression Tests — Security-012/013/014/015
|
|
|
|
/// <summary>
|
|
/// Regression tests for Security-014 (<c>RefreshToken</c> re-issues a token without
|
|
/// checking the idle timeout, so an idle-expired session can be renewed indefinitely).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Task 1.2 cutover: the former Security-013 (<c>ExtractFirstRdnValue</c> escaped-comma
|
|
/// parsing) and Security-015 (username trim before use) cases tested ScadaBridge's
|
|
/// bespoke <c>LdapAuthService.ExtractFirstRdnValue</c> / <c>NormalizeUsername</c> /
|
|
/// <c>BuildFallbackUserDn</c> statics. Those behaviours are now owned by the shared
|
|
/// library and covered by its own tests — <c>LdapEscapingTests.FirstRdnValue_IsEscapeAware</c>
|
|
/// (escaped-comma RDN extraction) and <c>LdapAuthServiceFailureTests</c>
|
|
/// (whitespace-username → BadCredentials, no connection) — so they were removed here.
|
|
/// </remarks>
|
|
public class SecurityReviewRegressionTests4
|
|
{
|
|
private static SecurityOptions JwtOptions() => new()
|
|
{
|
|
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
|
|
JwtExpiryMinutes = 15,
|
|
IdleTimeoutMinutes = 30,
|
|
JwtRefreshThresholdMinutes = 5
|
|
};
|
|
|
|
private static JwtTokenService CreateJwtService(SecurityOptions? options = null) =>
|
|
new(Options.Create(options ?? JwtOptions()), NullLogger<JwtTokenService>.Instance);
|
|
|
|
// --- Security-014: RefreshToken must reject an idle-expired principal ---
|
|
|
|
[Fact]
|
|
public void RefreshToken_IdleExpiredPrincipal_ReturnsNull()
|
|
{
|
|
// A user idle for 31 minutes (past the 30-minute idle window). Even though the
|
|
// DisplayName/Username claims are present, the refresh must be refused so an
|
|
// idle-expired session cannot be renewed by a background request.
|
|
var service = CreateJwtService();
|
|
var idleActivity = DateTimeOffset.UtcNow.AddMinutes(-31);
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
|
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
|
new Claim(JwtTokenService.LastActivityClaimType, idleActivity.ToString("o"))
|
|
}, "test"));
|
|
|
|
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
|
|
|
|
Assert.Null(refreshed);
|
|
}
|
|
|
|
[Fact]
|
|
public void RefreshToken_ActiveSessionWithinIdleWindow_StillRefreshes()
|
|
{
|
|
// A user idle for 10 minutes (well inside the 30-minute window): refresh must
|
|
// still succeed — the idle-timeout guard must not break the normal sliding path.
|
|
var service = CreateJwtService();
|
|
var recentActivity = DateTimeOffset.UtcNow.AddMinutes(-10);
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
|
new Claim(JwtTokenService.UsernameClaimType, "user"),
|
|
new Claim(JwtTokenService.LastActivityClaimType, recentActivity.ToString("o"))
|
|
}, "test"));
|
|
|
|
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
|
|
|
|
Assert.NotNull(refreshed);
|
|
}
|
|
|
|
[Fact]
|
|
public void RefreshToken_MissingLastActivityClaim_ReturnsNull()
|
|
{
|
|
// No LastActivity claim — IsIdleTimedOut treats this as timed out, so the
|
|
// refresh must be refused rather than minting a fresh session out of nothing.
|
|
var service = CreateJwtService();
|
|
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
{
|
|
new Claim(JwtTokenService.DisplayNameClaimType, "User"),
|
|
new Claim(JwtTokenService.UsernameClaimType, "user")
|
|
}, "test"));
|
|
|
|
var refreshed = service.RefreshToken(principal, new[] { "Administrator" }, null);
|
|
|
|
Assert.Null(refreshed);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Code Review Regression Tests — Security-012 (partial LDAP outage)
|
|
|
|
/// <summary>
|
|
/// Security-012 (fail-closed group lookup) + the ScadaBridge-side
|
|
/// <see cref="LdapAuthFailureMessages"/> failure-code→message adapter.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Task 1.2 cutover: the donor's fail-closed group-lookup rule (a post-bind group
|
|
/// search failure FAILS the login rather than admitting a roleless session) is now
|
|
/// owned by the shared <c>ZB.MOM.WW.Auth.Ldap</c> service and asserted by its own
|
|
/// <c>LdapAuthServiceFailureTests.GroupLookupFailed_WhenUserHasNoGroups</c> /
|
|
/// <c>ServiceAccountBindFailed_Distinctly_WhenServiceBindThrows</c>. The donor's
|
|
/// bespoke <c>BuildAuthResultFromGroupLookup</c> / <c>ServiceAccountBindException</c>
|
|
/// statics no longer exist, so those cases were re-aimed at the shared
|
|
/// <see cref="LdapAuthResult"/> contract here.
|
|
/// </para>
|
|
/// <para>
|
|
/// DEVIATION (documented): the donor admitted an "authenticated with zero groups"
|
|
/// login when the group lookup SUCCEEDED but returned no groups
|
|
/// (<c>BuildAuthResultFromGroupLookup_LookupSucceededNoGroups_IsAuthenticated</c>).
|
|
/// The shared library is STRICTER and fail-closes a zero-group result to
|
|
/// <see cref="LdapAuthFailure.GroupLookupFailed"/> — see
|
|
/// <c>EmptyGroups_IsTreatedAsGroupLookupFailed_LibraryIsStricter</c> below. The
|
|
/// downstream effect (a user with no mapped groups cannot obtain a useful session) is
|
|
/// unchanged: the donor's empty-group session mapped to zero roles and was denied by
|
|
/// every authorization policy anyway. The empty-GROUP-set-at-the-mapper case (a user
|
|
/// whose groups map to no roles) is covered by
|
|
/// <c>ScadaBridgeGroupRoleMapperTests.MapAsync_EmptyGroups_*</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
public class Security012GroupLookupFailureTests
|
|
{
|
|
[Fact]
|
|
public void LdapAuthResult_Fail_GroupLookupFailed_IsFailedLoginWithNoGroups()
|
|
{
|
|
// The fail-closed outcome: a failed group lookup surfaces as a FAILED result with
|
|
// the GroupLookupFailed code and an empty group set (never a roleless success).
|
|
var result = LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
|
Assert.Empty(result.Groups);
|
|
}
|
|
|
|
[Fact]
|
|
public void LdapAuthResult_Success_CarriesGroups()
|
|
{
|
|
var result = LdapAuthResult.Success("alice", "Alice", new[] { "SCADA-Admins" });
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Equal(new[] { "SCADA-Admins" }, result.Groups);
|
|
Assert.Null(result.Failure);
|
|
}
|
|
|
|
[Fact]
|
|
public void EmptyGroups_IsTreatedAsGroupLookupFailed_LibraryIsStricter()
|
|
{
|
|
// Documents the deviation: the shared library NEVER admits a zero-group success.
|
|
// The success factory requires >=1 group at the service level; a fabricated
|
|
// zero-group "success" is not a state the service produces. We pin the contract
|
|
// the cutover relies on: a zero-group outcome is the GroupLookupFailed failure.
|
|
var failClosed = LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
|
|
Assert.False(failClosed.Succeeded);
|
|
|
|
// And the donor's "Security-012 directory-unavailable" message is preserved for it.
|
|
Assert.Equal(
|
|
LdapAuthFailureMessages.DirectoryUnavailable,
|
|
LdapAuthFailureMessages.ToMessage(LdapAuthFailure.GroupLookupFailed));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(LdapAuthFailure.BadCredentials, LdapAuthFailureMessages.InvalidCredentials)]
|
|
[InlineData(LdapAuthFailure.UserNotFound, LdapAuthFailureMessages.InvalidCredentials)]
|
|
[InlineData(LdapAuthFailure.AmbiguousUser, LdapAuthFailureMessages.Misconfigured)]
|
|
[InlineData(LdapAuthFailure.ServiceAccountBindFailed, LdapAuthFailureMessages.Misconfigured)]
|
|
[InlineData(LdapAuthFailure.GroupLookupFailed, LdapAuthFailureMessages.DirectoryUnavailable)]
|
|
[InlineData(LdapAuthFailure.Disabled, LdapAuthFailureMessages.Disabled)]
|
|
public void LdapAuthFailureMessages_MapsEachFailure_ToDonorEquivalentMessage(
|
|
LdapAuthFailure failure, string expected)
|
|
{
|
|
// Security-019 framing preserved: user-credential failures are enumeration-safe
|
|
// ("Invalid username or password."), system-side faults point the OPERATOR at the
|
|
// cause ("...misconfigured...") rather than blaming user input.
|
|
Assert.Equal(expected, LdapAuthFailureMessages.ToMessage(failure));
|
|
}
|
|
|
|
[Fact]
|
|
public void LdapAuthFailureMessages_NullFailure_FallsBackToGeneric()
|
|
{
|
|
Assert.Equal(LdapAuthFailureMessages.Generic, LdapAuthFailureMessages.ToMessage(null));
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region WP-9: Authorization Policy Tests
|
|
|
|
public class AuthorizationPolicyTests
|
|
{
|
|
[Fact]
|
|
public async Task AdminPolicy_AdministratorRole_Succeeds()
|
|
{
|
|
var principal = CreatePrincipal(new[] { Roles.Administrator });
|
|
var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal);
|
|
Assert.True(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AdminPolicy_DesignerRole_Fails()
|
|
{
|
|
var principal = CreatePrincipal(new[] { Roles.Designer });
|
|
var result = await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal);
|
|
Assert.False(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DesignPolicy_DesignerRole_Succeeds()
|
|
{
|
|
var principal = CreatePrincipal(new[] { Roles.Designer });
|
|
var result = await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal);
|
|
Assert.True(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeploymentPolicy_DeployerRole_Succeeds()
|
|
{
|
|
var principal = CreatePrincipal(new[] { Roles.Deployer });
|
|
var result = await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal);
|
|
Assert.True(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoRoles_DeniedAll()
|
|
{
|
|
var principal = CreatePrincipal(Array.Empty<string>());
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal));
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDesign, principal));
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, principal));
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Audit Log #23 — OperationalAudit + AuditExport policies (M7-T15),
|
|
// post Task 1.7 canonicalization + SoD collapse. Default mapping
|
|
// (see AuthorizationPolicies XML doc):
|
|
// Administrator → OperationalAudit + AuditExport
|
|
// Viewer → OperationalAudit only (former AuditReadOnly home)
|
|
// Designer → neither
|
|
// Deployer → neither
|
|
// The former distinct Audit/AuditReadOnly roles no longer exist:
|
|
// Audit → collapsed into Administrator
|
|
// AuditReadOnly → collapsed into Viewer
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
[Theory]
|
|
[InlineData("Administrator")]
|
|
[InlineData("Viewer")]
|
|
public async Task OperationalAuditPolicy_GrantedRoles_Succeed(string role)
|
|
{
|
|
var principal = CreatePrincipal(new[] { role });
|
|
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Designer")]
|
|
[InlineData("Deployer")]
|
|
public async Task OperationalAuditPolicy_UngrantedRoles_Fail(string role)
|
|
{
|
|
var principal = CreatePrincipal(new[] { role });
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Administrator")]
|
|
public async Task AuditExportPolicy_GrantedRoles_Succeed(string role)
|
|
{
|
|
var principal = CreatePrincipal(new[] { role });
|
|
Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Viewer")]
|
|
[InlineData("Designer")]
|
|
[InlineData("Deployer")]
|
|
public async Task AuditExportPolicy_UngrantedRoles_Fail(string role)
|
|
{
|
|
var principal = CreatePrincipal(new[] { role });
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Viewer_ReadsAudit_ButCannotExport_PreservedHalfSoD()
|
|
{
|
|
// The load-bearing preserved-SoD case after the AuditReadOnly→Viewer
|
|
// collapse: a Viewer satisfies OperationalAudit (read the log + nav)
|
|
// but NOT AuditExport (bulk CSV exfiltration).
|
|
var principal = CreatePrincipal(new[] { Roles.Viewer });
|
|
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FormerAuditUser_NowAdministrator_GainsExportAndFullAdmin_DocumentedEscalation()
|
|
{
|
|
// The documented privilege escalation: the former Audit role collapsed
|
|
// into Administrator, so a former audit-only user now passes AuditExport
|
|
// AND RequireAdmin (the full admin surface).
|
|
var principal = CreatePrincipal(new[] { Roles.Administrator });
|
|
Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
|
|
Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal));
|
|
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
|
|
}
|
|
|
|
private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null)
|
|
{
|
|
var claims = new List<Claim>();
|
|
foreach (var role in roles)
|
|
claims.Add(new Claim(JwtTokenService.RoleClaimType, role));
|
|
if (siteIds != null)
|
|
foreach (var siteId in siteIds)
|
|
claims.Add(new Claim(JwtTokenService.SiteIdClaimType, siteId));
|
|
|
|
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
|
|
}
|
|
|
|
private static async Task<bool> EvaluatePolicy(string policyName, ClaimsPrincipal principal)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddScadaBridgeAuthorization();
|
|
services.AddLogging();
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var authService = provider.GetRequiredService<IAuthorizationService>();
|
|
|
|
var result = await authService.AuthorizeAsync(principal, null, policyName);
|
|
return result.Succeeded;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Code Review Regression Tests — Security-020 (LDAP fail-fast validation)
|
|
|
|
/// <summary>
|
|
/// Security-020: a missing/empty required LDAP field must fail fast at startup with a
|
|
/// clear, field-naming message, so a typo'd appsettings section fails at boot instead
|
|
/// of surfacing minutes/hours later as a generic LDAP error on the first real login.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Task 1.2/1.4 cutover: the donor's <c>SecurityOptionsValidator</c> (which checked
|
|
/// <c>Security:LdapServer</c> + <c>Security:LdapSearchBase</c> non-empty) was replaced by
|
|
/// the shared <c>ZB.MOM.WW.Auth.Ldap.LdapOptionsValidator</c>, registered with
|
|
/// <c>ValidateOnStart()</c> by <c>AddZbLdapAuth</c> inside <c>AddSecurity</c>. The shared
|
|
/// validator is STRICTER — it also requires <c>ServiceAccountDn</c> and rejects plaintext
|
|
/// transport without <c>AllowInsecure</c> — so the boot-time fail-fast guarantee is
|
|
/// preserved and broadened. These tests pin the shared validator's behaviour and the
|
|
/// ScadaBridge wiring that registers it.
|
|
/// </remarks>
|
|
public class SecurityOptionsValidatorTests
|
|
{
|
|
private static LdapOptions ValidOptions() => new()
|
|
{
|
|
Server = "ldap.example.com",
|
|
SearchBase = "dc=example,dc=com",
|
|
ServiceAccountDn = "cn=admin,dc=example,dc=com",
|
|
Transport = LdapTransport.Ldaps,
|
|
};
|
|
|
|
[Fact]
|
|
public void Validate_AllRequiredFieldsSet_Succeeds()
|
|
{
|
|
var result = new LdapOptionsValidator().Validate(name: null, ValidOptions());
|
|
Assert.True(result.Succeeded);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public void Validate_EmptyOrWhitespaceServer_Fails(string server)
|
|
{
|
|
var options = ValidOptions() with { Server = server };
|
|
|
|
var result = new LdapOptionsValidator().Validate(name: null, options);
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains(nameof(LdapOptions.Server), result.FailureMessage);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public void Validate_EmptyOrWhitespaceSearchBase_Fails(string searchBase)
|
|
{
|
|
var options = ValidOptions() with { SearchBase = searchBase };
|
|
|
|
var result = new LdapOptionsValidator().Validate(name: null, options);
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains(nameof(LdapOptions.SearchBase), result.FailureMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void Validate_EmptyServiceAccountDn_Fails()
|
|
{
|
|
// Stricter than the donor: an empty ServiceAccountDn would bind anonymously and
|
|
// defeat search-then-bind, so the shared validator rejects it at boot.
|
|
var options = ValidOptions() with { ServiceAccountDn = string.Empty };
|
|
|
|
var result = new LdapOptionsValidator().Validate(name: null, options);
|
|
|
|
Assert.True(result.Failed);
|
|
Assert.Contains(nameof(LdapOptions.ServiceAccountDn), result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the security composition the Host performs at its composition root —
|
|
/// <c>AddZbLdapAuth(configuration, LdapSectionPath)</c> followed by <c>AddSecurity()</c> —
|
|
/// wires the shared <see cref="LdapOptionsValidator"/> as an
|
|
/// <see cref="IValidateOptions{TOptions}"/> for <c>LdapOptions</c> (which is what makes
|
|
/// <c>ValidateOnStart()</c> fire). The LDAP registration moved to the Host because it is
|
|
/// config-coupled; <c>AddSecurity()</c> is a component library and no longer takes
|
|
/// <c>IConfiguration</c>.
|
|
/// </summary>
|
|
[Fact]
|
|
public void AddSecurity_RegistersLdapOptionsValidator_WithValidateOnStart()
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddDataProtection();
|
|
// Mirror the Host composition order: shared LDAP auth (config-coupled) first,
|
|
// then the config-free AddSecurity().
|
|
services.AddZbLdapAuth(
|
|
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
|
|
ZB.MOM.WW.ScadaBridge.Security.ServiceCollectionExtensions.LdapSectionPath);
|
|
services.AddSecurity();
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
|
|
// The shared validator participates in IValidateOptions<LdapOptions> —
|
|
// registration is the load-bearing wiring that makes ValidateOnStart() fire.
|
|
var validators = provider.GetServices<IValidateOptions<LdapOptions>>().ToList();
|
|
Assert.Contains(validators, v => v is LdapOptionsValidator);
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Task 1.5: Canonical claim vocabulary (ZbClaimTypes) — role/scope migration
|
|
|
|
/// <summary>
|
|
/// Task 1.5 (full canonical adoption): the Security module's claim-type constants now
|
|
/// alias the shared <see cref="ZbClaimTypes"/> vocabulary. These tests pin the contract
|
|
/// that the MINTED claim type is exactly the type the policies + token validation CONSUME,
|
|
/// across both the JWT bearer and the cookie principal, so a future drift fails loudly.
|
|
/// </summary>
|
|
public class CanonicalClaimVocabularyTests
|
|
{
|
|
private static SecurityOptions CreateOptions() => new()
|
|
{
|
|
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
|
|
JwtExpiryMinutes = 15,
|
|
IdleTimeoutMinutes = 30,
|
|
JwtRefreshThresholdMinutes = 5
|
|
};
|
|
|
|
private static JwtTokenService CreateService() =>
|
|
new(Options.Create(CreateOptions()), NullLogger<JwtTokenService>.Instance);
|
|
|
|
[Fact]
|
|
public void ClaimTypeConstants_AreCanonicalZbClaimTypes()
|
|
{
|
|
// The constants must be the canonical shared types so every mint/consume site
|
|
// inherits them centrally.
|
|
Assert.Equal(ZbClaimTypes.Role, JwtTokenService.RoleClaimType);
|
|
Assert.Equal(ZbClaimTypes.ScopeId, JwtTokenService.SiteIdClaimType);
|
|
Assert.Equal(ZbClaimTypes.DisplayName, JwtTokenService.DisplayNameClaimType);
|
|
Assert.Equal(ZbClaimTypes.Username, JwtTokenService.UsernameClaimType);
|
|
|
|
// Role aliases the framework URI; scope/display/username are the zb: strings.
|
|
Assert.Equal(ClaimTypes.Role, JwtTokenService.RoleClaimType);
|
|
Assert.Equal("zb:scopeid", JwtTokenService.SiteIdClaimType);
|
|
Assert.Equal("zb:displayname", JwtTokenService.DisplayNameClaimType);
|
|
Assert.Equal("zb:username", JwtTokenService.UsernameClaimType);
|
|
|
|
// LastActivity has no canonical equivalent and is unchanged.
|
|
Assert.Equal("LastActivity", JwtTokenService.LastActivityClaimType);
|
|
}
|
|
|
|
[Fact]
|
|
public void MintedJwt_RoundTrips_CanonicalClaimTypesVerbatim()
|
|
{
|
|
var service = CreateService();
|
|
var token = service.GenerateToken(
|
|
"Jane Roe", "janer",
|
|
new[] { Roles.Administrator },
|
|
new[] { "7" });
|
|
|
|
var principal = service.ValidateToken(token);
|
|
Assert.NotNull(principal);
|
|
|
|
// Claim TYPES must be the canonical strings, not the framework's short JWT names
|
|
// (MapInboundClaims=false / cleared outbound map guarantee this).
|
|
Assert.Equal("Jane Roe", principal!.FindFirst(ZbClaimTypes.DisplayName)?.Value);
|
|
Assert.Equal("janer", principal.FindFirst(ZbClaimTypes.Username)?.Value);
|
|
Assert.Equal(Roles.Administrator, principal.FindFirst(ZbClaimTypes.Role)?.Value);
|
|
Assert.Equal("7", principal.FindFirst(ZbClaimTypes.ScopeId)?.Value);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MintedJwt_RoleClaim_SatisfiesOperationalAuditPolicy()
|
|
{
|
|
// The load-bearing round-trip: a JWT minted with RoleClaimType=Administrator
|
|
// (post Task 1.7, the home of the former full-audit Audit role) must satisfy
|
|
// a RequireClaim(RoleClaimType, OperationalAuditRoles) policy after validation.
|
|
var service = CreateService();
|
|
var token = service.GenerateToken("Jane Roe", "janer", new[] { Roles.Administrator }, null);
|
|
|
|
var principal = service.ValidateToken(token);
|
|
Assert.NotNull(principal);
|
|
|
|
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal!));
|
|
// Administrator grants AuditExport too (it absorbed the former Audit role):
|
|
Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal!));
|
|
// Viewer (post Task 1.7 home of the former AuditReadOnly role) is read-only —
|
|
// separate assertion that the read-not-export half-SoD survives the type
|
|
// migration AND the role collapse.
|
|
var roPrincipal = service.ValidateToken(
|
|
service.GenerateToken("RO", "ro", new[] { Roles.Viewer }, null));
|
|
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, roPrincipal!));
|
|
Assert.False(await EvaluatePolicy(AuthorizationPolicies.AuditExport, roPrincipal!));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CookiePrincipal_BuiltLikeLogin_AuthorizesAndExposesCanonicalTypes()
|
|
{
|
|
// Reproduce AuthEndpoints' cookie principal exactly: canonical claim types via the
|
|
// constants + a ClaimsIdentity whose roleType is RoleClaimType so IsInRole resolves.
|
|
var claims = new List<Claim>
|
|
{
|
|
new(ClaimTypes.Name, "janer"),
|
|
new(JwtTokenService.DisplayNameClaimType, "Jane Roe"),
|
|
new(JwtTokenService.UsernameClaimType, "janer"),
|
|
new(JwtTokenService.RoleClaimType, Roles.Administrator),
|
|
new(JwtTokenService.SiteIdClaimType, "3"),
|
|
};
|
|
var identity = new ClaimsIdentity(
|
|
claims,
|
|
authenticationType: "TestCookie",
|
|
nameType: ClaimTypes.Name,
|
|
roleType: JwtTokenService.RoleClaimType);
|
|
var principal = new ClaimsPrincipal(identity);
|
|
|
|
// Claim types are canonical.
|
|
Assert.Equal("zb:scopeid", JwtTokenService.SiteIdClaimType);
|
|
Assert.Equal("3", principal.FindFirst(ZbClaimTypes.ScopeId)?.Value);
|
|
Assert.Equal("janer", principal.Identity?.Name); // ClaimTypes.Name resolves Identity.Name
|
|
|
|
// roleType wiring => IsInRole resolves against the canonical role claim.
|
|
Assert.True(principal.IsInRole(Roles.Administrator));
|
|
|
|
// Admin holds every permission by convention.
|
|
Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireAdmin, principal));
|
|
Assert.True(await EvaluatePolicy(AuthorizationPolicies.OperationalAudit, principal));
|
|
Assert.True(await EvaluatePolicy(AuthorizationPolicies.AuditExport, principal));
|
|
}
|
|
|
|
private static async Task<bool> EvaluatePolicy(string policyName, ClaimsPrincipal principal)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddScadaBridgeAuthorization();
|
|
services.AddLogging();
|
|
|
|
using var provider = services.BuildServiceProvider();
|
|
var authService = provider.GetRequiredService<IAuthorizationService>();
|
|
|
|
var result = await authService.AuthorizeAsync(principal, null, policyName);
|
|
return result.Succeeded;
|
|
}
|
|
}
|
|
|
|
#endregion
|