bbfeabdaa5
Admin login-lock reset now SCANs and clears all auth:login:* keys (any scope) and surfaces redis_deleted_keys / fallback_deleted_keys back to ops-web so the operator can tell whether the cache was actually hit. Also tracks paired keys via a per-identifier index plus an unlock marker so legacy/in-flight pair locks get cleared on the next acquire. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
329 lines
10 KiB
Go
329 lines
10 KiB
Go
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 TestLoginGuardUnlockClearsPairLocksForIdentifier(t *testing.T) {
|
|
guard, _ := newTestLoginGuard(t, func(cfg *LoginGuardConfig) {
|
|
cfg.FailureIdentifierLimit = 100
|
|
cfg.FailurePairLimit = 2
|
|
cfg.FailureLockBaseTTL = time.Minute
|
|
})
|
|
ctx := context.Background()
|
|
subject := LoginGuardSubject{Identifier: "13003088573", IP: "192.0.2.10"}
|
|
|
|
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)
|
|
|
|
result, err := guard.UnlockWithResult(ctx, subject.Identifier)
|
|
require.NoError(t, err)
|
|
assert.Greater(t, result.RedisDeletedKeys, int64(0))
|
|
|
|
permit, err := guard.Acquire(ctx, subject)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, permit)
|
|
permit.Release(ctx)
|
|
}
|
|
|
|
func TestLoginGuardUnlockMarkerClearsLegacyPairLock(t *testing.T) {
|
|
guard, mr := newTestLoginGuard(t, func(cfg *LoginGuardConfig) {
|
|
cfg.FailureIdentifierLimit = 100
|
|
cfg.FailurePairLimit = 2
|
|
cfg.FailureLockBaseTTL = time.Minute
|
|
})
|
|
ctx := context.Background()
|
|
subject := LoginGuardSubject{Identifier: "admin@geo.local", IP: "192.0.2.20"}
|
|
keys := guard.keysFor(subject)
|
|
|
|
require.NoError(t, mr.Set(keys.lockPair, "2"))
|
|
mr.SetTTL(keys.lockPair, time.Minute)
|
|
|
|
_, err := guard.Acquire(ctx, subject)
|
|
var blocked *LoginGuardBlockedError
|
|
require.ErrorAs(t, err, &blocked)
|
|
assert.Equal(t, LoginGuardBlockedLocked, blocked.Reason)
|
|
|
|
result, err := guard.UnlockWithResult(ctx, subject.Identifier)
|
|
require.NoError(t, err)
|
|
assert.Greater(t, result.RedisDeletedKeys, int64(0))
|
|
require.False(t, mr.Exists(keys.lockPair))
|
|
|
|
permit, err := guard.Acquire(ctx, subject)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, permit)
|
|
permit.Release(ctx)
|
|
}
|
|
|
|
func TestLoginGuardUnlockClearsRequestAndInflightState(t *testing.T) {
|
|
guard, _ := newTestLoginGuard(t, func(cfg *LoginGuardConfig) {
|
|
cfg.RequestIdentifierLimit = 1
|
|
cfg.RequestIdentifierWindow = time.Minute
|
|
})
|
|
ctx := context.Background()
|
|
subject := LoginGuardSubject{Identifier: "13003088573", IP: "192.0.2.30"}
|
|
|
|
permit, err := guard.Acquire(ctx, subject)
|
|
require.NoError(t, err)
|
|
|
|
_, err = guard.Acquire(ctx, subject)
|
|
var blocked *LoginGuardBlockedError
|
|
require.ErrorAs(t, err, &blocked)
|
|
assert.Equal(t, LoginGuardBlockedInProgress, blocked.Reason)
|
|
|
|
result, err := guard.UnlockWithResult(ctx, subject.Identifier)
|
|
require.NoError(t, err)
|
|
assert.Greater(t, result.RedisDeletedKeys, int64(0))
|
|
|
|
permit.Release(ctx)
|
|
next, err := guard.Acquire(ctx, subject)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, next)
|
|
next.Release(ctx)
|
|
}
|
|
|
|
func TestLoginGuardUnlockClearsWholeScopeProtectionCache(t *testing.T) {
|
|
guard, mr := newTestLoginGuard(t, nil)
|
|
ctx := context.Background()
|
|
otherSubject := LoginGuardSubject{Identifier: "other-user", IP: "192.0.2.40"}
|
|
otherKeys := guard.keysFor(otherSubject)
|
|
|
|
require.NoError(t, mr.Set(otherKeys.lockIdentifier, "1"))
|
|
mr.SetTTL(otherKeys.lockIdentifier, time.Minute)
|
|
require.NoError(t, mr.Set(otherKeys.lockPair, "1"))
|
|
mr.SetTTL(otherKeys.lockPair, time.Minute)
|
|
require.NoError(t, mr.Set(otherKeys.requestIdentifier, "99"))
|
|
mr.SetTTL(otherKeys.requestIdentifier, time.Minute)
|
|
require.NoError(t, mr.Set(otherKeys.requestPair, "99"))
|
|
mr.SetTTL(otherKeys.requestPair, time.Minute)
|
|
|
|
result, err := guard.UnlockWithResult(ctx, "13003088573")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(4), result.RedisDeletedKeys)
|
|
|
|
require.False(t, mr.Exists(otherKeys.lockIdentifier))
|
|
require.False(t, mr.Exists(otherKeys.lockPair))
|
|
require.False(t, mr.Exists(otherKeys.requestIdentifier))
|
|
require.False(t, mr.Exists(otherKeys.requestPair))
|
|
}
|
|
|
|
func TestLoginGuardUnlockClearsAllLoginScopes(t *testing.T) {
|
|
guard, mr := newTestLoginGuard(t, nil)
|
|
ctx := context.Background()
|
|
|
|
require.NoError(t, mr.Set("auth:login:tenant:lock:id:one", "1"))
|
|
require.NoError(t, mr.Set("auth:login:ops:lock:id:two", "1"))
|
|
require.NoError(t, mr.Set("auth:login:legacy:lock:id:three", "1"))
|
|
require.NoError(t, mr.Set("other:key", "1"))
|
|
|
|
result, err := guard.UnlockWithResult(ctx, "13003088573")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int64(3), result.RedisDeletedKeys)
|
|
assert.False(t, mr.Exists("auth:login:tenant:lock:id:one"))
|
|
assert.False(t, mr.Exists("auth:login:ops:lock:id:two"))
|
|
assert.False(t, mr.Exists("auth:login:legacy:lock:id:three"))
|
|
assert.True(t, mr.Exists("other:key"))
|
|
assert.Contains(t, result.RedisScanPatterns, "auth:login:*")
|
|
}
|
|
|
|
func TestLoginGuardUnlockClearsFallbackState(t *testing.T) {
|
|
cfg := DefaultLoginGuardConfig("test")
|
|
cfg.RequestPairLimit = 100
|
|
cfg.FailurePairLimit = 2
|
|
cfg.ConcurrentAttemptLockTTL = 10 * time.Second
|
|
guard := NewLoginGuard(nil, cfg)
|
|
ctx := context.Background()
|
|
subject := LoginGuardSubject{Identifier: "13003088573", IP: "192.0.2.50"}
|
|
|
|
permit, err := guard.Acquire(ctx, subject)
|
|
require.NoError(t, err)
|
|
permit.RecordFailure(ctx)
|
|
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)
|
|
|
|
result, err := guard.UnlockWithResult(ctx, subject.Identifier)
|
|
require.NoError(t, err)
|
|
assert.Greater(t, result.FallbackDeletedKeys, 0)
|
|
|
|
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))
|
|
}
|