The previous Admin-006 commit added <AntiforgeryToken /> to the logout form and updated the comment on the endpoint, but did not update LogoutAsync to actually call IAntiforgery.ValidateRequestAsync. Blazor's UseAntiforgery() middleware does not automatically validate minimal-API endpoints, so a tokenless POST still succeeded. This commit injects IAntiforgery into the handler, wraps ValidateRequestAsync in a try/catch, and returns 400 on AntiforgeryValidationException. The endpoint keeps .DisableAntiforgery() to prevent the middleware from also trying to read the body (which would cause a double-read). The regression test is updated to log in first (to get an authenticated session) before asserting 400 on a tokenless logout POST. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
9.4 KiB
C#
200 lines
9.4 KiB
C#
using System.Net;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Admin.Security;
|
|
using ZB.MOM.WW.OtOpcUa.Admin.Services;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
|
|
|
|
/// <summary>
|
|
/// Regression coverage for Admin-003 / Admin-005.
|
|
///
|
|
/// Admin-005 — the login is a static-rendered form posting to the <c>/auth/login</c>
|
|
/// minimal-API endpoint, which performs the LDAP bind, cookie <c>SignInAsync</c> and
|
|
/// redirect while it still owns the HTTP response (no interactive Blazor circuit).
|
|
///
|
|
/// Admin-003 — the three SignalR hubs reject anonymous connections.
|
|
///
|
|
/// These are HTTP-pipeline tests with a stubbed <see cref="ILdapAuthService"/>, so they
|
|
/// run without LDAP or the central SQL Server.
|
|
/// </summary>
|
|
public sealed class AuthEndpointsTests : IClassFixture<AuthEndpointsTests.StubbedAuthAppFactory>
|
|
{
|
|
private readonly StubbedAuthAppFactory _factory;
|
|
|
|
public AuthEndpointsTests(StubbedAuthAppFactory factory) => _factory = factory;
|
|
|
|
/// <summary>
|
|
/// Admin app host with the LDAP service stubbed (a fixed-credential pass/fail) and the
|
|
/// background poller removed so the host starts clean without DB or directory access.
|
|
/// </summary>
|
|
public sealed class StubbedAuthAppFactory : WebApplicationFactory<Program>
|
|
{
|
|
protected override IHost CreateHost(IHostBuilder builder)
|
|
{
|
|
builder.ConfigureServices(services =>
|
|
{
|
|
var poller = services.SingleOrDefault(d =>
|
|
d.ImplementationType?.Name == "FleetStatusPoller");
|
|
if (poller is not null) services.Remove(poller);
|
|
|
|
var ldap = services.SingleOrDefault(d => d.ServiceType == typeof(ILdapAuthService));
|
|
if (ldap is not null) services.Remove(ldap);
|
|
services.AddScoped<ILdapAuthService, StubLdapAuthService>();
|
|
|
|
var resolver = services.SingleOrDefault(d => d.ServiceType == typeof(IAdminRoleGrantResolver));
|
|
if (resolver is not null) services.Remove(resolver);
|
|
services.AddScoped<IAdminRoleGrantResolver, StubRoleGrantResolver>();
|
|
});
|
|
return base.CreateHost(builder);
|
|
}
|
|
|
|
public HttpClient CreateNonRedirectingClient() =>
|
|
CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
|
}
|
|
|
|
/// <summary>Stub LDAP: <c>good</c>/<c>pw</c> binds; anything else fails.</summary>
|
|
private sealed class StubLdapAuthService : ILdapAuthService
|
|
{
|
|
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default) =>
|
|
Task.FromResult(username == "good" && password == "pw"
|
|
? new LdapAuthResult(true, "Good Operator", "good", ["FleetAdmins"], ["FleetAdmin"], null)
|
|
: new LdapAuthResult(false, null, username, [], [], "Invalid username or password"));
|
|
}
|
|
|
|
/// <summary>Stub resolver: any non-empty group set yields a FleetAdmin grant.</summary>
|
|
private sealed class StubRoleGrantResolver : IAdminRoleGrantResolver
|
|
{
|
|
public Task<AdminRoleGrants> ResolveAsync(IReadOnlyList<string> ldapGroups, CancellationToken cancellationToken) =>
|
|
Task.FromResult(ldapGroups.Count == 0
|
|
? AdminRoleGrants.Empty
|
|
: new AdminRoleGrants([AdminRoles.FleetAdmin], []));
|
|
}
|
|
|
|
private static FormUrlEncodedContent Form(params (string Key, string Value)[] fields) =>
|
|
new(fields.Select(f => new KeyValuePair<string, string>(f.Key, f.Value)));
|
|
|
|
// ── Admin-005: /auth/login endpoint ─────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task Valid_login_issues_the_auth_cookie_and_redirects_home()
|
|
{
|
|
using var client = _factory.CreateNonRedirectingClient();
|
|
|
|
var response = await client.PostAsync("/auth/login",
|
|
Form(("username", "good"), ("password", "pw")));
|
|
|
|
// The endpoint owns the response, so the Set-Cookie header is actually emitted.
|
|
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
|
|
response.Headers.Location!.OriginalString.ShouldBe("/");
|
|
response.Headers.TryGetValues("Set-Cookie", out var cookies).ShouldBeTrue(
|
|
"a successful /auth/login must emit the auth cookie");
|
|
string.Join(';', cookies!).ShouldContain("OtOpcUa.Admin");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Invalid_login_redirects_back_to_login_with_an_error()
|
|
{
|
|
using var client = _factory.CreateNonRedirectingClient();
|
|
|
|
var response = await client.PostAsync("/auth/login",
|
|
Form(("username", "bad"), ("password", "wrong")));
|
|
|
|
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
|
|
response.Headers.Location!.OriginalString.ShouldContain("/login");
|
|
response.Headers.Location!.OriginalString.ShouldContain("error");
|
|
response.Headers.TryGetValues("Set-Cookie", out var cookies);
|
|
(cookies is null || !string.Join(';', cookies).Contains("OtOpcUa.Admin")).ShouldBeTrue(
|
|
"a failed bind must not issue the auth cookie");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Login_with_missing_credentials_redirects_back_to_login()
|
|
{
|
|
using var client = _factory.CreateNonRedirectingClient();
|
|
|
|
var response = await client.PostAsync("/auth/login", Form(("username", ""), ("password", "")));
|
|
|
|
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
|
|
response.Headers.Location!.OriginalString.ShouldContain("/login");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Login_redirect_target_is_open_redirect_safe()
|
|
{
|
|
using var client = _factory.CreateNonRedirectingClient();
|
|
|
|
// A returnUrl pointing off-site must be ignored — the post lands at the site root.
|
|
var response = await client.PostAsync("/auth/login",
|
|
Form(("username", "good"), ("password", "pw"), ("returnUrl", "https://evil.example.com/")));
|
|
|
|
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
|
|
response.Headers.Location!.OriginalString.ShouldBe("/");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Login_honours_a_local_return_url()
|
|
{
|
|
using var client = _factory.CreateNonRedirectingClient();
|
|
|
|
var response = await client.PostAsync("/auth/login",
|
|
Form(("username", "good"), ("password", "pw"), ("returnUrl", "/fleet")));
|
|
|
|
response.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
|
|
response.Headers.Location!.OriginalString.ShouldBe("/fleet");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Logout_with_valid_session_but_no_antiforgery_token_is_rejected()
|
|
{
|
|
// Admin-006: the logout endpoint no longer calls .DisableAntiforgery(), so the
|
|
// UseAntiforgery() middleware must reject a POST that carries no token with 400.
|
|
// This regression guards against CSRF-logout (attacker tricking the operator's
|
|
// already-authenticated browser into posting to /auth/logout from a foreign origin).
|
|
//
|
|
// To reach the antiforgery check we need an authenticated session — an
|
|
// unauthenticated POST is redirected to /login before the check is reached.
|
|
// We obtain the auth cookie via a valid /auth/login round-trip first.
|
|
using var client = _factory.CreateNonRedirectingClient();
|
|
|
|
// Step 1: log in to get the session cookie.
|
|
var loginResponse = await client.PostAsync("/auth/login",
|
|
Form(("username", "good"), ("password", "pw")));
|
|
loginResponse.StatusCode.ShouldBeOneOf(HttpStatusCode.Redirect, HttpStatusCode.Found);
|
|
|
|
// The cookie jar on the client now holds the auth cookie; subsequent requests are
|
|
// authenticated. Step 2: POST to /auth/logout without an antiforgery token.
|
|
var logoutResponse = await client.PostAsync("/auth/logout",
|
|
new FormUrlEncodedContent(Array.Empty<KeyValuePair<string, string>>()));
|
|
|
|
// The antiforgery middleware must reject the missing token with 400.
|
|
logoutResponse.StatusCode.ShouldBe(HttpStatusCode.BadRequest,
|
|
"/auth/logout from an authenticated session without an antiforgery token must be rejected (Admin-006)");
|
|
}
|
|
|
|
// ── Admin-003: SignalR hubs reject anonymous connections ────────────────────
|
|
|
|
[Theory]
|
|
[InlineData("/hubs/fleet")]
|
|
[InlineData("/hubs/alerts")]
|
|
[InlineData("/hubs/script-log")]
|
|
public async Task Anonymous_hub_negotiate_is_rejected(string hubPath)
|
|
{
|
|
using var client = _factory.CreateNonRedirectingClient();
|
|
|
|
// The SignalR negotiate handshake is a POST to <hub>/negotiate. An [Authorize]'d hub
|
|
// must refuse it for an unauthenticated caller (302 to login or 401).
|
|
var response = await client.PostAsync($"{hubPath}/negotiate",
|
|
new FormUrlEncodedContent(Array.Empty<KeyValuePair<string, string>>()));
|
|
|
|
response.StatusCode.ShouldNotBe(HttpStatusCode.OK,
|
|
$"anonymous negotiate of {hubPath} must not succeed — the hub is [Authorize]-gated");
|
|
response.StatusCode.ShouldBeOneOf(
|
|
HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden,
|
|
HttpStatusCode.Redirect, HttpStatusCode.Found);
|
|
}
|
|
}
|