fix(认证): 原子更新登录限流并统一凭据校验耗时
将失败计数与过期时间放入同一 Redis 事务,避免永久锁定。 不存在的账号执行同成本哈希校验,登录测试强制读取环境口令。
This commit is contained in:
parent
22605f4106
commit
67adecbf1b
@ -24,6 +24,10 @@ logger = logging.getLogger(__name__)
|
|||||||
# 攻击者据此无法判断某个用户名是否真实存在。
|
# 攻击者据此无法判断某个用户名是否真实存在。
|
||||||
INVALID_CREDENTIALS = "用户名或密码错误"
|
INVALID_CREDENTIALS = "用户名或密码错误"
|
||||||
|
|
||||||
|
# 与真实库哈希同算法、同轮次的假哈希。用户不存在时也执行一次校验,
|
||||||
|
# 让两条失败路径耗时接近,避免响应时间暴露有效用户名。
|
||||||
|
_DUMMY_PASSWORD_HASH = "$2b$12$T7f7tDu4ljCgkXigiJ0EHea3ik07F0Dqpv2B1rlUF1FljlylH68Um"
|
||||||
|
|
||||||
|
|
||||||
def _record(
|
def _record(
|
||||||
session: Session,
|
session: Session,
|
||||||
@ -103,7 +107,10 @@ def login(
|
|||||||
|
|
||||||
# ---- 3. 凭据校验 ----
|
# ---- 3. 凭据校验 ----
|
||||||
user = user_repository.get_by_username(session, username)
|
user = user_repository.get_by_username(session, username)
|
||||||
if user is None or not verify_password(password, user.password):
|
credentials_ok = verify_password(
|
||||||
|
password, user.password if user is not None else _DUMMY_PASSWORD_HASH
|
||||||
|
)
|
||||||
|
if user is None or not credentials_ok:
|
||||||
login_limiter.record_failure(username)
|
login_limiter.record_failure(username)
|
||||||
_record(
|
_record(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@ -43,10 +43,13 @@ def record_failure(username: str) -> int:
|
|||||||
正好给撞库留出缝隙。
|
正好给撞库留出缝隙。
|
||||||
"""
|
"""
|
||||||
key = _key(username)
|
key = _key(username)
|
||||||
redis = get_redis()
|
# INCR 和 EXPIRE 必须同事务提交。否则两步之间断连会留下无 TTL 的计数,
|
||||||
count = int(redis.incr(key))
|
# 该用户随后会被永久锁定;pipeline(transaction=True) 用 MULTI/EXEC 原子生效。
|
||||||
redis.expire(key, get_settings().login_lock_minutes * 60)
|
pipe = get_redis().pipeline(transaction=True)
|
||||||
return count
|
pipe.incr(key)
|
||||||
|
pipe.expire(key, get_settings().login_lock_minutes * 60)
|
||||||
|
count, _ = pipe.execute()
|
||||||
|
return int(count)
|
||||||
|
|
||||||
|
|
||||||
def clear(username: str) -> None:
|
def clear(username: str) -> None:
|
||||||
|
|||||||
57
backend/tests/unit/test_auth_credentials.py
Normal file
57
backend/tests/unit/test_auth_credentials.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"""不存在的账号仍做哈希校验,但永远不能通过登录。"""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.exceptions import BadRequestError
|
||||||
|
from app.services import auth_service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("exists", [True, False])
|
||||||
|
def test_凭据错误统一校验并记失败(monkeypatch, exists):
|
||||||
|
user = SimpleNamespace(id=1, password="stored-hash") if exists else None
|
||||||
|
monkeypatch.setattr(auth_service.user_repository, "get_by_username", lambda *_: user)
|
||||||
|
monkeypatch.setattr(auth_service.login_limiter, "is_locked", lambda *_: False)
|
||||||
|
monkeypatch.setattr(auth_service.captcha_service, "verify_and_consume", lambda *_: True)
|
||||||
|
verify = Mock(return_value=False)
|
||||||
|
failure = Mock()
|
||||||
|
record = Mock()
|
||||||
|
monkeypatch.setattr(auth_service, "verify_password", verify)
|
||||||
|
monkeypatch.setattr(auth_service.login_limiter, "record_failure", failure)
|
||||||
|
monkeypatch.setattr(auth_service, "_record", record)
|
||||||
|
with pytest.raises(BadRequestError, match=auth_service.INVALID_CREDENTIALS):
|
||||||
|
auth_service.login(
|
||||||
|
Mock(),
|
||||||
|
username="unknown",
|
||||||
|
password="wrong",
|
||||||
|
captcha_id="id",
|
||||||
|
captcha_code="ABCD",
|
||||||
|
ip=None,
|
||||||
|
user_agent=None,
|
||||||
|
)
|
||||||
|
verify.assert_called_once_with(
|
||||||
|
"wrong", user.password if user else auth_service._DUMMY_PASSWORD_HASH
|
||||||
|
)
|
||||||
|
failure.assert_called_once_with("unknown")
|
||||||
|
assert record.call_args.kwargs["success"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_输入匹配假哈希也不能登录(monkeypatch):
|
||||||
|
monkeypatch.setattr(auth_service.user_repository, "get_by_username", lambda *_: None)
|
||||||
|
monkeypatch.setattr(auth_service.login_limiter, "is_locked", lambda *_: False)
|
||||||
|
monkeypatch.setattr(auth_service.captcha_service, "verify_and_consume", lambda *_: True)
|
||||||
|
monkeypatch.setattr(auth_service, "verify_password", lambda *_: True)
|
||||||
|
monkeypatch.setattr(auth_service.login_limiter, "record_failure", Mock())
|
||||||
|
monkeypatch.setattr(auth_service, "_record", Mock())
|
||||||
|
with pytest.raises(BadRequestError, match=auth_service.INVALID_CREDENTIALS):
|
||||||
|
auth_service.login(
|
||||||
|
Mock(),
|
||||||
|
username="unknown",
|
||||||
|
password="dummy",
|
||||||
|
captcha_id="id",
|
||||||
|
captcha_code="ABCD",
|
||||||
|
ip=None,
|
||||||
|
user_agent=None,
|
||||||
|
)
|
||||||
@ -5,6 +5,8 @@
|
|||||||
正好给慢速撞库留出缝隙。
|
正好给慢速撞库留出缝隙。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.constants import REDIS_KEY_LOGIN_FAIL
|
from app.core.constants import REDIS_KEY_LOGIN_FAIL
|
||||||
from app.services import login_limiter
|
from app.services import login_limiter
|
||||||
@ -62,6 +64,24 @@ class TestSlidingWindow:
|
|||||||
assert _ttl("u") > 600
|
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:
|
class TestClearAndRemaining:
|
||||||
"""解锁与剩余时长。"""
|
"""解锁与剩余时长。"""
|
||||||
|
|
||||||
|
|||||||
@ -38,9 +38,11 @@ from common import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
USERNAME = "admin"
|
USERNAME = "admin"
|
||||||
# 与 system_management 同一约定:开发库 admin 密码从环境变量传入(各库不同,
|
# 与 system_management 同一约定:开发库 admin 密码只能从环境变量传入。
|
||||||
# 硬编码会在他机上假绿为「凭据错误」),缺省保留历史默认值兜底本机老库
|
# 不保留仓库内默认值,避免历史开发口令随代码扩散或在误用旧库时直接登录。
|
||||||
PASSWORD = os.environ.get("E2E_ADMIN_PASSWORD", "Admin@2026")
|
PASSWORD = os.environ.get("E2E_ADMIN_PASSWORD")
|
||||||
|
if not PASSWORD:
|
||||||
|
raise SystemExit("请先设置 E2E_ADMIN_PASSWORD,脚本不再提供默认管理员密码")
|
||||||
|
|
||||||
|
|
||||||
def run(page: Page) -> None:
|
def run(page: Page) -> None:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user