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

103 lines
3.9 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.

"""登录失败限流单元测试。
守的是:锁定阈值与**滑动窗口**语义。若记录失败时不刷新 TTL,
「4 次失败 + 等 14 分钟 + 1 次失败」就不会触发锁定,
正好给慢速撞库留出缝隙。
"""
import pytest
from app.core.config import get_settings
from app.core.constants import REDIS_KEY_LOGIN_FAIL
from app.services import login_limiter
def _ttl(username: str) -> int:
from app.core.redis_client import get_redis
return get_redis().ttl(REDIS_KEY_LOGIN_FAIL.format(username=username))
class TestThreshold:
"""阈值判定。"""
def test_初始状态未锁定且计数为零(self, fake_redis) -> None:
assert login_limiter.failure_count("u") == 0
assert login_limiter.is_locked("u") is False
def test_未达阈值时不锁定(self, fake_redis) -> None:
for _ in range(get_settings().login_fail_limit - 1):
login_limiter.record_failure("u")
assert login_limiter.is_locked("u") is False
def test_达到阈值即锁定(self, fake_redis) -> None:
for _ in range(get_settings().login_fail_limit):
login_limiter.record_failure("u")
assert login_limiter.is_locked("u") is True
def test_不同用户之间互不影响(self, fake_redis) -> None:
"""按用户名隔离,避免一个账号被攻击导致全员被锁。"""
for _ in range(get_settings().login_fail_limit):
login_limiter.record_failure("victim")
assert login_limiter.is_locked("victim") is True
assert login_limiter.is_locked("others") is False
class TestSlidingWindow:
"""滑动窗口:每次失败都刷新过期时间。"""
def test_锁定时长与配置一致(self, fake_redis) -> None:
"""容忍 1 秒偏差:Redis 的 TTL 向下取整,刚写入就查询可能少 1 秒。"""
login_limiter.record_failure("u")
expected = get_settings().login_lock_minutes * 60
assert _ttl("u") in (expected, expected - 1)
def test_再次失败会刷新过期时间(self, fake_redis) -> None:
"""把 TTL 改小模拟「距上次失败已过 14 分钟」,再失败一次应重新计满。
若不刷新,此处 TTL 仍是改小后的值,"慢速撞库"就能绕过锁定。
"""
login_limiter.record_failure("u")
fake_redis.expire(REDIS_KEY_LOGIN_FAIL.format(username="u"), 10)
login_limiter.record_failure("u")
assert _ttl("u") > 600
class TestAtomicWrite:
"""计数和 TTL 必须同事务生效。"""
def test_EXEC失败时不留下无TTL计数(self, fake_redis, monkeypatch) -> None:
key = REDIS_KEY_LOGIN_FAIL.format(username="u")
pipeline = fake_redis.pipeline(transaction=True)
def fail_execute():
raise RuntimeError("EXEC 失败")
monkeypatch.setattr(pipeline, "execute", fail_execute)
monkeypatch.setattr(fake_redis, "pipeline", lambda **_: pipeline)
with pytest.raises(RuntimeError, match="EXEC 失败"):
login_limiter.record_failure("u")
assert fake_redis.get(key) is None
class TestClearAndRemaining:
"""解锁与剩余时长。"""
def test_登录成功清除失败计数(self, fake_redis) -> None:
for _ in range(get_settings().login_fail_limit):
login_limiter.record_failure("u")
login_limiter.clear("u")
assert login_limiter.is_locked("u") is False
assert login_limiter.failure_count("u") == 0
def test_未锁定时剩余秒数为零(self, fake_redis) -> None:
assert login_limiter.remaining_lock_seconds("u") == 0
def test_锁定后剩余秒数为正且不超过锁定时长(self, fake_redis) -> None:
for _ in range(get_settings().login_fail_limit):
login_limiter.record_failure("u")
remaining = login_limiter.remaining_lock_seconds("u")
assert 0 < remaining <= get_settings().login_lock_minutes * 60