using NSubstitute; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.ScadaBridge.Security; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth; /// /// Regression tests for the logout CSRF fix. POST /auth/logout called /// .DisableAntiforgery() and a plain GET /logout route also /// signed the user out — either could be triggered cross-site to forcibly log /// a user out. Logout is a state-changing authenticated action and must be /// CSRF-protected: the POST keeps antiforgery enabled and the state-changing /// GET route is removed. /// public class AuthEndpointsCsrfTests { private static IReadOnlyList BuildEndpoints() { var builder = WebApplication.CreateBuilder(); builder.Services.AddRouting(); builder.Services.AddAntiforgery(); builder.Services.AddSingleton(); builder.Services.AddSingleton(TimeProvider.System); // Dispose the host: an undisposed WebApplication leaks its config // PhysicalFileProvider (appsettings reload-watch FileSystemWatcher — a // process-wide macOS run-loop thread) and a ConsoleLoggerProcessor // thread, which keep the test host process alive after the run. using var app = builder.Build(); app.MapAuthEndpoints(); return ((IEndpointRouteBuilder)app).DataSources .SelectMany(ds => ds.Endpoints) .OfType() .ToList(); } private static RouteEndpoint? Find(IReadOnlyList endpoints, string pattern, string method) => endpoints.FirstOrDefault(e => e.RoutePattern.RawText == pattern && (e.Metadata.GetMetadata()?.HttpMethods.Contains(method) ?? false)); /// Verifies that the logout POST route does not disable antiforgery validation. [Fact] public void PostAuthLogout_DoesNotDisableAntiforgery() { var endpoints = BuildEndpoints(); var logout = Find(endpoints, "/auth/logout", "POST"); Assert.NotNull(logout); // DisableAntiforgery() leaves an IAntiforgeryMetadata with // RequiresValidation == false. A CSRF-protected POST has either no such // metadata, or metadata that still requires validation. var antiforgery = logout!.Metadata.GetMetadata(); Assert.True(antiforgery is null || antiforgery.RequiresValidation, "POST /auth/logout must keep antiforgery validation enabled."); } /// Verifies that the state-changing GET logout route has been removed. [Fact] public void GetLogout_StateChangingRoute_IsRemoved() { var endpoints = BuildEndpoints(); var getLogout = Find(endpoints, "/logout", "GET"); Assert.Null(getLogout); } /// Verifies that a login POST with a valid antiforgery token authenticates the user and sets the session cookie. /// A task that represents the asynchronous operation. [Fact] public async Task PostAuthLogin_ValidTokenAuthenticatesAndSetsCookie() { var endpoint = Find(BuildEndpoints(), "/auth/login", "POST")!; var services = new ServiceCollection(); services.AddLogging(); services.AddAntiforgery(); services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(); services.AddSingleton(new LoginThrottle(TimeProvider.System, Options.Create(new SecurityOptions()))); var ldap = Substitute.For(); ldap.AuthenticateAsync("alice", "password", Arg.Any()) .Returns(LdapAuthResult.Success("alice", "Alice", [])); services.AddSingleton(ldap); var mapper = Substitute.For>(); mapper.MapAsync(Arg.Any>(), Arg.Any()) .Returns(new GroupRoleMapping([Roles.Viewer], Scope: null)); services.AddSingleton(mapper); using var provider = services.BuildServiceProvider(); var tokenContext = new DefaultHttpContext { RequestServices = provider }; var tokens = provider.GetRequiredService().GetAndStoreTokens(tokenContext); var cookie = tokenContext.Response.Headers.SetCookie.Single()!.Split(';')[0]; var context = new DefaultHttpContext { RequestServices = provider }; context.Request.Headers.Cookie = cookie; context.Request.Method = "POST"; context.Request.ContentType = "application/x-www-form-urlencoded"; var form = $"username=alice&password=password&{tokens.FormFieldName}={Uri.EscapeDataString(tokens.RequestToken!)}"; context.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(form)); await endpoint.RequestDelegate!(context); Assert.Equal(StatusCodes.Status302Found, context.Response.StatusCode); Assert.Equal("/", context.Response.Headers.Location.ToString()); Assert.Contains(context.Response.Headers.SetCookie, value => value!.StartsWith(".AspNetCore.Cookies=")); } /// Verifies that a login POST with a missing antiforgery token is rejected before any credential lookup occurs. /// A task that represents the asynchronous operation. [Fact] public async Task PostAuthLogin_MissingTokenRejectsBeforeCredentialLookup() { var endpoint = Find(BuildEndpoints(), "/auth/login", "POST")!; var services = new ServiceCollection(); services.AddLogging(); services.AddAntiforgery(); using var provider = services.BuildServiceProvider(); var context = new DefaultHttpContext { RequestServices = provider }; context.Request.Method = "POST"; context.Request.ContentType = "application/x-www-form-urlencoded"; context.Request.Body = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("username=test&password=password")); await endpoint.RequestDelegate!(context); Assert.Equal(StatusCodes.Status400BadRequest, context.Response.StatusCode); } }