From 67adecbf1bffa2607414a45d57fdbe2e7d8c5acf Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 3 Nov 2026 17:18:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E8=AE=A4=E8=AF=81):=20=E5=8E=9F=E5=AD=90?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=99=BB=E5=BD=95=E9=99=90=E6=B5=81=E5=B9=B6?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=87=AD=E6=8D=AE=E6=A0=A1=E9=AA=8C=E8=80=97?= =?UTF-8?q?=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将失败计数与过期时间放入同一 Redis 事务,避免永久锁定。 不存在的账号执行同成本哈希校验,登录测试强制读取环境口令。 --- backend/app/services/auth_service.py | 9 +++- backend/app/services/login_limiter.py | 11 ++-- backend/tests/unit/test_auth_credentials.py | 57 +++++++++++++++++++++ backend/tests/unit/test_login_limiter.py | 20 ++++++++ frontend/e2e/login_flow.py | 8 +-- 5 files changed, 97 insertions(+), 8 deletions(-) create mode 100644 backend/tests/unit/test_auth_credentials.py diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 2fca0aa..fc0aae8 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -24,6 +24,10 @@ logger = logging.getLogger(__name__) # 攻击者据此无法判断某个用户名是否真实存在。 INVALID_CREDENTIALS = "用户名或密码错误" +# 与真实库哈希同算法、同轮次的假哈希。用户不存在时也执行一次校验, +# 让两条失败路径耗时接近,避免响应时间暴露有效用户名。 +_DUMMY_PASSWORD_HASH = "$2b$12$T7f7tDu4ljCgkXigiJ0EHea3ik07F0Dqpv2B1rlUF1FljlylH68Um" + def _record( session: Session, @@ -103,7 +107,10 @@ def login( # ---- 3. 凭据校验 ---- 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) _record( session, diff --git a/backend/app/services/login_limiter.py b/backend/app/services/login_limiter.py index 6aec497..22b8ee3 100644 --- a/backend/app/services/login_limiter.py +++ b/backend/app/services/login_limiter.py @@ -43,10 +43,13 @@ def record_failure(username: str) -> int: 正好给撞库留出缝隙。 """ key = _key(username) - redis = get_redis() - count = int(redis.incr(key)) - redis.expire(key, get_settings().login_lock_minutes * 60) - return count + # 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: diff --git a/backend/tests/unit/test_auth_credentials.py b/backend/tests/unit/test_auth_credentials.py new file mode 100644 index 0000000..c8d327a --- /dev/null +++ b/backend/tests/unit/test_auth_credentials.py @@ -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, + ) diff --git a/backend/tests/unit/test_login_limiter.py b/backend/tests/unit/test_login_limiter.py index 4887dd0..d2ab1fc 100644 --- a/backend/tests/unit/test_login_limiter.py +++ b/backend/tests/unit/test_login_limiter.py @@ -5,6 +5,8 @@ 正好给慢速撞库留出缝隙。 """ +import pytest + from app.core.config import get_settings from app.core.constants import REDIS_KEY_LOGIN_FAIL from app.services import login_limiter @@ -62,6 +64,24 @@ class TestSlidingWindow: 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: """解锁与剩余时长。""" diff --git a/frontend/e2e/login_flow.py b/frontend/e2e/login_flow.py index 110e4db..7b8ce13 100644 --- a/frontend/e2e/login_flow.py +++ b/frontend/e2e/login_flow.py @@ -38,9 +38,11 @@ from common import ( ) USERNAME = "admin" -# 与 system_management 同一约定:开发库 admin 密码从环境变量传入(各库不同, -# 硬编码会在他机上假绿为「凭据错误」),缺省保留历史默认值兜底本机老库 -PASSWORD = os.environ.get("E2E_ADMIN_PASSWORD", "Admin@2026") +# 与 system_management 同一约定:开发库 admin 密码只能从环境变量传入。 +# 不保留仓库内默认值,避免历史开发口令随代码扩散或在误用旧库时直接登录。 +PASSWORD = os.environ.get("E2E_ADMIN_PASSWORD") +if not PASSWORD: + raise SystemExit("请先设置 E2E_ADMIN_PASSWORD,脚本不再提供默认管理员密码") def run(page: Page) -> None: