feat(auth): add LoginGuard for rate-limit and lockout primitives
Introduce a Redis-backed LoginGuard that combines per-IP, per-identifier and IP+identifier rate limits, exponential lockout on consecutive failures, and a concurrent-attempt lock so a single subject cannot pile up parallel brute-force tries. The guard ships with an in-memory fallback when Redis is unreachable so login protection keeps working under degraded mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newFailingRedisClient(t *testing.T) *goredis.Client {
|
||||
t.Helper()
|
||||
client := goredis.NewClient(&goredis.Options{
|
||||
Addr: "127.0.0.1:1",
|
||||
MaxRetries: 0,
|
||||
DialTimeout: 10 * time.Millisecond,
|
||||
ReadTimeout: 10 * time.Millisecond,
|
||||
WriteTimeout: 10 * time.Millisecond,
|
||||
})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
return client
|
||||
}
|
||||
|
||||
func newTestLoginGuard(t *testing.T, mutate func(*LoginGuardConfig)) (*LoginGuard, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
|
||||
mr := miniredis.RunT(t)
|
||||
client := goredis.NewClient(&goredis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
cfg := DefaultLoginGuardConfig("test")
|
||||
cfg.RequestIPLimit = 100
|
||||
cfg.RequestIdentifierLimit = 100
|
||||
cfg.RequestPairLimit = 100
|
||||
cfg.RequestIPWindow = time.Minute
|
||||
cfg.RequestIdentifierWindow = time.Minute
|
||||
cfg.RequestPairWindow = time.Minute
|
||||
cfg.FailureIdentifierLimit = 100
|
||||
cfg.FailurePairLimit = 100
|
||||
cfg.FailureWindow = 15 * time.Minute
|
||||
cfg.FailureLockBaseTTL = 2 * time.Minute
|
||||
cfg.FailureLockMaxTTL = 10 * time.Minute
|
||||
cfg.ConcurrentAttemptLockTTL = 10 * time.Second
|
||||
if mutate != nil {
|
||||
mutate(&cfg)
|
||||
}
|
||||
|
||||
return NewLoginGuard(client, cfg), mr
|
||||
}
|
||||
|
||||
func TestLoginGuardFallsBackWhenRedisUnavailable(t *testing.T) {
|
||||
cfg := DefaultLoginGuardConfig("test")
|
||||
cfg.RequestPairLimit = 100
|
||||
cfg.FailurePairLimit = 2
|
||||
cfg.ConcurrentAttemptLockTTL = 10 * time.Second
|
||||
guard := NewLoginGuard(newFailingRedisClient(t), cfg)
|
||||
ctx := context.Background()
|
||||
subject := LoginGuardSubject{Identifier: "admin@geo.local", IP: "127.0.0.1"}
|
||||
|
||||
permit, err := guard.Acquire(ctx, subject)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, permit)
|
||||
|
||||
_, err = guard.Acquire(ctx, subject)
|
||||
var blocked *LoginGuardBlockedError
|
||||
require.ErrorAs(t, err, &blocked)
|
||||
assert.Equal(t, LoginGuardBlockedInProgress, blocked.Reason)
|
||||
|
||||
permit.RecordFailure(ctx)
|
||||
permit, err = guard.Acquire(ctx, subject)
|
||||
require.NoError(t, err)
|
||||
permit.RecordFailure(ctx)
|
||||
|
||||
_, err = guard.Acquire(ctx, subject)
|
||||
require.ErrorAs(t, err, &blocked)
|
||||
assert.Equal(t, LoginGuardBlockedLocked, blocked.Reason)
|
||||
}
|
||||
|
||||
func TestLoginGuardRejectsConcurrentIdentifierAttempt(t *testing.T) {
|
||||
guard, _ := newTestLoginGuard(t, nil)
|
||||
ctx := context.Background()
|
||||
subject := LoginGuardSubject{Identifier: "Admin@Geo.Local", IP: "127.0.0.1"}
|
||||
|
||||
permit, err := guard.Acquire(ctx, subject)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, permit)
|
||||
|
||||
_, err = guard.Acquire(ctx, subject)
|
||||
var blocked *LoginGuardBlockedError
|
||||
require.ErrorAs(t, err, &blocked)
|
||||
assert.Equal(t, LoginGuardBlockedInProgress, blocked.Reason)
|
||||
assert.Greater(t, blocked.RetryAfterSeconds(), 0)
|
||||
|
||||
permit.Release(ctx)
|
||||
next, err := guard.Acquire(ctx, subject)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, next)
|
||||
next.Release(ctx)
|
||||
}
|
||||
|
||||
func TestLoginGuardRateLimitsRequestWindow(t *testing.T) {
|
||||
guard, _ := newTestLoginGuard(t, func(cfg *LoginGuardConfig) {
|
||||
cfg.RequestPairLimit = 1
|
||||
cfg.RequestPairWindow = time.Minute
|
||||
})
|
||||
ctx := context.Background()
|
||||
subject := LoginGuardSubject{Identifier: "admin@geo.local", IP: "127.0.0.1"}
|
||||
|
||||
permit, err := guard.Acquire(ctx, subject)
|
||||
require.NoError(t, err)
|
||||
permit.RecordSuccess(ctx)
|
||||
|
||||
_, err = guard.Acquire(ctx, subject)
|
||||
var blocked *LoginGuardBlockedError
|
||||
require.ErrorAs(t, err, &blocked)
|
||||
assert.Equal(t, LoginGuardBlockedRateLimited, blocked.Reason)
|
||||
assert.Greater(t, blocked.RetryAfterSeconds(), 0)
|
||||
}
|
||||
|
||||
func TestLoginGuardLocksAfterFailedAttempts(t *testing.T) {
|
||||
guard, mr := newTestLoginGuard(t, func(cfg *LoginGuardConfig) {
|
||||
cfg.FailurePairLimit = 2
|
||||
cfg.FailureLockBaseTTL = time.Minute
|
||||
})
|
||||
ctx := context.Background()
|
||||
subject := LoginGuardSubject{Identifier: "admin@geo.local", IP: "127.0.0.1"}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
permit, err := guard.Acquire(ctx, subject)
|
||||
require.NoError(t, err)
|
||||
permit.RecordFailure(ctx)
|
||||
}
|
||||
|
||||
_, err := guard.Acquire(ctx, subject)
|
||||
var blocked *LoginGuardBlockedError
|
||||
require.ErrorAs(t, err, &blocked)
|
||||
assert.Equal(t, LoginGuardBlockedLocked, blocked.Reason)
|
||||
|
||||
mr.FastForward(time.Minute + time.Second)
|
||||
permit, err := guard.Acquire(ctx, subject)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, permit)
|
||||
permit.Release(ctx)
|
||||
}
|
||||
|
||||
func TestLoginGuardSuccessClearsFailureCounters(t *testing.T) {
|
||||
guard, mr := newTestLoginGuard(t, nil)
|
||||
ctx := context.Background()
|
||||
subject := LoginGuardSubject{Identifier: "ADMIN@Geo.Local ", IP: "127.0.0.1:43120"}
|
||||
keys := guard.keysFor(subject)
|
||||
|
||||
permit, err := guard.Acquire(ctx, subject)
|
||||
require.NoError(t, err)
|
||||
permit.RecordFailure(ctx)
|
||||
require.True(t, mr.Exists(keys.failureIdentifier))
|
||||
require.True(t, mr.Exists(keys.failurePair))
|
||||
|
||||
permit, err = guard.Acquire(ctx, subject)
|
||||
require.NoError(t, err)
|
||||
permit.RecordSuccess(ctx)
|
||||
|
||||
assert.False(t, mr.Exists(keys.failureIdentifier))
|
||||
assert.False(t, mr.Exists(keys.failurePair))
|
||||
}
|
||||
Reference in New Issue
Block a user