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,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