124 lines
5.9 KiB
C#
124 lines
5.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Regression tests for CentralUI-017. <c>POST /auth/logout</c> called
|
|
/// <c>.DisableAntiforgery()</c> and a plain <c>GET /logout</c> 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.
|
|
/// </summary>
|
|
public class AuthEndpointsCsrfTests
|
|
{
|
|
private static IReadOnlyList<RouteEndpoint> BuildEndpoints()
|
|
{
|
|
var builder = WebApplication.CreateBuilder();
|
|
builder.Services.AddRouting();
|
|
builder.Services.AddAntiforgery();
|
|
builder.Services.AddSingleton<ZB.MOM.WW.ScadaBridge.Security.SessionActivityTracker>();
|
|
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<RouteEndpoint>()
|
|
.ToList();
|
|
}
|
|
|
|
private static RouteEndpoint? Find(IReadOnlyList<RouteEndpoint> endpoints, string pattern, string method)
|
|
=> endpoints.FirstOrDefault(e =>
|
|
e.RoutePattern.RawText == pattern &&
|
|
(e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains(method) ?? false));
|
|
|
|
[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<IAntiforgeryMetadata>();
|
|
Assert.True(antiforgery is null || antiforgery.RequiresValidation,
|
|
"POST /auth/logout must keep antiforgery validation enabled.");
|
|
}
|
|
|
|
[Fact]
|
|
public void GetLogout_StateChangingRoute_IsRemoved()
|
|
{
|
|
var endpoints = BuildEndpoints();
|
|
var getLogout = Find(endpoints, "/logout", "GET");
|
|
|
|
Assert.Null(getLogout);
|
|
}
|
|
|
|
[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<ILdapAuthService>();
|
|
ldap.AuthenticateAsync("alice", "password", Arg.Any<CancellationToken>())
|
|
.Returns(LdapAuthResult.Success("alice", "Alice", []));
|
|
services.AddSingleton(ldap);
|
|
var mapper = Substitute.For<IGroupRoleMapper<string>>();
|
|
mapper.MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
|
.Returns(new GroupRoleMapping<string>([Roles.Viewer], Scope: null));
|
|
services.AddSingleton(mapper);
|
|
using var provider = services.BuildServiceProvider();
|
|
var tokenContext = new DefaultHttpContext { RequestServices = provider };
|
|
var tokens = provider.GetRequiredService<IAntiforgery>().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="));
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|