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-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
|
|
||||||
<a-tooltip title="重置登录保护">
|
<a-tooltip title="清除登录锁定">
|
||||||
<a-popconfirm
|
<a-popconfirm
|
||||||
title="确认清除该用户的登录锁定状态?"
|
title="确认清除该用户的登录锁定状态?"
|
||||||
:ok-button-props="{ loading: resetLoginLockLoadingId === record.id }"
|
: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({
|
const filter = reactive({
|
||||||
keyword: '',
|
keyword: '',
|
||||||
status: undefined as string | undefined,
|
status: undefined as string | undefined,
|
||||||
@@ -626,8 +635,14 @@ const resetLoginLockLoadingId = ref<number | null>(null)
|
|||||||
async function resetLoginLock(row: AdminUserRow) {
|
async function resetLoginLock(row: AdminUserRow) {
|
||||||
resetLoginLockLoadingId.value = row.id
|
resetLoginLockLoadingId.value = row.id
|
||||||
try {
|
try {
|
||||||
await http.post(`/admin-users/${row.id}/reset-login-lock`)
|
const result = await http.post<ResetLoginLockResult>(`/admin-users/${row.id}/reset-login-lock`)
|
||||||
message.success('登录保护已重置,用户可立即重新登录')
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof OpsApiError) message.error(error.detail || error.message)
|
if (error instanceof OpsApiError) message.error(error.detail || error.message)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -19,15 +19,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ActionAdminUserCreate = "admin_user.create"
|
ActionAdminUserCreate = "admin_user.create"
|
||||||
ActionAdminUserUpdateProfile = "admin_user.update_profile"
|
ActionAdminUserUpdateProfile = "admin_user.update_profile"
|
||||||
ActionAdminUserChangePlan = "admin_user.change_plan"
|
ActionAdminUserChangePlan = "admin_user.change_plan"
|
||||||
ActionAdminUserChangeRole = "admin_user.change_role"
|
ActionAdminUserChangeRole = "admin_user.change_role"
|
||||||
ActionAdminUserChangeExpiry = "admin_user.change_expiry"
|
ActionAdminUserChangeExpiry = "admin_user.change_expiry"
|
||||||
ActionAdminUserResetUsage = "admin_user.reset_usage"
|
ActionAdminUserResetUsage = "admin_user.reset_usage"
|
||||||
ActionAdminUserSetKOL = "admin_user.set_kol"
|
ActionAdminUserSetKOL = "admin_user.set_kol"
|
||||||
ActionAdminUserDisable = "admin_user.disable"
|
ActionAdminUserDisable = "admin_user.disable"
|
||||||
ActionAdminUserEnable = "admin_user.enable"
|
ActionAdminUserEnable = "admin_user.enable"
|
||||||
ActionAdminUserResetPassword = "admin_user.reset_password"
|
ActionAdminUserResetPassword = "admin_user.reset_password"
|
||||||
ActionAdminUserResetLoginLock = "admin_user.reset_login_lock"
|
ActionAdminUserResetLoginLock = "admin_user.reset_login_lock"
|
||||||
)
|
)
|
||||||
@@ -179,6 +179,15 @@ type ResetAdminUserPlanUsageResult struct {
|
|||||||
Reset ResetPlanUsageView `json:"reset"`
|
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 {
|
func toAdminUserView(u *repository.AdminUserRecord) AdminUserView {
|
||||||
return AdminUserView{
|
return AdminUserView{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
@@ -603,24 +612,31 @@ func (s *AdminUserService) ResetPassword(ctx context.Context, actor *Actor, id i
|
|||||||
return nil
|
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)
|
user, err := s.users.GetByID(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
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 {
|
if s.loginGuard != nil {
|
||||||
_ = s.loginGuard.Unlock(ctx, user.Phone)
|
unlock, err := s.loginGuard.UnlockWithResult(ctx, user.Phone)
|
||||||
if user.Email != nil && *user.Email != "" {
|
if err != nil {
|
||||||
_ = s.loginGuard.Unlock(ctx, *user.Email)
|
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 {
|
if actor != nil {
|
||||||
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetLoginLock, "admin_user", id, nil))
|
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetLoginLock, "admin_user", id, nil))
|
||||||
}
|
}
|
||||||
return nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AdminUserService) mapWriteError(err error) error {
|
func (s *AdminUserService) mapWriteError(err error) error {
|
||||||
|
|||||||
@@ -292,10 +292,11 @@ func resetAdminUserLoginLockHandler(svc *app.AdminUserService) gin.HandlerFunc {
|
|||||||
response.Error(c, err)
|
response.Error(c, err)
|
||||||
return
|
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)
|
response.Error(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
response.Success(c, gin.H{"id": id})
|
response.Success(c, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,14 @@ type LoginGuard struct {
|
|||||||
fallback *memoryLoginGuard
|
fallback *memoryLoginGuard
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LoginGuardUnlockResult struct {
|
||||||
|
RedisDeletedKeys int64
|
||||||
|
RedisScanPatterns []string
|
||||||
|
RedisAddr string
|
||||||
|
RedisDB int
|
||||||
|
FallbackDeletedKeys int
|
||||||
|
}
|
||||||
|
|
||||||
func NewLoginGuard(rdb *redis.Client, cfg LoginGuardConfig) *LoginGuard {
|
func NewLoginGuard(rdb *redis.Client, cfg LoginGuardConfig) *LoginGuard {
|
||||||
cfg = normalizeLoginGuardConfig(cfg)
|
cfg = normalizeLoginGuardConfig(cfg)
|
||||||
return &LoginGuard{
|
return &LoginGuard{
|
||||||
@@ -135,6 +143,7 @@ func (g *LoginGuard) Acquire(ctx context.Context, subject LoginGuardSubject) (*L
|
|||||||
return g.acquireFallback(keys, token)
|
return g.acquireFallback(keys, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pairIndexTTL := g.pairIndexTTL()
|
||||||
result, err := loginGuardAcquireScript.Run(
|
result, err := loginGuardAcquireScript.Run(
|
||||||
ctx,
|
ctx,
|
||||||
g.rdb,
|
g.rdb,
|
||||||
@@ -145,6 +154,10 @@ func (g *LoginGuard) Acquire(ctx context.Context, subject LoginGuardSubject) (*L
|
|||||||
keys.requestIP,
|
keys.requestIP,
|
||||||
keys.requestIdentifier,
|
keys.requestIdentifier,
|
||||||
keys.requestPair,
|
keys.requestPair,
|
||||||
|
keys.pairIndex,
|
||||||
|
keys.unlockIdentifier,
|
||||||
|
keys.failurePair,
|
||||||
|
keys.inflightIdentifier,
|
||||||
},
|
},
|
||||||
token,
|
token,
|
||||||
g.cfg.ConcurrentAttemptLockTTL.Milliseconds(),
|
g.cfg.ConcurrentAttemptLockTTL.Milliseconds(),
|
||||||
@@ -154,6 +167,7 @@ func (g *LoginGuard) Acquire(ctx context.Context, subject LoginGuardSubject) (*L
|
|||||||
g.cfg.RequestIdentifierWindow.Milliseconds(),
|
g.cfg.RequestIdentifierWindow.Milliseconds(),
|
||||||
g.cfg.RequestPairLimit,
|
g.cfg.RequestPairLimit,
|
||||||
g.cfg.RequestPairWindow.Milliseconds(),
|
g.cfg.RequestPairWindow.Milliseconds(),
|
||||||
|
pairIndexTTL.Milliseconds(),
|
||||||
).Slice()
|
).Slice()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return g.acquireFallback(keys, token)
|
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 {
|
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 {
|
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 {
|
if g.rdb == nil {
|
||||||
g.fallback.unlock(keys)
|
return result, nil
|
||||||
return 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 {
|
type loginGuardKeys struct {
|
||||||
@@ -277,6 +316,8 @@ type loginGuardKeys struct {
|
|||||||
lockIdentifier string
|
lockIdentifier string
|
||||||
lockPair string
|
lockPair string
|
||||||
inflightIdentifier string
|
inflightIdentifier string
|
||||||
|
pairIndex string
|
||||||
|
unlockIdentifier string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *LoginGuard) keysFor(subject LoginGuardSubject) loginGuardKeys {
|
func (g *LoginGuard) keysFor(subject LoginGuardSubject) loginGuardKeys {
|
||||||
@@ -297,6 +338,98 @@ func (g *LoginGuard) keysFor(subject LoginGuardSubject) loginGuardKeys {
|
|||||||
lockIdentifier: prefix + "lock:id:" + identifierHash,
|
lockIdentifier: prefix + "lock:id:" + identifierHash,
|
||||||
lockPair: prefix + "lock:pair:" + pairHash,
|
lockPair: prefix + "lock:pair:" + pairHash,
|
||||||
inflightIdentifier: prefix + "inflight:id:" + identifierHash,
|
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 {
|
type memoryLoginGuard struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cfg LoginGuardConfig
|
cfg LoginGuardConfig
|
||||||
requests map[string]memoryLoginCounter
|
requests map[string]memoryLoginCounter
|
||||||
failures map[string]memoryLoginCounter
|
failures map[string]memoryLoginCounter
|
||||||
locks map[string]time.Time
|
locks map[string]time.Time
|
||||||
inflight map[string]memoryLoginInflight
|
inflight map[string]memoryLoginInflight
|
||||||
|
pairIndex map[string]map[string]struct{}
|
||||||
|
unlockMarkers map[string]time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type memoryLoginCounter struct {
|
type memoryLoginCounter struct {
|
||||||
@@ -449,11 +584,13 @@ type memoryLoginPermit struct {
|
|||||||
|
|
||||||
func newMemoryLoginGuard(cfg LoginGuardConfig) *memoryLoginGuard {
|
func newMemoryLoginGuard(cfg LoginGuardConfig) *memoryLoginGuard {
|
||||||
return &memoryLoginGuard{
|
return &memoryLoginGuard{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
requests: make(map[string]memoryLoginCounter),
|
requests: make(map[string]memoryLoginCounter),
|
||||||
failures: make(map[string]memoryLoginCounter),
|
failures: make(map[string]memoryLoginCounter),
|
||||||
locks: make(map[string]time.Time),
|
locks: make(map[string]time.Time),
|
||||||
inflight: make(map[string]memoryLoginInflight),
|
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()
|
g.mu.Lock()
|
||||||
defer g.mu.Unlock()
|
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 {
|
if retryAfter := g.lockRetryAfterLocked(keys.lockIdentifier, now); retryAfter > 0 {
|
||||||
return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedLocked, RetryAfter: retryAfter}
|
return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedLocked, RetryAfter: retryAfter}
|
||||||
}
|
}
|
||||||
if retryAfter := g.lockRetryAfterLocked(keys.lockPair, now); retryAfter > 0 {
|
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 {
|
if retryAfter := g.inflightRetryAfterLocked(keys.inflightIdentifier, now); retryAfter > 0 {
|
||||||
return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedInProgress, RetryAfter: retryAfter}
|
return nil, &LoginGuardBlockedError{Reason: LoginGuardBlockedInProgress, RetryAfter: retryAfter}
|
||||||
@@ -599,6 +748,69 @@ func (g *memoryLoginGuard) unlock(keys loginGuardKeys) {
|
|||||||
defer g.mu.Unlock()
|
defer g.mu.Unlock()
|
||||||
delete(g.locks, keys.lockIdentifier)
|
delete(g.locks, keys.lockIdentifier)
|
||||||
delete(g.failures, keys.failureIdentifier)
|
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) {
|
func (g *memoryLoginGuard) releaseLocked(key, token string) {
|
||||||
@@ -609,6 +821,32 @@ func (g *memoryLoginGuard) releaseLocked(key, token string) {
|
|||||||
delete(g.inflight, key)
|
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(`
|
var loginGuardAcquireScript = redis.NewScript(`
|
||||||
local token = ARGV[1]
|
local token = ARGV[1]
|
||||||
local inflight_ttl = tonumber(ARGV[2])
|
local inflight_ttl = tonumber(ARGV[2])
|
||||||
@@ -618,6 +856,7 @@ local id_limit = tonumber(ARGV[5])
|
|||||||
local id_window = tonumber(ARGV[6])
|
local id_window = tonumber(ARGV[6])
|
||||||
local pair_limit = tonumber(ARGV[7])
|
local pair_limit = tonumber(ARGV[7])
|
||||||
local pair_window = tonumber(ARGV[8])
|
local pair_window = tonumber(ARGV[8])
|
||||||
|
local pair_index_ttl = tonumber(ARGV[9])
|
||||||
|
|
||||||
local function positive_ttl(key, fallback)
|
local function positive_ttl(key, fallback)
|
||||||
local ttl = redis.call("PTTL", key)
|
local ttl = redis.call("PTTL", key)
|
||||||
@@ -627,11 +866,23 @@ local function positive_ttl(key, fallback)
|
|||||||
return ttl
|
return ttl
|
||||||
end
|
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
|
if redis.call("EXISTS", KEYS[1]) == 1 then
|
||||||
return {0, "login_locked", positive_ttl(KEYS[1], inflight_ttl)}
|
return {0, "login_locked", positive_ttl(KEYS[1], inflight_ttl)}
|
||||||
end
|
end
|
||||||
if redis.call("EXISTS", KEYS[2]) == 1 then
|
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
|
end
|
||||||
|
|
||||||
if not redis.call("SET", KEYS[3], token, "PX", inflight_ttl, "NX") then
|
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
|
end
|
||||||
return 0
|
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)
|
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) {
|
func TestLoginGuardSuccessClearsFailureCounters(t *testing.T) {
|
||||||
guard, mr := newTestLoginGuard(t, nil)
|
guard, mr := newTestLoginGuard(t, nil)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
Reference in New Issue
Block a user