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}";
}