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:
@@ -192,3 +192,12 @@ Role checks are expressed as named ASP.NET Core authorization policies (in `Auth
|
||||
- **All central components**: Role checks are a cross-cutting concern applied at the API layer.
|
||||
- **Management Service**: The ManagementActor enforces role-based authorization on every incoming command using the authenticated user identity carried in the message envelope. The CLI authenticates users via the same LDAP bind mechanism and passes the user's identity (username, roles, permitted sites) in every request message. The ManagementActor applies the same role and site-scoping rules as the Central UI — no separate authentication path exists on the server side.
|
||||
- **Transport (#24)**: Provides the `RequireDesign` policy (export) and `RequireAdmin` policy (import) enforced at both the Razor page layer and inside the `ZB.MOM.WW.ScadaBridge.Transport` service entrypoints.
|
||||
|
||||
## Login throttling
|
||||
|
||||
To blunt online password-spray and brute-force against the directory, every LDAP-bind surface consults a shared in-memory throttle (`LoginThrottle`, a `ZB.MOM.WW.ScadaBridge.Security` singleton) before attempting a bind, and records the bind outcome afterwards.
|
||||
|
||||
- **Scope — every LDAP-bind surface**: the Central UI interactive login (`POST /auth/login`) and CLI token issue (`POST /auth/token`), plus the HTTP-Basic management/CLI surfaces fronted by `ManagementAuthenticator` — `POST /management`, the audit REST endpoints, and the debug-stream hub. No bind happens outside these paths, so throttling coverage is complete. The dev/test `DisableLogin` bypass sits **above** the throttle: a login-disabled deployment never binds, so it is never throttled.
|
||||
- **Key and window**: counters are keyed per `{username}|{IP}` pair (lower-cased username; remote IP from the connection). A fixed window of `LoginFailureWindowMinutes` (default 5) opens on the first failure; reaching `MaxLoginFailuresPerWindow` failures (default 5) within it locks that key out for `LoginLockoutMinutes` (default 5). A successful bind clears the key; an expired window resets the count. Setting `MaxLoginFailuresPerWindow` to `0` disables throttling entirely.
|
||||
- **Behaviour when locked**: the surface refuses the bind without contacting LDAP. The Basic-Auth surfaces (`ManagementAuthenticator`) and `POST /auth/token` return HTTP `429` with `code = "AUTH_THROTTLED"`; `POST /auth/login` redirects to `/login` with a "Too many failed attempts. Try again later." message.
|
||||
- **Best-effort, per-node**: the throttle is in-memory with no external state. The two central nodes maintain independent counters (a spray hitting both simply burns its budget on each). Memory is bounded — writes prune expired keys and cap the tracked-key count. This is a rate-limit guard, not an account-lockout policy; it never disables the directory account itself.
|
||||
|
||||
@@ -33,17 +33,30 @@ public static class AuthEndpoints
|
||||
return;
|
||||
}
|
||||
|
||||
// Login throttle: refuse a bind while this username+IP key is locked out
|
||||
// (fixed-window password-spray guard, arch-review P1/UA5).
|
||||
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
|
||||
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
|
||||
if (throttle.IsLockedOut(username, remoteIp!))
|
||||
{
|
||||
context.Response.Redirect("/login?error=Too+many+failed+attempts.+Try+again+later.");
|
||||
return;
|
||||
}
|
||||
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
|
||||
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
throttle.RecordFailure(username, remoteIp!);
|
||||
var errorMsg = Uri.EscapeDataString(LdapAuthFailureMessages.ToMessage(authResult.Failure));
|
||||
context.Response.Redirect($"/login?error={errorMsg}");
|
||||
return;
|
||||
}
|
||||
|
||||
throttle.RecordSuccess(username, remoteIp!);
|
||||
|
||||
// Map LDAP groups to roles via the shared IGroupRoleMapper<string> seam
|
||||
// (ScadaBridgeGroupRoleMapper, wrapping the DB-backed RoleMapper).
|
||||
// The full RoleMappingResult — including PermittedSiteIds and the
|
||||
@@ -107,6 +120,17 @@ public static class AuthEndpoints
|
||||
return Results.Json(new { error = "Username and password are required." }, statusCode: 400);
|
||||
}
|
||||
|
||||
// Login throttle: refuse a bind while this username+IP key is locked out
|
||||
// (fixed-window password-spray guard, arch-review P1/UA5).
|
||||
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
|
||||
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
|
||||
if (throttle.IsLockedOut(username, remoteIp!))
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
|
||||
statusCode: 429);
|
||||
}
|
||||
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
|
||||
var jwtService = context.RequestServices.GetRequiredService<JwtTokenService>();
|
||||
var roleMapper = context.RequestServices.GetRequiredService<IGroupRoleMapper<string>>();
|
||||
@@ -114,11 +138,14 @@ public static class AuthEndpoints
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
throttle.RecordFailure(username, remoteIp!);
|
||||
return Results.Json(
|
||||
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure) },
|
||||
statusCode: 401);
|
||||
}
|
||||
|
||||
throttle.RecordSuccess(username, remoteIp!);
|
||||
|
||||
var roleMapping = await roleMapper.MapAsync(authResult.Groups, context.RequestAborted);
|
||||
|
||||
// Guard the opaque-Scope unwrap (review I4); see the matching note on
|
||||
|
||||
@@ -104,15 +104,30 @@ public static class ManagementAuthenticator
|
||||
new { error = "Username and password are required.", code = "AUTH_FAILED" }, statusCode: 401));
|
||||
}
|
||||
|
||||
// 1a. Login throttle: refuse a bind while this username+IP key is locked out
|
||||
// (fixed-window password-spray guard, arch-review P1/UA5). Consulted BELOW the
|
||||
// DisableLogin bypass — a login-disabled deployment never binds, so it never throttles.
|
||||
var throttle = context.RequestServices.GetRequiredService<LoginThrottle>();
|
||||
var remoteIp = context.Connection.RemoteIpAddress?.ToString();
|
||||
if (throttle.IsLockedOut(username, remoteIp!))
|
||||
{
|
||||
return new AuthOutcome(null, Results.Json(
|
||||
new { error = "Too many failed authentication attempts. Try again later.", code = "AUTH_THROTTLED" },
|
||||
statusCode: 429));
|
||||
}
|
||||
|
||||
// 2. LDAP authentication
|
||||
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
|
||||
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
throttle.RecordFailure(username, remoteIp!);
|
||||
return new AuthOutcome(null, Results.Json(
|
||||
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" }, statusCode: 401));
|
||||
}
|
||||
|
||||
throttle.RecordSuccess(username, remoteIp!);
|
||||
|
||||
// 3. Role resolution
|
||||
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
|
||||
var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, context.RequestAborted);
|
||||
|
||||
@@ -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