feat(security): wire LoginThrottle into management Basic-Auth and /auth/login|token — password-spray guard (arch-review P1/UA5)

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 05:13:59 -04:00
parent d4d1732a9e
commit 7af36fd5dc
5 changed files with 143 additions and 0 deletions
@@ -96,6 +96,10 @@ public class AuditEndpointsTests
services.AddSingleton(repo);
services.AddSingleton(ldap);
services.AddSingleton(roleMapper);
// ManagementAuthenticator now consults the login throttle before the bind.
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
services.AddSingleton<LoginThrottle>();
});
web.Configure(app =>
{
@@ -652,6 +656,10 @@ public class AuditEndpointsTests
services.AddSingleton(repo);
services.AddSingleton(ldap);
services.AddSingleton(roleMapper);
// ManagementAuthenticator now consults the login throttle before the bind.
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
services.AddSingleton<LoginThrottle>();
});
web.Configure(app =>
{
@@ -825,6 +833,10 @@ public class AuditEndpointsTests
services.AddSingleton(repo);
services.AddSingleton(ldap);
services.AddSingleton(roleMapper);
// ManagementAuthenticator now consults the login throttle before the bind.
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
services.AddSingleton<LoginThrottle>();
});
web.Configure(app =>
{
@@ -1,6 +1,9 @@
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.Security.Auth;
@@ -93,4 +96,81 @@ public class ManagementAuthenticatorTests
Assert.Null(outcome.User);
Assert.NotNull(outcome.Failure);
}
// --- Login-throttle wiring (arch-review P1/UA5) -------------------------------------------
/// <summary>
/// Builds a real Basic-Auth request context wired with a <see cref="LoginThrottle"/>
/// (over the injected <paramref name="clock"/>) and the supplied LDAP mock, with the given
/// remote IP. Returns the context plus the resolvable throttle so tests can seed failures.
/// </summary>
private static (HttpContext Context, LoginThrottle Throttle) BasicAuthContext(
string username,
string password,
ILdapAuthService ldap,
TimeProvider clock,
string remoteIp = "127.0.0.1")
{
var services = new ServiceCollection();
services.AddSingleton<IOptions<AuthDisableLoginOptions>>(
Options.Create(new AuthDisableLoginOptions { DisableLogin = false }));
services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
services.AddSingleton(clock);
services.AddSingleton<LoginThrottle>();
services.AddSingleton(ldap);
services.AddLogging(); // JsonHttpResult.ExecuteAsync resolves ILoggerFactory
var provider = services.BuildServiceProvider();
var context = new DefaultHttpContext { RequestServices = provider };
var creds = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
context.Request.Headers.Authorization = $"Basic {creds}";
context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse(remoteIp);
context.Response.Body = new MemoryStream();
return (context, provider.GetRequiredService<LoginThrottle>());
}
private static async Task<(int Status, string Body)> ExecuteAsync(HttpContext context, IResult result)
{
await result.ExecuteAsync(context);
context.Response.Body.Position = 0;
using var reader = new StreamReader(context.Response.Body);
var body = await reader.ReadToEndAsync();
return (context.Response.StatusCode, body);
}
[Fact]
public async Task AuthenticateAsync_LockedOutKey_Returns429WithoutLdapBind()
{
var ldap = Substitute.For<ILdapAuthService>();
var (context, throttle) = BasicAuthContext("alice", "wrong", ldap, TimeProvider.System);
for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "127.0.0.1");
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
Assert.Null(outcome.User);
Assert.NotNull(outcome.Failure);
var (status, body) = await ExecuteAsync(context, outcome.Failure!);
Assert.Equal(429, status);
Assert.Contains("AUTH_THROTTLED", body);
await ldap.DidNotReceiveWithAnyArgs().AuthenticateAsync(default!, default!, default);
}
[Fact]
public async Task AuthenticateAsync_FailedBind_RecordsFailure()
{
var ldap = Substitute.For<ILdapAuthService>();
ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
var (context, throttle) = BasicAuthContext("alice", "wrong", ldap, TimeProvider.System);
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
// The bind failed → a 401 is returned AND the failure is recorded on the throttle.
Assert.Null(outcome.User);
Assert.NotNull(outcome.Failure);
Assert.False(throttle.IsLockedOut("alice", "127.0.0.1"));
for (var i = 0; i < 4; i++) throttle.RecordFailure("alice", "127.0.0.1");
Assert.True(throttle.IsLockedOut("alice", "127.0.0.1"));
}
}