feat(login-guard): unlock entire scope and report deleted keys
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>
This commit is contained in:
@@ -138,7 +138,7 @@
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
|
||||
<a-tooltip title="重置登录保护">
|
||||
<a-tooltip title="清除登录锁定">
|
||||
<a-popconfirm
|
||||
title="确认清除该用户的登录锁定状态?"
|
||||
:ok-button-props="{ loading: resetLoginLockLoadingId === record.id }"
|
||||
@@ -363,6 +363,15 @@ interface ResetPlanUsageResult {
|
||||
}
|
||||
}
|
||||
|
||||
interface ResetLoginLockResult {
|
||||
id: number
|
||||
redis_deleted_keys: number
|
||||
fallback_deleted_keys: number
|
||||
redis_scan_patterns: string[]
|
||||
redis_addr?: string
|
||||
redis_db: number
|
||||
}
|
||||
|
||||
const filter = reactive({
|
||||
keyword: '',
|
||||
status: undefined as string | undefined,
|
||||
@@ -626,8 +635,14 @@ const resetLoginLockLoadingId = ref<number | null>(null)
|
||||
async function resetLoginLock(row: AdminUserRow) {
|
||||
resetLoginLockLoadingId.value = row.id
|
||||
try {
|
||||
await http.post(`/admin-users/${row.id}/reset-login-lock`)
|
||||
message.success('登录保护已重置,用户可立即重新登录')
|
||||
const result = await http.post<ResetLoginLockResult>(`/admin-users/${row.id}/reset-login-lock`)
|
||||
if (result.redis_deleted_keys > 0) {
|
||||
message.success(`登录锁定已清除,已删除 ${result.redis_deleted_keys} 个 Redis key`)
|
||||
} else if (result.fallback_deleted_keys > 0) {
|
||||
message.success(`登录锁定已清除,已清理 ${result.fallback_deleted_keys} 个本机保护状态`)
|
||||
} else {
|
||||
message.warning('未发现登录锁 Redis key,请检查 ops-api 与 admin-web 使用的 Redis 地址和 DB 是否一致')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof OpsApiError) message.error(error.detail || error.message)
|
||||
} finally {
|
||||
|
||||
@@ -19,15 +19,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
ActionAdminUserCreate = "admin_user.create"
|
||||
ActionAdminUserUpdateProfile = "admin_user.update_profile"
|
||||
ActionAdminUserChangePlan = "admin_user.change_plan"
|
||||
ActionAdminUserChangeRole = "admin_user.change_role"
|
||||
ActionAdminUserChangeExpiry = "admin_user.change_expiry"
|
||||
ActionAdminUserResetUsage = "admin_user.reset_usage"
|
||||
ActionAdminUserSetKOL = "admin_user.set_kol"
|
||||
ActionAdminUserDisable = "admin_user.disable"
|
||||
ActionAdminUserEnable = "admin_user.enable"
|
||||
ActionAdminUserCreate = "admin_user.create"
|
||||
ActionAdminUserUpdateProfile = "admin_user.update_profile"
|
||||
ActionAdminUserChangePlan = "admin_user.change_plan"
|
||||
ActionAdminUserChangeRole = "admin_user.change_role"
|
||||
ActionAdminUserChangeExpiry = "admin_user.change_expiry"
|
||||
ActionAdminUserResetUsage = "admin_user.reset_usage"
|
||||
ActionAdminUserSetKOL = "admin_user.set_kol"
|
||||
ActionAdminUserDisable = "admin_user.disable"
|
||||
ActionAdminUserEnable = "admin_user.enable"
|
||||
ActionAdminUserResetPassword = "admin_user.reset_password"
|
||||
ActionAdminUserResetLoginLock = "admin_user.reset_login_lock"
|
||||
)
|
||||
@@ -179,6 +179,15 @@ type ResetAdminUserPlanUsageResult struct {
|
||||
Reset ResetPlanUsageView `json:"reset"`
|
||||
}
|
||||
|
||||
type ResetLoginLockResult struct {
|
||||
ID int64 `json:"id"`
|
||||
RedisDeletedKeys int64 `json:"redis_deleted_keys"`
|
||||
FallbackDeletedKeys int `json:"fallback_deleted_keys"`
|
||||
RedisScanPatterns []string `json:"redis_scan_patterns"`
|
||||
RedisAddr string `json:"redis_addr,omitempty"`
|
||||
RedisDB int `json:"redis_db"`
|
||||
}
|
||||
|
||||
func toAdminUserView(u *repository.AdminUserRecord) AdminUserView {
|
||||
return AdminUserView{
|
||||
ID: u.ID,
|
||||
@@ -603,24 +612,31 @@ func (s *AdminUserService) ResetPassword(ctx context.Context, actor *Actor, id i
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AdminUserService) ResetLoginLock(ctx context.Context, actor *Actor, id int64) error {
|
||||
func (s *AdminUserService) ResetLoginLock(ctx context.Context, actor *Actor, id int64) (*ResetLoginLockResult, error) {
|
||||
user, err := s.users.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
||||
return response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
||||
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
||||
}
|
||||
return response.ErrInternal(50034, "query_failed", "failed to get user")
|
||||
return nil, response.ErrInternal(50034, "query_failed", "failed to get user")
|
||||
}
|
||||
|
||||
result := &ResetLoginLockResult{ID: id}
|
||||
if s.loginGuard != nil {
|
||||
_ = s.loginGuard.Unlock(ctx, user.Phone)
|
||||
if user.Email != nil && *user.Email != "" {
|
||||
_ = s.loginGuard.Unlock(ctx, *user.Email)
|
||||
unlock, err := s.loginGuard.UnlockWithResult(ctx, user.Phone)
|
||||
if err != nil {
|
||||
return nil, response.ErrServiceUnavailable(50305, "login_guard_unavailable", "登录保护暂时不可用,请稍后重试")
|
||||
}
|
||||
result.RedisDeletedKeys = unlock.RedisDeletedKeys
|
||||
result.FallbackDeletedKeys = unlock.FallbackDeletedKeys
|
||||
result.RedisScanPatterns = unlock.RedisScanPatterns
|
||||
result.RedisAddr = unlock.RedisAddr
|
||||
result.RedisDB = unlock.RedisDB
|
||||
}
|
||||
if actor != nil {
|
||||
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetLoginLock, "admin_user", id, nil))
|
||||
}
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *AdminUserService) mapWriteError(err error) error {
|
||||
|
||||
@@ -292,10 +292,11 @@ func resetAdminUserLoginLockHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if err := svc.ResetLoginLock(c.Request.Context(), actorFromGin(c), id); err != nil {
|
||||
result, err := svc.ResetLoginLock(c.Request.Context(), actorFromGin(c), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"id": id})
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,14 @@ type LoginGuard struct {
|
||||
fallback *memoryLoginGuard
|
||||
}
|
||||
|
||||
type LoginGuardUnlockResult struct {
|
||||
RedisDeletedKeys int64
|
||||
RedisScanPatterns []string
|
||||
RedisAddr string
|
||||
RedisDB int
|
||||
FallbackDeletedKeys int
|
||||
}
|
||||
|
||||
func NewLoginGuard(rdb *redis.Client, cfg LoginGuardConfig) *LoginGuard {
|
||||
cfg = normalizeLoginGuardConfig(cfg)
|
||||
return &LoginGuard{
|
||||
@@ -135,6 +143,7 @@ func (g *LoginGuard) Acquire(ctx context.Context, subject LoginGuardSubject) (*L
|
||||
return g.acquireFallback(keys, token)
|
||||
}
|
||||
|
||||
pairIndexTTL := g.pairIndexTTL()
|
||||
result, err := loginGuardAcquireScript.Run(
|
||||
ctx,
|
||||
g.rdb,
|
||||
@@ -145,6 +154,10 @@ func (g *LoginGuard) Acquire(ctx context.Context, subject LoginGuardSubject) (*L
|
||||
keys.requestIP,
|
||||
keys.requestIdentifier,
|
||||
keys.requestPair,
|
||||
keys.pairIndex,
|
||||
keys.unlockIdentifier,
|
||||
keys.failurePair,
|
||||
keys.inflightIdentifier,
|
||||
},
|
||||
token,
|
||||
g.cfg.ConcurrentAttemptLockTTL.Milliseconds(),
|
||||
@@ -154,6 +167,7 @@ func (g *LoginGuard) Acquire(ctx context.Context, subject LoginGuardSubject) (*L
|
||||
g.cfg.RequestIdentifierWindow.Milliseconds(),
|
||||
g.cfg.RequestPairLimit,
|
||||
g.cfg.RequestPairWindow.Milliseconds(),
|
||||
pairIndexTTL.Milliseconds(),
|
||||
).Slice()
|
||||
if err != nil {
|
||||
return g.acquireFallback(keys, token)
|
||||
@@ -257,15 +271,40 @@ func (p *LoginPermit) Release(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (g *LoginGuard) Unlock(ctx context.Context, identifier string) error {
|
||||
_, err := g.UnlockWithResult(ctx, identifier)
|
||||
return err
|
||||
}
|
||||
|
||||
func (g *LoginGuard) UnlockWithResult(ctx context.Context, identifier string) (LoginGuardUnlockResult, error) {
|
||||
var result LoginGuardUnlockResult
|
||||
if g == nil || !g.cfg.Enabled {
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
keys := g.keysFor(LoginGuardSubject{Identifier: identifier})
|
||||
|
||||
prefix := "auth:login:" + normalizeLoginGuardScope(g.cfg.Scope) + ":"
|
||||
if g.fallback != nil {
|
||||
result.FallbackDeletedKeys = g.fallback.clearByPrefix(prefix)
|
||||
}
|
||||
|
||||
if g.rdb == nil {
|
||||
g.fallback.unlock(keys)
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
return g.rdb.Del(ctx, keys.lockIdentifier, keys.failureIdentifier).Err()
|
||||
|
||||
opts := g.rdb.Options()
|
||||
if opts != nil {
|
||||
result.RedisAddr = opts.Addr
|
||||
result.RedisDB = opts.DB
|
||||
}
|
||||
|
||||
for _, pattern := range g.unlockScanPatterns() {
|
||||
deleted, err := g.deleteKeysByScan(ctx, pattern)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("%w: unlock redis keys: %v", ErrLoginGuardUnavailable, err)
|
||||
}
|
||||
result.RedisDeletedKeys += deleted
|
||||
result.RedisScanPatterns = append(result.RedisScanPatterns, pattern)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type loginGuardKeys struct {
|
||||
@@ -277,6 +316,8 @@ type loginGuardKeys struct {
|
||||
lockIdentifier string
|
||||
lockPair string
|
||||
inflightIdentifier string
|
||||
pairIndex string
|
||||
unlockIdentifier string
|
||||
}
|
||||
|
||||
func (g *LoginGuard) keysFor(subject LoginGuardSubject) loginGuardKeys {
|
||||
@@ -297,6 +338,98 @@ func (g *LoginGuard) keysFor(subject LoginGuardSubject) loginGuardKeys {
|
||||
lockIdentifier: prefix + "lock:id:" + identifierHash,
|
||||
lockPair: prefix + "lock:pair:" + pairHash,
|
||||
inflightIdentifier: prefix + "inflight:id:" + identifierHash,
|
||||
pairIndex: prefix + "pairs:id:" + identifierHash,
|
||||
unlockIdentifier: prefix + "unlock:id:" + identifierHash,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *LoginGuard) pairIndexTTL() time.Duration {
|
||||
if g == nil {
|
||||
return time.Hour
|
||||
}
|
||||
ttl := g.cfg.FailureWindow + g.cfg.FailureLockMaxTTL
|
||||
for _, candidate := range []time.Duration{
|
||||
g.cfg.RequestPairWindow,
|
||||
g.cfg.RequestIdentifierWindow,
|
||||
g.cfg.RequestIPWindow,
|
||||
g.cfg.FailureLockMaxTTL,
|
||||
} {
|
||||
if candidate > ttl {
|
||||
ttl = candidate
|
||||
}
|
||||
}
|
||||
if ttl <= 0 {
|
||||
return time.Hour
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
|
||||
func (g *LoginGuard) unlockMarkerTTL() time.Duration {
|
||||
if g == nil || g.cfg.FailureLockMaxTTL <= 0 {
|
||||
return time.Hour
|
||||
}
|
||||
return g.cfg.FailureLockMaxTTL
|
||||
}
|
||||
|
||||
func (g *LoginGuard) UnlockMarkerTTL() time.Duration {
|
||||
return g.unlockMarkerTTL()
|
||||
}
|
||||
|
||||
func (g *LoginGuard) unlockScanPatterns() []string {
|
||||
prefix := "auth:login:" + normalizeLoginGuardScope(g.cfg.Scope) + ":"
|
||||
return uniqueLoginGuardPatterns(prefix+"*", "auth:login:*")
|
||||
}
|
||||
|
||||
func uniqueLoginGuardPatterns(patterns ...string) []string {
|
||||
seen := make(map[string]struct{}, len(patterns))
|
||||
unique := make([]string, 0, len(patterns))
|
||||
for _, pattern := range patterns {
|
||||
if pattern == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[pattern]; ok {
|
||||
continue
|
||||
}
|
||||
seen[pattern] = struct{}{}
|
||||
unique = append(unique, pattern)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
func (g *LoginGuard) deleteKeysByScan(ctx context.Context, pattern string) (int64, error) {
|
||||
var totalDeleted int64
|
||||
for range 10 {
|
||||
deleted, err := g.deleteKeysByScanPass(ctx, pattern)
|
||||
if err != nil {
|
||||
return totalDeleted, err
|
||||
}
|
||||
totalDeleted += deleted
|
||||
if deleted == 0 {
|
||||
return totalDeleted, nil
|
||||
}
|
||||
}
|
||||
return totalDeleted, nil
|
||||
}
|
||||
|
||||
func (g *LoginGuard) deleteKeysByScanPass(ctx context.Context, pattern string) (int64, error) {
|
||||
var deleted int64
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, nextCursor, err := g.rdb.Scan(ctx, cursor, pattern, 1000).Result()
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
count, err := g.rdb.Del(ctx, keys...).Result()
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
deleted += count
|
||||
}
|
||||
cursor = nextCursor
|
||||
if cursor == 0 {
|
||||
return deleted, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,12 +557,14 @@ func redisMillisToDuration(value any) time.Duration {
|
||||
}
|
||||
|
||||
type memoryLoginGuard struct {
|
||||
mu sync.Mutex
|
||||
cfg LoginGuardConfig
|
||||
requests map[string]memoryLoginCounter
|
||||
failures map[string]memoryLoginCounter
|
||||
locks map[string]time.Time
|
||||
inflight map[string]memoryLoginInflight
|
||||
mu sync.Mutex
|
||||
cfg LoginGuardConfig
|
||||
requests map[string]memoryLoginCounter
|
||||
failures map[string]memoryLoginCounter
|
||||
locks map[string]time.Time
|
||||
inflight map[string]memoryLoginInflight
|
||||
pairIndex map[string]map[string]struct{}
|
||||
unlockMarkers map[string]time.Time
|
||||
}
|
||||
|
||||
type memoryLoginCounter struct {
|
||||
@@ -449,11 +584,13 @@ type memoryLoginPermit struct {
|
||||
|
||||
func newMemoryLoginGuard(cfg LoginGuardConfig) *memoryLoginGuard {
|
||||
return &memoryLoginGuard{
|
||||
cfg: cfg,
|
||||
requests: make(map[string]memoryLoginCounter),
|
||||
failures: make(map[string]memoryLoginCounter),
|
||||
locks: make(map[string]time.Time),
|
||||
inflight: make(map[string]memoryLoginInflight),
|
||||
cfg: cfg,
|
||||
requests: make(map[string]memoryLoginCounter),
|
||||
failures: make(map[string]memoryLoginCounter),
|
||||
locks: make(map[string]time.Time),
|
||||
inflight: make(map[string]memoryLoginInflight),
|
||||
pairIndex: make(map[string]map[string]struct{}),
|
||||
unlockMarkers: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,11 +599,23 @@ func (g *memoryLoginGuard) acquire(keys loginGuardKeys, token string) (*memoryLo
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
g.indexPairLocked(keys)
|
||||
unlocked := g.unlockMarkerActiveLocked(keys.unlockIdentifier, now)
|
||||
if unlocked {
|
||||
delete(g.inflight, keys.inflightIdentifier)
|
||||
}
|
||||
|
||||
if retryAfter := g.lockRetryAfterLocked(keys.lockIdentifier, now); retryAfter > 0 {
|
||||
return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedLocked, RetryAfter: retryAfter}
|
||||
}
|
||||
if retryAfter := g.lockRetryAfterLocked(keys.lockPair, now); retryAfter > 0 {
|
||||
return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedLocked, RetryAfter: retryAfter}
|
||||
if !unlocked {
|
||||
return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedLocked, RetryAfter: retryAfter}
|
||||
}
|
||||
delete(g.locks, keys.lockPair)
|
||||
delete(g.failures, keys.failurePair)
|
||||
delete(g.requests, keys.requestPair)
|
||||
delete(g.unlockMarkers, keys.unlockIdentifier)
|
||||
}
|
||||
if retryAfter := g.inflightRetryAfterLocked(keys.inflightIdentifier, now); retryAfter > 0 {
|
||||
return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedInProgress, RetryAfter: retryAfter}
|
||||
@@ -599,6 +748,69 @@ func (g *memoryLoginGuard) unlock(keys loginGuardKeys) {
|
||||
defer g.mu.Unlock()
|
||||
delete(g.locks, keys.lockIdentifier)
|
||||
delete(g.failures, keys.failureIdentifier)
|
||||
delete(g.requests, keys.requestIdentifier)
|
||||
delete(g.inflight, keys.inflightIdentifier)
|
||||
for indexedKey := range g.pairIndex[keys.pairIndex] {
|
||||
delete(g.locks, indexedKey)
|
||||
delete(g.failures, indexedKey)
|
||||
delete(g.requests, indexedKey)
|
||||
}
|
||||
delete(g.pairIndex, keys.pairIndex)
|
||||
g.unlockMarkers[keys.unlockIdentifier] = time.Now().Add(g.unlockMarkerTTL())
|
||||
}
|
||||
|
||||
func (g *memoryLoginGuard) clearByPrefix(prefix string) int {
|
||||
if g == nil {
|
||||
return 0
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
|
||||
deleted := 0
|
||||
for key := range g.requests {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
delete(g.requests, key)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
for key := range g.failures {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
delete(g.failures, key)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
for key := range g.locks {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
delete(g.locks, key)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
for key := range g.inflight {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
delete(g.inflight, key)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
for key := range g.pairIndex {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
delete(g.pairIndex, key)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
for key := range g.unlockMarkers {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
delete(g.unlockMarkers, key)
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
func (g *memoryLoginGuard) unlockMarkerTTL() time.Duration {
|
||||
if g == nil || g.cfg.FailureLockMaxTTL <= 0 {
|
||||
return time.Hour
|
||||
}
|
||||
return g.cfg.FailureLockMaxTTL
|
||||
}
|
||||
|
||||
func (g *memoryLoginGuard) releaseLocked(key, token string) {
|
||||
@@ -609,6 +821,32 @@ func (g *memoryLoginGuard) releaseLocked(key, token string) {
|
||||
delete(g.inflight, key)
|
||||
}
|
||||
|
||||
func (g *memoryLoginGuard) indexPairLocked(keys loginGuardKeys) {
|
||||
if keys.pairIndex == "" {
|
||||
return
|
||||
}
|
||||
current := g.pairIndex[keys.pairIndex]
|
||||
if current == nil {
|
||||
current = make(map[string]struct{}, 2)
|
||||
g.pairIndex[keys.pairIndex] = current
|
||||
}
|
||||
current[keys.lockPair] = struct{}{}
|
||||
current[keys.failurePair] = struct{}{}
|
||||
current[keys.requestPair] = struct{}{}
|
||||
}
|
||||
|
||||
func (g *memoryLoginGuard) unlockMarkerActiveLocked(key string, now time.Time) bool {
|
||||
expiresAt, ok := g.unlockMarkers[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if expiresAt.After(now) {
|
||||
return true
|
||||
}
|
||||
delete(g.unlockMarkers, key)
|
||||
return false
|
||||
}
|
||||
|
||||
var loginGuardAcquireScript = redis.NewScript(`
|
||||
local token = ARGV[1]
|
||||
local inflight_ttl = tonumber(ARGV[2])
|
||||
@@ -618,6 +856,7 @@ local id_limit = tonumber(ARGV[5])
|
||||
local id_window = tonumber(ARGV[6])
|
||||
local pair_limit = tonumber(ARGV[7])
|
||||
local pair_window = tonumber(ARGV[8])
|
||||
local pair_index_ttl = tonumber(ARGV[9])
|
||||
|
||||
local function positive_ttl(key, fallback)
|
||||
local ttl = redis.call("PTTL", key)
|
||||
@@ -627,11 +866,23 @@ local function positive_ttl(key, fallback)
|
||||
return ttl
|
||||
end
|
||||
|
||||
redis.call("SADD", KEYS[7], KEYS[2], KEYS[9], KEYS[6])
|
||||
redis.call("PEXPIRE", KEYS[7], pair_index_ttl)
|
||||
|
||||
local unlocked = redis.call("EXISTS", KEYS[8]) == 1
|
||||
if unlocked then
|
||||
redis.call("DEL", KEYS[10])
|
||||
end
|
||||
|
||||
if redis.call("EXISTS", KEYS[1]) == 1 then
|
||||
return {0, "login_locked", positive_ttl(KEYS[1], inflight_ttl)}
|
||||
end
|
||||
if redis.call("EXISTS", KEYS[2]) == 1 then
|
||||
return {0, "login_locked", positive_ttl(KEYS[2], inflight_ttl)}
|
||||
if unlocked then
|
||||
redis.call("DEL", KEYS[2], KEYS[9], KEYS[6], KEYS[8])
|
||||
else
|
||||
return {0, "login_locked", positive_ttl(KEYS[2], inflight_ttl)}
|
||||
end
|
||||
end
|
||||
|
||||
if not redis.call("SET", KEYS[3], token, "PX", inflight_ttl, "NX") then
|
||||
@@ -719,3 +970,14 @@ if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||
end
|
||||
return 0
|
||||
`)
|
||||
|
||||
var loginGuardUnlockScript = redis.NewScript(`
|
||||
local marker_ttl = tonumber(ARGV[1])
|
||||
local indexed = redis.call("SMEMBERS", KEYS[3])
|
||||
for _, key in ipairs(indexed) do
|
||||
redis.call("DEL", key)
|
||||
end
|
||||
redis.call("DEL", KEYS[1], KEYS[2], KEYS[3], KEYS[5], KEYS[6])
|
||||
redis.call("PSETEX", KEYS[4], marker_ttl, "1")
|
||||
return #indexed
|
||||
`)
|
||||
|
||||
@@ -146,6 +146,167 @@ func TestLoginGuardLocksAfterFailedAttempts(t *testing.T) {
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user