feat(security): LoginThrottle — per-username+IP LDAP-bind failure lockout (arch-review P1)
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-window LDAP-bind failure lockout, keyed per <c>{username}|{ip}</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every LDAP-bind surface (the Central UI <c>/auth/login</c> and <c>/auth/token</c>
|
||||
/// endpoints, and the ManagementAuthenticator-fronted <c>POST /management</c>, audit REST,
|
||||
/// and debug hub) records bind outcomes here and consults <see cref="IsLockedOut"/> 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).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The counter uses a <b>fixed window</b>: the first failure opens a window of
|
||||
/// <see cref="SecurityOptions.LoginFailureWindowMinutes"/>; reaching
|
||||
/// <see cref="SecurityOptions.MaxLoginFailuresPerWindow"/> failures inside that window locks
|
||||
/// the key out for <see cref="SecurityOptions.LoginLockoutMinutes"/>. A successful bind or an
|
||||
/// expired window resets the counter. Setting <see cref="SecurityOptions.MaxLoginFailuresPerWindow"/>
|
||||
/// to 0 disables throttling entirely.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Registered as a singleton (<see cref="ServiceCollectionExtensions.AddSecurity"/>) so the
|
||||
/// counters are shared across all requests on a node. Thread-safe via
|
||||
/// <see cref="ConcurrentDictionary{TKey,TValue}"/> and atomic entry swaps. Memory is bounded:
|
||||
/// each write opportunistically prunes expired entries, and if the map still exceeds
|
||||
/// <see cref="MaxTrackedKeys"/> the oldest-windowed entries are dropped — so a spray across a
|
||||
/// large IP/username space cannot grow the map without limit.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class LoginThrottle
|
||||
{
|
||||
/// <summary>
|
||||
/// Hard cap on the number of tracked <c>{username}|{ip}</c> keys. Above this the
|
||||
/// oldest-windowed entries are dropped on write to bound memory under a spray attack.
|
||||
/// </summary>
|
||||
public const int MaxTrackedKeys = 10_000;
|
||||
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly IOptions<SecurityOptions> _options;
|
||||
|
||||
private readonly ConcurrentDictionary<string, Entry> _entries = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// One tracked key's state: the failure <paramref name="Count"/>, the
|
||||
/// <paramref name="WindowStart"/> that anchors the fixed failure window, and — once the
|
||||
/// key is locked — the <paramref name="LockedUntil"/> instant the lockout expires.
|
||||
/// </summary>
|
||||
private readonly record struct Entry(int Count, DateTimeOffset WindowStart, DateTimeOffset? LockedUntil);
|
||||
|
||||
/// <summary>Creates the throttle over the injected clock and security options.</summary>
|
||||
/// <param name="clock">Time source (wall clock in production; a fake in tests).</param>
|
||||
/// <param name="options">Security options carrying the throttle thresholds.</param>
|
||||
public LoginThrottle(TimeProvider clock, IOptions<SecurityOptions> options)
|
||||
{
|
||||
_clock = clock;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the given <paramref name="username"/> + <paramref name="ip"/> 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).
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a failed bind for the given <paramref name="username"/> + <paramref name="ip"/>.
|
||||
/// Opens or advances the fixed window and, on reaching the configured threshold, arms the
|
||||
/// lockout. No-op when throttling is disabled.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a successful bind for the given <paramref name="username"/> +
|
||||
/// <paramref name="ip"/>, clearing any accumulated failure count and lockout for that key.
|
||||
/// </summary>
|
||||
public void RecordSuccess(string username, string ip)
|
||||
{
|
||||
_entries.TryRemove(Key(username, ip), out _);
|
||||
}
|
||||
|
||||
/// <summary>Arms the lockout on <paramref name="entry"/> if it has reached the threshold.</summary>
|
||||
private static Entry Arm(Entry entry, SecurityOptions opts, DateTimeOffset now, TimeSpan lockout) =>
|
||||
entry.Count >= opts.MaxLoginFailuresPerWindow
|
||||
? entry with { LockedUntil = now + lockout }
|
||||
: entry;
|
||||
|
||||
/// <summary>
|
||||
/// Opportunistic memory management on write: drop entries whose window has fully expired
|
||||
/// (no active lockout), and if the map still exceeds <see cref="MaxTrackedKeys"/> evict the
|
||||
/// oldest-windowed entries so a spray across many keys cannot grow the map unbounded.
|
||||
/// </summary>
|
||||
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}";
|
||||
}
|
||||
@@ -104,6 +104,26 @@ public class SecurityOptions
|
||||
/// <see cref="DefaultCookieName"/>. Changing this invalidates existing sessions on next deploy.
|
||||
/// </summary>
|
||||
public string CookieName { get; set; } = DefaultCookieName;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum failed LDAP binds tolerated per <see cref="LoginThrottle"/> fixed window for a
|
||||
/// single <c>{username}|{ip}</c> pair before the pair is locked out. Default: <b>5</b>.
|
||||
/// Set to <b>0</b> to disable login throttling entirely.
|
||||
/// </summary>
|
||||
public int MaxLoginFailuresPerWindow { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="LoginThrottle"/> fixed-window length in minutes. Failures older than this
|
||||
/// (measured from the window's first failure) no longer count toward the lockout threshold.
|
||||
/// Default: <b>5</b>.
|
||||
/// </summary>
|
||||
public int LoginFailureWindowMinutes { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// How long, in minutes, a <c>{username}|{ip}</c> pair stays locked out once it reaches
|
||||
/// <see cref="MaxLoginFailuresPerWindow"/> failures within the window. Default: <b>5</b>.
|
||||
/// </summary>
|
||||
public int LoginLockoutMinutes { get; set; } = 5;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,12 @@ public static class ServiceCollectionExtensions
|
||||
services.AddOptions<SecurityOptions>().ValidateOnStart();
|
||||
services.AddSingleton<IValidateOptions<SecurityOptions>, 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<LoginThrottle>();
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises <see cref="LoginThrottle"/>: the fixed-window per-(username+IP)
|
||||
/// LDAP-bind failure lockout. Time is driven by a hand-rolled controllable
|
||||
/// <see cref="TimeProvider"/> (the central package list has no
|
||||
/// Microsoft.Extensions.TimeProvider.Testing FakeTimeProvider — mirroring the
|
||||
/// CookieSessionValidator + Transport tests, which hand-roll the same clock).
|
||||
/// </summary>
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user