diff --git a/docs/cx/AUDIT_LOG_SPEC.md b/docs/cx/AUDIT_LOG_SPEC.md index bbdca70..a71cda7 100644 --- a/docs/cx/AUDIT_LOG_SPEC.md +++ b/docs/cx/AUDIT_LOG_SPEC.md @@ -39,10 +39,10 @@ Common fields: | `api_auth_failed` | `failure` | none | Missing or invalid API key. The presented key is never logged. | | `ingest_accepted` | `success` | runner id | Result, estimate, PA data, or estimation-input upload accepted. | | `api_query_accepted` | `success` | runner id | Authenticated API query accepted. | -| `rate_limit_exceeded` | `failure` | rate-limit key | API or admin fixed-window limit exceeded. | +| `rate_limit_exceeded` | `failure` | rate-limit key | Login, API, or admin fixed-window limit exceeded. | | `redis_unavailable` | `failure` / `degraded` | none | Redis unavailable for authentication or throttling. | | `login_success` | `success` | user email | User completed TOTP login. | -| `login_failure` | `failure` | user email, when known | Login failed. TOTP code is never logged. | +| `login_failure` | `failure` | user email, when known | Login failed. TOTP code is never logged. Invalid-code failures include a short-window failed-attempt count for audit context, but accounts are not hard-locked by that counter. | | `setup_complete` | `success` | user email | Invitation-based TOTP setup completed. | | `setup_failure` | `failure` | user email | TOTP setup verification failed. | | `admin_user_invited` | `success` | admin email | Admin created an invitation. | diff --git a/docs/deploy/hardening-guide.md b/docs/deploy/hardening-guide.md index 4ea2576..87a8dad 100644 --- a/docs/deploy/hardening-guide.md +++ b/docs/deploy/hardening-guide.md @@ -22,17 +22,23 @@ oversized uploads are rejected before they consume worker memory. ## Rate Limits -API ingest/query routes and admin write routes use Redis-backed fixed-window -rate limits. Production deployments must keep Redis monitored and available; -when Redis is required but unavailable, protected operations fail closed with a -503 response. +Login POST, API ingest/query routes, and admin write routes use Redis-backed +fixed-window rate limits. Production deployments must keep Redis monitored and +available; when Redis is required but unavailable, protected operations fail +closed with a 503 response. Default limits: +- Login verification: 20 requests per client source per minute - API ingest: 120 requests per runner per minute - API query: 60 requests per runner per minute - Admin write actions: 20 requests per admin user per minute +Login failures also maintain a short-lived per-email counter for audit and +alerting context, but the counter does not hard-lock the account. This avoids a +targeted lockout DoS where an attacker can repeatedly close a known user's +login window; volume control is enforced by the source-scoped login limit. + ## Reverse Proxy Run the Flask app behind a reverse proxy that terminates TLS and forwards only @@ -40,6 +46,14 @@ loopback traffic to the app. Keep `/admin/` and `/auth/` protected by portal authentication; `robots.txt` only reduces crawler noise and is not an access control mechanism. +`app.py` trusts one reverse proxy hop with Werkzeug `ProxyFix`, so the frontend +proxy must set `X-Forwarded-For` and `X-Forwarded-Proto`. The configured hop +count assumes nginx is the only trusted proxy directly in front of Gunicorn. If +a load balancer or another proxy is inserted before nginx, review the +`ProxyFix` hop count and nginx header handling before enabling the deployment. +Do not expose Gunicorn directly to untrusted clients, because forwarded headers +are trusted only under the single-nginx deployment model. + ## Gunicorn Run Gunicorn under systemd with explicit worker, bind, timeout, and recycling diff --git a/docs/guides/developer-reference.md b/docs/guides/developer-reference.md index de1bbc3..df08ce3 100644 --- a/docs/guides/developer-reference.md +++ b/docs/guides/developer-reference.md @@ -220,7 +220,8 @@ For production portal deployments: - The legacy `RESULT_SERVER_KEY` variable is still accepted as runner `default` for compatibility, but should be rotated to `RESULT_SERVER_KEYS`. - See `docs/deploy/key-management.md` for generation and rotation guidance. - `REDIS_URL` must point to a monitored Redis instance; production authentication refuses login when Redis is unavailable. -- API ingest and query endpoints use Redis-backed rate limits by default; set `RESULT_SERVER_MAX_UPLOAD_MB` and `RESULT_SERVER_MAX_ARCHIVE_MEMBER_MB` when deployment-specific upload limits are needed. +- Login verification, API ingest/query, and admin write endpoints use Redis-backed rate limits by default; set `RESULT_SERVER_MAX_UPLOAD_MB` and `RESULT_SERVER_MAX_ARCHIVE_MEMBER_MB` when deployment-specific upload limits are needed. +- Repeated login failures are tracked per email for audit context only; source-scoped Redis rate limits enforce login traffic control without hard-locking a target account. - Admin-managed affiliations are only rejected when they contain unsafe path/control characters or the comma delimiter used by the form; set `RESULT_SERVER_ALLOWED_AFFILIATIONS` only when a deployment wants to enforce a fixed comma-separated allowlist. - Security-relevant auth, API, and admin actions emit structured `benchkit.audit` events; see `docs/cx/AUDIT_LOG_SPEC.md`. - `app_dev.py` is localhost-only, uses ephemeral development secrets when none are provided, and enables the Werkzeug debugger only with `RESULT_SERVER_DEV_DEBUG=1`. diff --git a/result_server/app.py b/result_server/app.py index 6b12a95..a94d41a 100644 --- a/result_server/app.py +++ b/result_server/app.py @@ -4,6 +4,7 @@ from flask import Flask, jsonify, render_template from flask_session import Session +from werkzeug.middleware.proxy_fix import ProxyFix from routes.api import api_bp from routes.estimated import estimated_bp @@ -42,6 +43,11 @@ def _configure_session(app, base_dir): Session(app) +def _configure_proxy_fix(app): + """Trust the single nginx reverse proxy in front of production gunicorn.""" + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1) + + def _configure_redis(app, prefix): """Attach Redis connection settings and the key prefix to app config.""" import redis @@ -123,6 +129,7 @@ def create_app(prefix="", base_dir=None): raise ValueError("base_dir must be specified") app = Flask(__name__, template_folder="templates") + _configure_proxy_fix(app) secret_key = os.environ.get("FLASK_SECRET_KEY") if not secret_key: diff --git a/result_server/app_dev.py b/result_server/app_dev.py index 627dbd5..89756fe 100644 --- a/result_server/app_dev.py +++ b/result_server/app_dev.py @@ -84,7 +84,7 @@ def _create_stub_totp_manager(): mod.generate_qr_base64 = lambda s, e, **kw: "" mod.verify_code = lambda s, c: True mod.check_code_reuse = lambda *a, **kw: False - mod.check_rate_limit = lambda *a, **kw: False + mod.get_failed_attempt_count = lambda *a, **kw: 0 mod.record_failed_attempt = lambda *a, **kw: 0 mod.clear_failed_attempts = lambda *a, **kw: None mod.MAX_LOGIN_ATTEMPTS = 5 diff --git a/result_server/routes/auth.py b/result_server/routes/auth.py index 724ef48..98df5c8 100644 --- a/result_server/routes/auth.py +++ b/result_server/routes/auth.py @@ -14,14 +14,16 @@ session, url_for, ) +from werkzeug.exceptions import TooManyRequests from utils.audit_logging import audit_event +from utils.rate_limit import enforce_rate_limit from utils.totp_manager import ( check_code_reuse, - check_rate_limit, clear_failed_attempts, generate_qr_base64, generate_secret, + MAX_LOGIN_ATTEMPTS, record_failed_attempt, verify_code, ) @@ -115,6 +117,11 @@ def _render_setup_page(email, token, secret): ) +def _login_rate_key(): + """Return the source-scoped login rate-limit key.""" + return request.remote_addr or "unknown" + + @auth_bp.route("/login", methods=["GET", "POST"]) def login(): """Render the login flow and validate submitted TOTP codes.""" @@ -138,19 +145,17 @@ def login(): if not email: return redirect(url_for("auth.login")) - # Enforce rate limiting when Redis-backed tracking is available. if redis_conn: - is_locked, remaining = check_rate_limit(redis_conn, prefix, email) - if is_locked: - audit_event( - "login_failure", - actor=email, - result="failure", - level=logging.WARNING, - details={"reason": "rate_limited"}, + try: + enforce_rate_limit( + redis_conn=redis_conn, + key_suffix=_login_rate_key(), + max_per_minute=20, + scope="login", ) - flash(f"Too many failed attempts. Please try again in {remaining} seconds.") - return _render_login_totp_step(email) + except TooManyRequests: + flash("Too many login attempts. Please wait and try again.") + return _render_login_totp_step(email), 429 store = get_user_store() user = store.get_user(email) @@ -179,26 +184,25 @@ def login(): flash("Authentication successful.") return redirect(url_for("results.results")) - # Failed authentication: record or report the attempt. + # Failed authentication: record a short-window counter for audit/alerting. + details = {"reason": "invalid_totp_or_user"} + if redis_conn: + attempts = record_failed_attempt(redis_conn, prefix, email) + details.update( + { + "failed_attempts": attempts, + "failed_attempt_threshold": MAX_LOGIN_ATTEMPTS, + "threshold_exceeded": attempts >= MAX_LOGIN_ATTEMPTS, + } + ) audit_event( "login_failure", actor=email, result="failure", level=logging.WARNING, - details={"reason": "invalid_totp_or_user"}, + details=details, ) - if redis_conn: - attempts = record_failed_attempt(redis_conn, prefix, email) - from utils.totp_manager import MAX_LOGIN_ATTEMPTS - - remaining_attempts = MAX_LOGIN_ATTEMPTS - attempts - if remaining_attempts > 0: - flash(f"Authentication failed. {remaining_attempts} attempts remaining.") - else: - flash("Too many failed attempts. Your account is temporarily locked.") - return _render_login_totp_step(email) - else: - flash("Authentication failed. Please check your code.") + flash("Authentication failed. Please check your code.") return _render_login_totp_step(email) return redirect(url_for("auth.login")) diff --git a/result_server/tests/test_rate_limit.py b/result_server/tests/test_rate_limit.py index f94b6f3..24396c2 100644 --- a/result_server/tests/test_rate_limit.py +++ b/result_server/tests/test_rate_limit.py @@ -178,6 +178,23 @@ def test_rate_limit_redis_failure_fails_closed_when_required(): _cleanup(temp_dirs) +def test_rate_limit_missing_redis_fails_closed_when_required(): + app, temp_dirs = _api_app() + app.config["REDIS_CONN"] = None + app.config["AUTH_REQUIRES_REDIS"] = True + try: + with app.test_client() as client: + resp = client.post( + "/api/ingest/result", + data=json.dumps({"code": "first"}), + headers={"X-API-Key": API_KEY, "Content-Type": "application/json"}, + ) + + assert resp.status_code == 503 + finally: + _cleanup(temp_dirs) + + def test_admin_write_rate_limit_returns_429(): app, temp_dirs = _portal_app() try: diff --git a/result_server/tests/test_totp_security.py b/result_server/tests/test_totp_security.py index d3f2616..0b7a1ac 100644 --- a/result_server/tests/test_totp_security.py +++ b/result_server/tests/test_totp_security.py @@ -19,6 +19,7 @@ import fakeredis import pytest from flask import Flask +from werkzeug.middleware.proxy_fix import ProxyFix sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -27,11 +28,11 @@ install_portal_test_stubs(include_redis=False) from utils.totp_manager import ( - LOCKOUT_SECONDS, + FAILED_ATTEMPT_WINDOW_SECONDS, MAX_LOGIN_ATTEMPTS, check_code_reuse, - check_rate_limit, clear_failed_attempts, + get_failed_attempt_count, record_failed_attempt, ) @@ -76,36 +77,30 @@ def test_replay_key_has_ttl(self, redis_conn): class TestRateLimiting: - """Tests for brute-force protection on repeated login attempts.""" + """Tests for failed-login attempt tracking.""" - def test_no_lockout_initially(self, redis_conn): - """A new user should not be locked out.""" - is_locked, remaining = check_rate_limit(redis_conn, PREFIX, "user@test.com") - assert is_locked is False - assert remaining == 0 + def test_no_attempts_initially(self, redis_conn): + """A new user should have no recent failed attempts.""" + assert get_failed_attempt_count(redis_conn, PREFIX, "user@test.com") == 0 - def test_lockout_after_max_attempts(self, redis_conn): - """The user should be locked out after the maximum number of failures.""" + def test_attempts_are_counted_after_max_attempts(self, redis_conn): + """Failed-attempt counters should track repeated failures without locking.""" for _ in range(MAX_LOGIN_ATTEMPTS): record_failed_attempt(redis_conn, PREFIX, "user@test.com") - is_locked, remaining = check_rate_limit(redis_conn, PREFIX, "user@test.com") - assert is_locked is True - assert remaining > 0 + assert get_failed_attempt_count(redis_conn, PREFIX, "user@test.com") == MAX_LOGIN_ATTEMPTS - def test_not_locked_before_max(self, redis_conn): - """The user should remain unlocked before reaching the limit.""" + def test_attempts_before_max(self, redis_conn): + """The counter should reflect attempts before the advisory threshold.""" for _ in range(MAX_LOGIN_ATTEMPTS - 1): record_failed_attempt(redis_conn, PREFIX, "user@test.com") - is_locked, _ = check_rate_limit(redis_conn, PREFIX, "user@test.com") - assert is_locked is False + assert get_failed_attempt_count(redis_conn, PREFIX, "user@test.com") == MAX_LOGIN_ATTEMPTS - 1 def test_clear_resets_attempts(self, redis_conn): - """Clearing failures should reset the lockout state.""" + """Clearing failures should reset the failed-attempt counter.""" for _ in range(MAX_LOGIN_ATTEMPTS - 1): record_failed_attempt(redis_conn, PREFIX, "user@test.com") clear_failed_attempts(redis_conn, PREFIX, "user@test.com") - is_locked, _ = check_rate_limit(redis_conn, PREFIX, "user@test.com") - assert is_locked is False + assert get_failed_attempt_count(redis_conn, PREFIX, "user@test.com") == 0 def test_record_returns_count(self, redis_conn): """record_failed_attempt() should return the current attempt count.""" @@ -117,15 +112,14 @@ def test_different_users_independent(self, redis_conn): """Attempt counters should be tracked per user.""" for _ in range(MAX_LOGIN_ATTEMPTS): record_failed_attempt(redis_conn, PREFIX, "user1@test.com") - is_locked, _ = check_rate_limit(redis_conn, PREFIX, "user2@test.com") - assert is_locked is False + assert get_failed_attempt_count(redis_conn, PREFIX, "user2@test.com") == 0 def test_attempts_key_has_ttl(self, redis_conn): """Attempt-counter keys should expire automatically.""" record_failed_attempt(redis_conn, PREFIX, "user@test.com") key = f"{PREFIX}login_attempts:user@test.com" ttl = redis_conn.ttl(key) - assert 0 < ttl <= LOCKOUT_SECONDS + assert 0 < ttl <= FAILED_ATTEMPT_WINDOW_SECONDS class _StubUserStore: @@ -173,6 +167,11 @@ def ping(self): raise ConnectionError("redis down") +class _BrokenRateLimitRedis(fakeredis.FakeRedis): + def incr(self, _key): + raise ConnectionError("redis down") + + @pytest.fixture def admin_app(): """Create a Flask app for admin self-delete protection tests.""" @@ -279,6 +278,136 @@ def test_dev_mode_without_redis_continues_login_flow(self, auth_app): assert b"Step 2 of 2" in resp.data +class TestLoginRateLimiting: + """Tests source-scoped login rate limiting without account lockout.""" + + def _configure_auth(self, app, monkeypatch, *, limit=20): + import routes.auth as auth_routes + + app.config["REDIS_CONN"] = fakeredis.FakeRedis(decode_responses=True) + app.config["REDIS_PREFIX"] = PREFIX + app.config["RATE_LIMITS"] = {"login": limit} + app.config["AUTH_REQUIRES_REDIS"] = True + app.config["USER_STORE"].create_user("user@test.com", "SECRET", ["dev"]) + monkeypatch.setattr(auth_routes, "verify_code", lambda _secret, code: code == "000000") + + def test_login_post_rate_limit_returns_429(self, auth_app, monkeypatch): + self._configure_auth(auth_app, monkeypatch, limit=1) + + with auth_app.test_client() as client: + first = client.post( + "/auth/login", + data={"email": "user@test.com", "totp_code": "111111"}, + ) + second = client.post( + "/auth/login", + data={"email": "user@test.com", "totp_code": "222222"}, + ) + + assert first.status_code == 200 + assert second.status_code == 429 + assert b"Too many login attempts" in second.data + + def test_proxyfix_separates_login_rate_keys_by_forwarded_client(self, auth_app, monkeypatch): + self._configure_auth(auth_app, monkeypatch, limit=1) + auth_app.wsgi_app = ProxyFix(auth_app.wsgi_app, x_for=1, x_proto=1) + + with auth_app.test_client() as client: + first = client.post( + "/auth/login", + data={"email": "user@test.com", "totp_code": "111111"}, + headers={"X-Forwarded-For": "198.51.100.10"}, + ) + second = client.post( + "/auth/login", + data={"email": "user@test.com", "totp_code": "222222"}, + headers={"X-Forwarded-For": "203.0.113.10"}, + ) + third_same_source = client.post( + "/auth/login", + data={"email": "user@test.com", "totp_code": "333333"}, + headers={"X-Forwarded-For": "198.51.100.10"}, + ) + + assert first.status_code == 200 + assert second.status_code == 200 + assert third_same_source.status_code == 429 + + def test_proxyfix_ignores_untrusted_extra_forwarded_for_values(self, auth_app, monkeypatch): + self._configure_auth(auth_app, monkeypatch, limit=1) + auth_app.wsgi_app = ProxyFix(auth_app.wsgi_app, x_for=1, x_proto=1) + + with auth_app.test_client() as client: + spoofed = client.post( + "/auth/login", + data={"email": "user@test.com", "totp_code": "111111"}, + headers={"X-Forwarded-For": "198.51.100.99, 203.0.113.10"}, + ) + same_trusted_source = client.post( + "/auth/login", + data={"email": "user@test.com", "totp_code": "222222"}, + headers={"X-Forwarded-For": "203.0.113.10"}, + ) + + assert spoofed.status_code == 200 + assert same_trusted_source.status_code == 429 + + def test_login_rate_limit_redis_failure_fails_closed(self, auth_app, monkeypatch): + self._configure_auth(auth_app, monkeypatch, limit=20) + auth_app.config["REDIS_CONN"] = _BrokenRateLimitRedis(decode_responses=True) + + with auth_app.test_client() as client: + resp = client.post( + "/auth/login", + data={"email": "user@test.com", "totp_code": "111111"}, + ) + + assert resp.status_code == 503 + + def test_totp_replay_check_still_blocks_reused_code(self, auth_app, monkeypatch): + self._configure_auth(auth_app, monkeypatch, limit=100) + auth_app.wsgi_app = ProxyFix(auth_app.wsgi_app, x_for=1, x_proto=1) + + with auth_app.test_client() as client: + first = client.post( + "/auth/login", + data={"email": "user@test.com", "totp_code": "000000"}, + headers={"X-Forwarded-For": "198.51.100.10"}, + ) + replay = client.post( + "/auth/login", + data={"email": "user@test.com", "totp_code": "000000"}, + headers={"X-Forwarded-For": "203.0.113.10"}, + ) + + assert first.status_code == 302 + assert replay.status_code == 200 + assert b"This code has already been used" in replay.data + + def test_failed_attempt_threshold_does_not_lock_out_valid_login(self, auth_app, monkeypatch): + self._configure_auth(auth_app, monkeypatch, limit=100) + auth_app.wsgi_app = ProxyFix(auth_app.wsgi_app, x_for=1, x_proto=1) + + with auth_app.test_client() as client: + for _ in range(MAX_LOGIN_ATTEMPTS): + failed = client.post( + "/auth/login", + data={"email": "user@test.com", "totp_code": "111111"}, + headers={"X-Forwarded-For": "198.51.100.10"}, + ) + assert failed.status_code == 200 + + valid = client.post( + "/auth/login", + data={"email": "user@test.com", "totp_code": "000000"}, + headers={"X-Forwarded-For": "203.0.113.10"}, + ) + + assert valid.status_code == 302 + assert valid.headers["Location"].endswith("/results/") + assert auth_app.config["REDIS_CONN"].get(f"{PREFIX}login_attempts:user@test.com") is None + + class TestAdminSelfDeletePrevention: """Tests that admins cannot delete their own account.""" diff --git a/result_server/utils/rate_limit.py b/result_server/utils/rate_limit.py index 266102e..5b36659 100644 --- a/result_server/utils/rate_limit.py +++ b/result_server/utils/rate_limit.py @@ -21,52 +21,69 @@ def _configured_limit(scope: str, default: int) -> int: return int(overrides.get(scope, default)) +def enforce_rate_limit( + *, + redis_conn, + key_suffix: str, + max_per_minute: int, + scope: str, +) -> None: + """Apply a simple Redis-backed fixed-window rate limit or abort on excess.""" + if redis_conn is None: + if current_app.config.get("AUTH_REQUIRES_REDIS"): + abort(503, description="Rate limiting service unavailable") + return + + redis_prefix = current_app.config.get("REDIS_PREFIX", "") + redis_key = f"{redis_prefix}rate:{scope}:{key_suffix}" + limit = _configured_limit(scope, max_per_minute) + + try: + count = redis_conn.incr(redis_key) + if count == 1: + redis_conn.expire(redis_key, 60) + if count > limit: + audit_event( + "rate_limit_exceeded", + actor=key_suffix, + result="failure", + level=logging.WARNING, + details={ + "scope": scope, + "count": count, + "threshold": limit, + }, + ) + current_app.logger.warning( + "rate_limit_exceeded", + extra={ + "rate_limit_scope": scope, + "rate_limit_key": key_suffix, + "rate_limit_count": count, + "rate_limit_threshold": limit, + }, + ) + abort(429, description="Too many requests") + except HTTPException: + raise + except Exception: + current_app.logger.exception("Rate limit check failed") + if current_app.config.get("AUTH_REQUIRES_REDIS"): + abort(503, description="Rate limiting service unavailable") + + def rate_limited(*, max_per_minute: int, key_fn: RateKeyFunc | None = None, scope: str): """Apply a simple Redis-backed fixed-window rate limit to a Flask view.""" def decorator(func): @wraps(func) def wrapper(*args, **kwargs): redis_conn = current_app.config.get("REDIS_CONN") - if redis_conn is None: - return func(*args, **kwargs) - - key_suffix = key_fn(request) if key_fn else (request.remote_addr or "unknown") - redis_prefix = current_app.config.get("REDIS_PREFIX", "") - redis_key = f"{redis_prefix}rate:{scope}:{key_suffix}" - limit = _configured_limit(scope, max_per_minute) - - try: - count = redis_conn.incr(redis_key) - if count == 1: - redis_conn.expire(redis_key, 60) - if count > limit: - audit_event( - "rate_limit_exceeded", - actor=key_suffix, - result="failure", - level=logging.WARNING, - details={ - "scope": scope, - "count": count, - "threshold": limit, - }, - ) - current_app.logger.warning( - "rate_limit_exceeded", - extra={ - "rate_limit_scope": scope, - "rate_limit_key": key_suffix, - "rate_limit_count": count, - "rate_limit_threshold": limit, - }, - ) - abort(429, description="Too many requests") - except HTTPException: - raise - except Exception: - current_app.logger.exception("Rate limit check failed") - if current_app.config.get("AUTH_REQUIRES_REDIS"): - abort(503, description="Rate limiting service unavailable") + enforce_rate_limit( + redis_conn=redis_conn, + key_suffix=key_fn(request) if key_fn else (request.remote_addr or "unknown"), + max_per_minute=max_per_minute, + scope=scope, + ) return func(*args, **kwargs) diff --git a/result_server/utils/totp_manager.py b/result_server/utils/totp_manager.py index 5326d54..e42f50b 100644 --- a/result_server/utils/totp_manager.py +++ b/result_server/utils/totp_manager.py @@ -8,9 +8,9 @@ ISSUER_NAME = "CX Portal" -# Brute-force protection thresholds. +# Failed-login tracking thresholds. MAX_LOGIN_ATTEMPTS = 5 -LOCKOUT_SECONDS = 300 # 5 minutes +FAILED_ATTEMPT_WINDOW_SECONDS = 300 # 5 minutes def generate_secret() -> str: @@ -50,14 +50,11 @@ def check_code_reuse(redis_conn, prefix: str, email: str, code: str) -> bool: return False -def check_rate_limit(redis_conn, prefix: str, email: str) -> tuple: - """Return whether the user is locked out and the remaining TTL.""" +def get_failed_attempt_count(redis_conn, prefix: str, email: str) -> int: + """Return the recent failed-login count for the given user.""" key = f"{prefix}login_attempts:{email}" attempts = redis_conn.get(key) - if attempts and int(attempts) >= MAX_LOGIN_ATTEMPTS: - ttl = redis_conn.ttl(key) - return True, max(ttl, 0) - return False, 0 + return int(attempts or 0) def record_failed_attempt(redis_conn, prefix: str, email: str) -> int: @@ -65,7 +62,7 @@ def record_failed_attempt(redis_conn, prefix: str, email: str) -> int: key = f"{prefix}login_attempts:{email}" pipe = redis_conn.pipeline() pipe.incr(key) - pipe.expire(key, LOCKOUT_SECONDS) + pipe.expire(key, FAILED_ATTEMPT_WINDOW_SECONDS) results = pipe.execute() return results[0]