PayrollManagementSystem/backend/app/services/login_limiter.py
liuwenxin 67adecbf1b fix(认证): 原子更新登录限流并统一凭据校验耗时
将失败计数与过期时间放入同一 Redis 事务,避免永久锁定。
不存在的账号执行同成本哈希校验,登录测试强制读取环境口令。
2026-11-03 17:18:36 +08:00

58 lines
2.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""登录失败限流。
键 `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))