diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 053ed04..28a6deb 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -285,13 +285,19 @@ async def login( await check_account_lockout(user) if not valid: - await record_failed_login(db, user) + remaining = await record_failed_login(db, user) await log_audit_event( db, action="auth.login_failed", actor_id=user.id, - detail={"reason": "invalid_password"}, ip=client_ip, + detail={"reason": "invalid_password", "attempts_remaining": remaining}, ip=client_ip, ) await db.commit() - raise HTTPException(status_code=401, detail="Invalid username or password") + if remaining == 0: + detail = "Account temporarily locked. Try again in 30 minutes." + elif remaining <= 3: + detail = f"Invalid username or password. {remaining} attempt(s) remaining before account locks." + else: + detail = "Invalid username or password" + raise HTTPException(status_code=401, detail=detail) # Block passwordless-only accounts from using the password login path. # Checked after password verification to avoid leaking account existence via timing. diff --git a/backend/app/routers/passkeys.py b/backend/app/routers/passkeys.py index 8266f6d..e026791 100644 --- a/backend/app/routers/passkeys.py +++ b/backend/app/routers/passkeys.py @@ -267,6 +267,16 @@ async def passkey_login_begin( cid_bytes = base64url_to_bytes(row[0]) transports = json.loads(row[1]) if row[1] else None credential_data.append((cid_bytes, transports)) + else: + # F-01: User not found — run a no-op DB query to equalize timing with + # the credential fetch that executes for existing users. Without this, + # the absence of the second query makes the "no user" path measurably + # faster, leaking whether the username exists. + await db.execute( + select(PasskeyCredential.credential_id).where( + PasskeyCredential.user_id == 0 + ).limit(1) + ) # V-03: Generate options regardless of whether user exists or has passkeys. # Identical response shape prevents timing enumeration. @@ -330,13 +340,15 @@ async def passkey_login_complete( except Exception as e: logger.warning("Passkey authentication verification failed for user %s: %s", user.id, e) # Increment failed login counter (shared with password auth) - await record_failed_login(db, user) + remaining = await record_failed_login(db, user) await log_audit_event( db, action="passkey.login_failed", actor_id=user.id, - detail={"reason": "verification_failed"}, + detail={"reason": "verification_failed", "attempts_remaining": remaining}, ip=get_client_ip(request), ) await db.commit() + if remaining == 0: + raise HTTPException(status_code=401, detail="Account temporarily locked. Try again in 30 minutes.") raise HTTPException(status_code=401, detail="Authentication failed") # Update sign count (log anomaly but don't fail — S-05) diff --git a/backend/app/routers/totp.py b/backend/app/routers/totp.py index e2d8331..2dcb396 100644 --- a/backend/app/routers/totp.py +++ b/backend/app/routers/totp.py @@ -276,8 +276,10 @@ async def totp_verify( normalized = data.backup_code.strip().upper() valid = await _verify_backup_code(db, user.id, normalized) if not valid: - await record_failed_login(db, user) + remaining = await record_failed_login(db, user) await db.commit() + if remaining == 0: + raise HTTPException(status_code=401, detail="Account temporarily locked. Try again in 30 minutes.") raise HTTPException(status_code=401, detail="Invalid backup code") # Backup code accepted — reset lockout counter and issue session @@ -293,8 +295,10 @@ async def totp_verify( # --- TOTP code path --- matched_window = verify_totp_code(user.totp_secret, data.code) if matched_window is None: - await record_failed_login(db, user) + remaining = await record_failed_login(db, user) await db.commit() + if remaining == 0: + raise HTTPException(status_code=401, detail="Account temporarily locked. Try again in 30 minutes.") raise HTTPException(status_code=401, detail="Invalid code") # Replay prevention — record (user_id, code, actual_matching_window) diff --git a/backend/app/services/session.py b/backend/app/services/session.py index ae33bda..3df0ca0 100644 --- a/backend/app/services/session.py +++ b/backend/app/services/session.py @@ -33,24 +33,31 @@ def set_session_cookie(response: Response, token: str) -> None: async def check_account_lockout(user: User) -> None: - """Raise HTTP 423 if the account is currently locked.""" + """Raise HTTP 401 if the account is currently locked. + + Uses 401 (same status as wrong-password) so that status-code analysis + cannot distinguish a locked account from an invalid credential (F-02). + """ if user.locked_until and datetime.now() < user.locked_until: remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1 raise HTTPException( - status_code=423, - detail=f"Account locked. Try again in {remaining} minutes.", + status_code=401, + detail=f"Account temporarily locked. Try again in {remaining} minutes.", ) -async def record_failed_login(db: AsyncSession, user: User) -> None: +async def record_failed_login(db: AsyncSession, user: User) -> int: """Increment failure counter; lock account after 10 failures. + Returns the number of attempts remaining before lockout (0 = just locked). Does NOT commit — caller owns the transaction boundary. """ user.failed_login_count += 1 + remaining = max(0, 10 - user.failed_login_count) if user.failed_login_count >= 10: user.locked_until = datetime.now() + timedelta(minutes=30) await db.flush() + return remaining async def record_successful_login(db: AsyncSession, user: User) -> None: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 3b1ca90..97690b0 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -29,13 +29,14 @@ server { # Suppress nginx version in Server header server_tokens off; - # ── Real client IP restoration (PT-01) ──────────────────────────── + # ── Real client IP restoration (PT-01 / F-03) ───────────────────── # Pangolin (TLS-terminating reverse proxy) connects via Docker bridge. # Restore the real client IP from X-Forwarded-For so that limit_req_zone # (which keys on $binary_remote_addr) throttles per-client, not per-proxy. - # Safe to trust all sources: nginx is only reachable via Docker networking, - # never directly internet-facing. Tighten if deployment model changes. - set_real_ip_from 0.0.0.0/0; + # Restricted to RFC 1918 ranges only — trusting 0.0.0.0/0 would allow an + # external client to spoof X-Forwarded-For and bypass rate limiting (F-03). + set_real_ip_from 172.16.0.0/12; + set_real_ip_from 10.0.0.0/8; real_ip_header X-Forwarded-For; real_ip_recursive on;