"""登录失败限流。 键 `ys:login:fail:{username}` 记录连续失败次数,TTL 等于锁定时长。 达到阈值后即使密码正确也拒绝登录,须等锁定期结束。 **为什么按用户名而非 IP**:本系统用户是确定的几十个营销人员, 按用户名限流才能防住针对性撞库;按 IP 会被内网 NAT 和攻击者换 IP 绕过。 代价是攻击者可故意把他人账号锁 15 分钟 —— 该风险在内部系统中可接受, 且管理员可通过 `clear()` 手动解锁。 """ from app.core.config import get_settings from app.core.constants import REDIS_KEY_LOGIN_FAIL from app.core.redis_client import get_redis def _key(username: str) -> str: return REDIS_KEY_LOGIN_FAIL.format(username=username) def failure_count(username: str) -> int: """当前连续失败次数。键不存在时为 0。""" value = get_redis().get(_key(username)) return int(value) if value else 0 def is_locked(username: str) -> bool: """账号当前是否处于锁定期。""" return failure_count(username) >= get_settings().login_fail_limit def remaining_lock_seconds(username: str) -> int: """锁定期剩余秒数。未锁定时为 0。""" ttl = get_redis().ttl(_key(username)) return ttl if ttl and ttl > 0 else 0 def record_failure(username: str) -> int: """记录一次登录失败,返回累计次数。 每次失败都刷新 TTL,形成**滑动窗口**:任意 15 分钟区间内累计 5 次即锁定。 若不刷新,则"4 次失败 + 等 14 分钟 + 1 次失败"就不会触发锁定, 正好给撞库留出缝隙。 """ key = _key(username) redis = get_redis() count = int(redis.incr(key)) redis.expire(key, get_settings().login_lock_minutes * 60) return count def clear(username: str) -> None: """登录成功后清除失败计数。""" get_redis().delete(_key(username))