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; /// Amortization cadence: a full prune sweep runs every Nth failure write /// (plus immediately whenever the map exceeds ), so a /// spray does not pay an O(n) scan — and a possible full sort — on every failed /// request (arch-review R2 N8). Lockout SEMANTICS are unaffected: IsLockedOut reads /// window/lockout expiry directly and never depends on pruning. internal const int PruneEveryNFailures = 64; /// Diagnostics/test accessor: number of currently tracked keys. internal int TrackedKeyCount => _entries.Count; private int _failureWrites; 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). /// /// The username being authenticated. /// The client IP address of the bind attempt. /// true when the key is currently locked out; otherwise false. 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. /// /// The username that failed authentication. /// The client IP address of the failed bind attempt. 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); }); // Amortized prune (R2 N8): every Nth failure, or immediately when over the hard // cap — the cap is the memory-safety invariant and must never wait for the cadence. if (Interlocked.Increment(ref _failureWrites) % PruneEveryNFailures == 0 || _entries.Count > MaxTrackedKeys) { Prune(now); } } /// /// Records a successful bind for the given + /// , clearing any accumulated failure count and lockout for that key. /// /// The username that authenticated successfully. /// The client IP address of the successful bind attempt. 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}"; }