diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/LoginThrottle.cs b/src/ZB.MOM.WW.ScadaBridge.Security/LoginThrottle.cs
new file mode 100644
index 00000000..e890b557
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Security/LoginThrottle.cs
@@ -0,0 +1,166 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Options;
+
+namespace ZB.MOM.WW.ScadaBridge.Security;
+
+///
+/// Fixed-window LDAP-bind failure lockout, keyed per {username}|{ip}.
+///
+///
+///
+/// Every LDAP-bind surface (the Central UI /auth/login and /auth/token
+/// endpoints, and the ManagementAuthenticator-fronted POST /management, audit REST,
+/// and debug hub) records bind outcomes here and consults before
+/// attempting a bind. This blunts online password-spray / brute-force against the directory
+/// without any external state — it is an in-memory, best-effort throttle per central node
+/// (the two central nodes maintain independent counters; that is acceptable, since a
+/// determined spray hitting both nodes still burns its budget twice as fast on each).
+///
+///
+/// The counter uses a fixed window: the first failure opens a window of
+/// ; reaching
+/// failures inside that window locks
+/// the key out for . A successful bind or an
+/// expired window resets the counter. Setting
+/// to 0 disables throttling entirely.
+///
+///
+/// Registered as a singleton () so the
+/// counters are shared across all requests on a node. Thread-safe via
+/// and atomic entry swaps. Memory is bounded:
+/// each write opportunistically prunes expired entries, and if the map still exceeds
+/// the oldest-windowed entries are dropped — so a spray across a
+/// large IP/username space cannot grow the map without limit.
+///
+///
+public sealed class LoginThrottle
+{
+ ///
+ /// Hard cap on the number of tracked {username}|{ip} keys. Above this the
+ /// oldest-windowed entries are dropped on write to bound memory under a spray attack.
+ ///
+ public const int MaxTrackedKeys = 10_000;
+
+ private readonly TimeProvider _clock;
+ private readonly IOptions _options;
+
+ private readonly ConcurrentDictionary _entries = new(StringComparer.Ordinal);
+
+ ///
+ /// One tracked key's state: the failure , the
+ /// that anchors the fixed failure window, and — once the
+ /// key is locked — the instant the lockout expires.
+ ///
+ private readonly record struct Entry(int Count, DateTimeOffset WindowStart, DateTimeOffset? LockedUntil);
+
+ /// Creates the throttle over the injected clock and security options.
+ /// Time source (wall clock in production; a fake in tests).
+ /// Security options carrying the throttle thresholds.
+ public LoginThrottle(TimeProvider clock, IOptions options)
+ {
+ _clock = clock;
+ _options = options;
+ }
+
+ ///
+ /// Returns true when the given + pair is
+ /// currently locked out and a bind must be refused without contacting LDAP. Never mutates
+ /// state (an expired lockout simply reads as unlocked; the entry is cleared on the next write).
+ ///
+ public bool IsLockedOut(string username, string ip)
+ {
+ var opts = _options.Value;
+ if (opts.MaxLoginFailuresPerWindow <= 0) return false; // throttling disabled
+
+ if (!_entries.TryGetValue(Key(username, ip), out var entry)) return false;
+
+ return entry.LockedUntil is { } until && until > _clock.GetUtcNow();
+ }
+
+ ///
+ /// Records a failed bind for the given + .
+ /// Opens or advances the fixed window and, on reaching the configured threshold, arms the
+ /// lockout. No-op when throttling is disabled.
+ ///
+ public void RecordFailure(string username, string ip)
+ {
+ var opts = _options.Value;
+ if (opts.MaxLoginFailuresPerWindow <= 0) return; // throttling disabled
+
+ var now = _clock.GetUtcNow();
+ var window = TimeSpan.FromMinutes(opts.LoginFailureWindowMinutes);
+ var lockout = TimeSpan.FromMinutes(opts.LoginLockoutMinutes);
+ var key = Key(username, ip);
+
+ _entries.AddOrUpdate(
+ key,
+ // First observed failure for this key: open a fresh window.
+ _ => Arm(new Entry(1, now, null), opts, now, lockout),
+ (_, existing) =>
+ {
+ // A live lockout stands until it expires — further failures don't extend it.
+ if (existing.LockedUntil is { } until && until > now)
+ {
+ return existing;
+ }
+
+ // If the current window has lapsed (or a prior lockout has expired), restart it.
+ if (existing.LockedUntil is not null || now - existing.WindowStart >= window)
+ {
+ return Arm(new Entry(1, now, null), opts, now, lockout);
+ }
+
+ return Arm(existing with { Count = existing.Count + 1 }, opts, now, lockout);
+ });
+
+ Prune(now);
+ }
+
+ ///
+ /// Records a successful bind for the given +
+ /// , clearing any accumulated failure count and lockout for that key.
+ ///
+ public void RecordSuccess(string username, string ip)
+ {
+ _entries.TryRemove(Key(username, ip), out _);
+ }
+
+ /// Arms the lockout on if it has reached the threshold.
+ private static Entry Arm(Entry entry, SecurityOptions opts, DateTimeOffset now, TimeSpan lockout) =>
+ entry.Count >= opts.MaxLoginFailuresPerWindow
+ ? entry with { LockedUntil = now + lockout }
+ : entry;
+
+ ///
+ /// Opportunistic memory management on write: drop entries whose window has fully expired
+ /// (no active lockout), and if the map still exceeds evict the
+ /// oldest-windowed entries so a spray across many keys cannot grow the map unbounded.
+ ///
+ private void Prune(DateTimeOffset now)
+ {
+ var opts = _options.Value;
+ var window = TimeSpan.FromMinutes(opts.LoginFailureWindowMinutes);
+
+ foreach (var kvp in _entries)
+ {
+ var e = kvp.Value;
+ var lockActive = e.LockedUntil is { } until && until > now;
+ if (!lockActive && now - e.WindowStart >= window)
+ {
+ _entries.TryRemove(kvp);
+ }
+ }
+
+ if (_entries.Count <= MaxTrackedKeys) return;
+
+ // Still over the cap after pruning expired entries — evict the oldest-windowed keys.
+ var excess = _entries.Count - MaxTrackedKeys;
+ foreach (var kvp in _entries.OrderBy(k => k.Value.WindowStart).Take(excess))
+ {
+ _entries.TryRemove(kvp);
+ }
+ }
+
+ private static string Key(string username, string ip) =>
+ $"{(username ?? string.Empty).ToLowerInvariant()}|{ip ?? string.Empty}";
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs
index 2a119baa..ac574715 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs
@@ -104,6 +104,26 @@ public class SecurityOptions
/// . Changing this invalidates existing sessions on next deploy.
///
public string CookieName { get; set; } = DefaultCookieName;
+
+ ///
+ /// Maximum failed LDAP binds tolerated per fixed window for a
+ /// single {username}|{ip} pair before the pair is locked out. Default: 5.
+ /// Set to 0 to disable login throttling entirely.
+ ///
+ public int MaxLoginFailuresPerWindow { get; set; } = 5;
+
+ ///
+ /// The fixed-window length in minutes. Failures older than this
+ /// (measured from the window's first failure) no longer count toward the lockout threshold.
+ /// Default: 5.
+ ///
+ public int LoginFailureWindowMinutes { get; set; } = 5;
+
+ ///
+ /// How long, in minutes, a {username}|{ip} pair stays locked out once it reaches
+ /// failures within the window. Default: 5.
+ ///
+ public int LoginLockoutMinutes { get; set; } = 5;
}
///
@@ -137,6 +157,32 @@ public sealed class SecurityOptionsValidator : Microsoft.Extensions.Options.IVal
$"enforcement is defeated.");
}
+ // SECURITY: login-throttle thresholds must be non-negative. 0 for
+ // MaxLoginFailuresPerWindow is the documented "disable throttling" escape hatch;
+ // a negative value (or a negative window/lockout) is a misconfiguration that would
+ // make the fixed-window math ill-defined, so it fails fast at boot.
+ if (options.MaxLoginFailuresPerWindow < 0)
+ {
+ return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
+ $"{nameof(SecurityOptions.MaxLoginFailuresPerWindow)} " +
+ $"({options.MaxLoginFailuresPerWindow}) must be non-negative " +
+ $"(0 disables login throttling).");
+ }
+
+ if (options.LoginFailureWindowMinutes < 0)
+ {
+ return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
+ $"{nameof(SecurityOptions.LoginFailureWindowMinutes)} " +
+ $"({options.LoginFailureWindowMinutes}) must be non-negative.");
+ }
+
+ if (options.LoginLockoutMinutes < 0)
+ {
+ return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
+ $"{nameof(SecurityOptions.LoginLockoutMinutes)} " +
+ $"({options.LoginLockoutMinutes}) must be non-negative.");
+ }
+
return Microsoft.Extensions.Options.ValidateOptionsResult.Success;
}
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs
index 40178054..c2ed6e86 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs
@@ -99,6 +99,12 @@ public static class ServiceCollectionExtensions
services.AddOptions().ValidateOnStart();
services.AddSingleton, SecurityOptionsValidator>();
+ // Fixed-window LDAP-bind failure lockout (arch-review P1). A singleton so its
+ // per-{username|ip} failure counters are shared across every request on this node;
+ // consulted before, and updated after, every LDAP-bind surface (login/token endpoints
+ // and the ManagementAuthenticator-fronted management/audit/debug paths).
+ services.AddSingleton();
+
// Note: the old SecurityOptionsValidator (which fail-fast-validated LdapServer +
// LdapSearchBase) is gone — those keys moved into the shared LdapOptions, whose
// LdapOptionsValidator (registered with ValidateOnStart by AddZbLdapAuth above)
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs
new file mode 100644
index 00000000..c25c6679
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LoginThrottleTests.cs
@@ -0,0 +1,113 @@
+using Microsoft.Extensions.Options;
+using ZB.MOM.WW.ScadaBridge.Security;
+
+namespace ZB.MOM.WW.ScadaBridge.Security.Tests;
+
+///
+/// Exercises : the fixed-window per-(username+IP)
+/// LDAP-bind failure lockout. Time is driven by a hand-rolled controllable
+/// (the central package list has no
+/// Microsoft.Extensions.TimeProvider.Testing FakeTimeProvider — mirroring the
+/// CookieSessionValidator + Transport tests, which hand-roll the same clock).
+///
+public class LoginThrottleTests
+{
+ private static readonly DateTimeOffset Start = new(2026, 6, 15, 12, 0, 0, TimeSpan.Zero);
+
+ private sealed class TestTimeProvider : TimeProvider
+ {
+ private DateTimeOffset _now;
+ public TestTimeProvider(DateTimeOffset start) => _now = start;
+ public override DateTimeOffset GetUtcNow() => _now;
+ public void Advance(TimeSpan by) => _now = _now.Add(by);
+ }
+
+ private static LoginThrottle Create(TimeProvider clock, SecurityOptions? options = null) =>
+ new(clock, Options.Create(options ?? new SecurityOptions()));
+
+ [Fact]
+ public void FiveFailuresInWindow_LocksOut_ThenWindowExpiryUnlocks()
+ {
+ var clock = new TestTimeProvider(Start);
+ var throttle = Create(clock);
+
+ for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "10.0.0.1");
+
+ Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));
+ Assert.False(throttle.IsLockedOut("alice", "10.0.0.2")); // per username+IP key
+ Assert.False(throttle.IsLockedOut("bob", "10.0.0.1"));
+
+ clock.Advance(TimeSpan.FromMinutes(6)); // lockout window passed
+ Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
+ }
+
+ [Fact]
+ public void SuccessResets()
+ {
+ var clock = new TestTimeProvider(Start);
+ var throttle = Create(clock);
+
+ for (var i = 0; i < 4; i++) throttle.RecordFailure("alice", "10.0.0.1");
+ Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
+
+ throttle.RecordSuccess("alice", "10.0.0.1");
+
+ // A single failure after the reset must NOT lock out — the counter restarted.
+ throttle.RecordFailure("alice", "10.0.0.1");
+ Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
+ }
+
+ [Fact]
+ public void KeyIsCaseInsensitiveOnUsername()
+ {
+ var clock = new TestTimeProvider(Start);
+ var throttle = Create(clock);
+
+ for (var i = 0; i < 5; i++) throttle.RecordFailure("Alice", "10.0.0.1");
+
+ Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));
+ Assert.True(throttle.IsLockedOut("ALICE", "10.0.0.1"));
+ }
+
+ [Fact]
+ public void FailuresOutsideWindow_DoNotAccumulate()
+ {
+ var clock = new TestTimeProvider(Start);
+ var throttle = Create(clock);
+
+ // Four failures, then let the failure window lapse before the fifth.
+ for (var i = 0; i < 4; i++) throttle.RecordFailure("alice", "10.0.0.1");
+ clock.Advance(TimeSpan.FromMinutes(6)); // > LoginFailureWindowMinutes (5)
+ throttle.RecordFailure("alice", "10.0.0.1");
+
+ // The window reset, so only one failure is counted — no lockout.
+ Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
+ }
+
+ [Fact]
+ public void LockoutPersistsUntilLockoutMinutesElapse()
+ {
+ var clock = new TestTimeProvider(Start);
+ var throttle = Create(clock);
+
+ for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "10.0.0.1");
+ Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));
+
+ clock.Advance(TimeSpan.FromMinutes(4)); // still inside the 5-minute lockout
+ Assert.True(throttle.IsLockedOut("alice", "10.0.0.1"));
+
+ clock.Advance(TimeSpan.FromMinutes(2)); // now past the 5-minute lockout
+ Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
+ }
+
+ [Fact]
+ public void ZeroMaxFailures_DisablesThrottling()
+ {
+ var clock = new TestTimeProvider(Start);
+ var throttle = Create(clock, new SecurityOptions { MaxLoginFailuresPerWindow = 0 });
+
+ for (var i = 0; i < 100; i++) throttle.RecordFailure("alice", "10.0.0.1");
+
+ Assert.False(throttle.IsLockedOut("alice", "10.0.0.1"));
+ }
+}