58 lines
2.1 KiB
Python
58 lines
2.1 KiB
Python
"""登录失败限流。
|
||
|
||
键 `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)
|
||
# INCR 和 EXPIRE 必须同事务提交。否则两步之间断连会留下无 TTL 的计数,
|
||
# 该用户随后会被永久锁定;pipeline(transaction=True) 用 MULTI/EXEC 原子生效。
|
||
pipe = get_redis().pipeline(transaction=True)
|
||
pipe.incr(key)
|
||
pipe.expire(key, get_settings().login_lock_minutes * 60)
|
||
count, _ = pipe.execute()
|
||
return int(count)
|
||
|
||
|
||
def clear(username: str) -> None:
|
||
"""登录成功后清除失败计数。"""
|
||
get_redis().delete(_key(username))
|