9b0720af9c
Auth 0.5.0 (all five packages) adds the passwordless ILdapGroupLookup. A new singleton DirectoryGroupRefresher asks it for a session's current groups once the new zb:lastgrouprefresh anchor (stamped at login) is older than ScadaBridge:Security:LdapGroupRefresh:IntervalMinutes (default 15, Enabled default true, CacheSeconds 60 per-node answer reuse). CookieSessionValidator replaces the stored groups and re-maps roles; this reaches HTTP and live circuits (once per interval, independent of the 10 s circuit refresh). Failure policy: directory unavailable keeps the stored groups (warn, all paths incl. fail-closed mutation checks); user not found / disabled / ambiguous / no groups ends the session. Inert under BindMode=User (NotSupported), LDAP disabled, or DisableLogin. Auth 0.4.4 ApiKeyFailure.StoreUnavailable now answers 503 SERVICE_UNAVAILABLE on the inbound API instead of a misleading 401. Docs: Component-Security.md, components/Security.md, Component-Host.md, register row 23 closed. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
243 lines
10 KiB
C#
243 lines
10 KiB
C#
using System.Net;
|
|
using System.Text.RegularExpressions;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Auth flow integration tests.
|
|
/// Tests that require a running LDAP server are marked with Integration trait.
|
|
/// </summary>
|
|
public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
|
|
{
|
|
private readonly ScadaBridgeWebApplicationFactory _factory;
|
|
|
|
/// <summary>Initializes the test with the shared web application factory.</summary>
|
|
/// <param name="factory">The web application factory hosting the app under test.</param>
|
|
public AuthFlowTests(ScadaBridgeWebApplicationFactory factory)
|
|
{
|
|
_factory = factory;
|
|
}
|
|
|
|
/// <summary>Verifies that the login endpoint with empty credentials redirects to the login page with an error.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task LoginEndpoint_WithEmptyCredentials_RedirectsToLoginWithError()
|
|
{
|
|
var client = _factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
|
{
|
|
AllowAutoRedirect = false
|
|
});
|
|
|
|
using var content = await CreateLoginFormAsync(client, "", "");
|
|
|
|
var response = await client.PostAsync("/auth/login", content);
|
|
|
|
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
|
var location = response.Headers.Location?.ToString() ?? "";
|
|
Assert.Contains("/login", location);
|
|
Assert.Contains("error", location, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
/// <summary>Verifies that the logout endpoint clears the cookie and redirects.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task LogoutEndpoint_ClearsCookieAndRedirects()
|
|
{
|
|
var client = _factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
|
{
|
|
AllowAutoRedirect = false
|
|
});
|
|
|
|
var response = await client.PostAsync("/auth/logout", null);
|
|
|
|
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
|
var location = response.Headers.Location?.ToString() ?? "";
|
|
Assert.Contains("/login", location);
|
|
}
|
|
|
|
/// <summary>Verifies that the JWT token service round-trips a generated token through validation.</summary>
|
|
[Fact]
|
|
public void JwtTokenService_GenerateAndValidate_RoundTrips()
|
|
{
|
|
using var scope = _factory.Services.CreateScope();
|
|
var jwtService = scope.ServiceProvider.GetRequiredService<JwtTokenService>();
|
|
|
|
var token = jwtService.GenerateToken(
|
|
displayName: "Test User",
|
|
username: "testuser",
|
|
roles: new[] { "Administrator", "Designer" },
|
|
permittedSiteIds: null);
|
|
|
|
Assert.NotNull(token);
|
|
|
|
var principal = jwtService.ValidateToken(token);
|
|
Assert.NotNull(principal);
|
|
|
|
var displayName = principal!.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
|
|
var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value;
|
|
var roles = principal.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
|
|
|
|
Assert.Equal("Test User", displayName);
|
|
Assert.Equal("testuser", username);
|
|
Assert.Contains("Administrator", roles);
|
|
Assert.Contains("Designer", roles);
|
|
}
|
|
|
|
/// <summary>Verifies that tokens generated with site scopes include site id claims.</summary>
|
|
[Fact]
|
|
public void JwtTokenService_WithSiteScopes_IncludesSiteIdClaims()
|
|
{
|
|
using var scope = _factory.Services.CreateScope();
|
|
var jwtService = scope.ServiceProvider.GetRequiredService<JwtTokenService>();
|
|
|
|
var token = jwtService.GenerateToken(
|
|
displayName: "Deployer",
|
|
username: "deployer1",
|
|
roles: new[] { "Deployer" },
|
|
permittedSiteIds: new[] { "1", "3" });
|
|
|
|
var principal = jwtService.ValidateToken(token);
|
|
Assert.NotNull(principal);
|
|
|
|
var siteIds = principal!.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToList();
|
|
Assert.Contains("1", siteIds);
|
|
Assert.Contains("3", siteIds);
|
|
}
|
|
|
|
/// <summary>Verifies that the login endpoint with valid LDAP credentials sets the cookie and redirects.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Trait("Category", "Integration")]
|
|
[Fact]
|
|
public async Task LoginEndpoint_WithValidLdapCredentials_SetsCookieAndRedirects()
|
|
{
|
|
// Requires a local GLAuth on localhost:3893 — the redundant test pair:
|
|
// cd ~/Desktop/scadaproj/infra/glauth-redundant && docker compose up -d
|
|
// (baseDN dc=zb,dc=local, user passwords "password"; the old infra/docker-compose.yml
|
|
// glauth service no longer exists — LDAP moved out of that stack.)
|
|
if (!await IsLdapAvailableAsync())
|
|
{
|
|
// Skip gracefully if GLAuth not running — not a test failure
|
|
return;
|
|
}
|
|
|
|
var client = _factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
|
{
|
|
AllowAutoRedirect = false
|
|
});
|
|
|
|
using var content = await CreateLoginFormAsync(client, "admin", "password");
|
|
|
|
var response = await client.PostAsync("/auth/login", content);
|
|
|
|
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
|
var location = response.Headers.Location?.ToString() ?? "";
|
|
Assert.Equal("/", location);
|
|
|
|
// Verify auth cookie was set
|
|
var setCookieHeader = response.Headers.GetValues("Set-Cookie").FirstOrDefault();
|
|
Assert.NotNull(setCookieHeader);
|
|
Assert.Contains("ZB.MOM.WW.ScadaBridge.Auth", setCookieHeader);
|
|
}
|
|
|
|
/// <summary>Verifies that the login endpoint without an antiforgery token is rejected without an auth cookie.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task LoginEndpoint_WithoutAntiforgeryToken_RejectsWithoutAuthCookie()
|
|
{
|
|
using var client = _factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
|
|
{
|
|
AllowAutoRedirect = false
|
|
});
|
|
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["username"] = "admin", ["password"] = "password"
|
|
});
|
|
using var response = await client.PostAsync("/auth/login", content);
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
if (response.Headers.TryGetValues("Set-Cookie", out var cookies))
|
|
Assert.DoesNotContain(cookies, cookie => cookie.StartsWith(SecurityOptions.DefaultCookieName + "=", StringComparison.Ordinal));
|
|
}
|
|
|
|
private static async Task<FormUrlEncodedContent> CreateLoginFormAsync(HttpClient client, string username, string password)
|
|
{
|
|
// Fetch the actual rendered form. WebApplicationFactory's cookie handler retains
|
|
// its antiforgery cookie, so the POST exercises the same pair as a browser login.
|
|
using var page = await client.GetAsync("/login");
|
|
Assert.Equal(HttpStatusCode.OK, page.StatusCode);
|
|
var html = await page.Content.ReadAsStringAsync();
|
|
var input = Regex.Match(html, """<input\b[^>]*name="__RequestVerificationToken"[^>]*>""", RegexOptions.IgnoreCase);
|
|
Assert.True(input.Success, "The login form must render an antiforgery input.");
|
|
var value = Regex.Match(input.Value, "\\bvalue=\"([^\"]+)\"", RegexOptions.IgnoreCase);
|
|
Assert.True(value.Success, "The login form must provide a nonempty antiforgery token.");
|
|
return new FormUrlEncodedContent(new Dictionary<string, string>
|
|
{
|
|
["username"] = username,
|
|
["password"] = password,
|
|
["__RequestVerificationToken"] = WebUtility.HtmlDecode(value.Groups[1].Value)
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// ScadaBridge#46 against the live GLAuth pair: an active session whose stored groups are stale
|
|
/// is re-read through the real Auth 0.5.0 <c>ILdapGroupLookup</c> (service account), so the
|
|
/// directory's groups replace the stored ones without the user's password; a deleted account
|
|
/// ends the session.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Trait("Category", "Integration")]
|
|
[Fact]
|
|
public async Task ActiveSession_GroupsAreReReadFromTheDirectory_AndAnUnknownUserIsEnded()
|
|
{
|
|
if (!await IsLdapAvailableAsync())
|
|
{
|
|
return; // GLAuth not running locally — see the login test above.
|
|
}
|
|
|
|
var refresher = _factory.Services.GetRequiredService<DirectoryGroupRefresher>();
|
|
Assert.True(refresher.IsActive);
|
|
refresher.Clear();
|
|
|
|
// The GLAuth "designer" account is in SCADA-Designers only; the session claims SCADA-Admins.
|
|
var issued = DateTimeOffset.UtcNow.AddMinutes(-16);
|
|
var stale = SessionClaimBuilder.Build("designer", "designer", ["SCADA-Admins"],
|
|
new RoleMappingResult([Roles.Administrator], [], true), issued);
|
|
|
|
using (var scope = _factory.Services.CreateScope())
|
|
{
|
|
var validator = scope.ServiceProvider.GetRequiredService<CookieSessionValidator>();
|
|
var result = await validator.ValidateAsync(stale);
|
|
|
|
Assert.Equal(SessionValidationAction.Replace, result.Action);
|
|
Assert.Equal(["SCADA-Designers"], SessionClaimBuilder.ReadGroups(result.Principal!));
|
|
Assert.DoesNotContain(Roles.Administrator,
|
|
result.Principal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value));
|
|
}
|
|
|
|
var gone = SessionClaimBuilder.Build("no-such-user-46", "x", ["SCADA-Admins"],
|
|
new RoleMappingResult([Roles.Administrator], [], true), issued);
|
|
using (var scope = _factory.Services.CreateScope())
|
|
{
|
|
var validator = scope.ServiceProvider.GetRequiredService<CookieSessionValidator>();
|
|
Assert.Equal(SessionValidationAction.Reject, (await validator.ValidateAsync(gone)).Action);
|
|
}
|
|
}
|
|
|
|
private static async Task<bool> IsLdapAvailableAsync()
|
|
{
|
|
try
|
|
{
|
|
using var tcp = new System.Net.Sockets.TcpClient();
|
|
await tcp.ConnectAsync("localhost", 3893).WaitAsync(TimeSpan.FromSeconds(2));
|
|
return true;
|
|
}
|
|
catch
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|