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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user