diff --git a/.env.example b/.env.example index 31526784..51ab7881 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,11 @@ HOST=0.0.0.0 PORT=8000 SECRET_KEY=super_long_default_key_change_this_in_production +# Note: SESSION_COOKIE_SECURE defaults to True in config.py for production security. +# If you are testing locally on HTTP (e.g. http://localhost:8000), you must set it to False +# or you will not be able to log in. +# SESSION_COOKIE_SECURE=false + # ── LLM ────────────────────────────────────────────────────────────── OPENAI_API_KEY=your_openai_api_key_here diff --git a/finbot/apps/finbot/auth.py b/finbot/apps/finbot/auth.py index 5a0656fc..461c3833 100644 --- a/finbot/apps/finbot/auth.py +++ b/finbot/apps/finbot/auth.py @@ -1,10 +1,14 @@ """Authentication routes for magic link sign-in""" import secrets +import time +from collections import defaultdict from datetime import UTC, datetime, timedelta +from threading import Lock from fastapi import APIRouter, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse +from pydantic import EmailStr, TypeAdapter, ValidationError from finbot.config import settings from finbot.core.auth.session import session_manager @@ -17,6 +21,41 @@ router = APIRouter(prefix="/auth", tags=["auth"]) +email_adapter = TypeAdapter(EmailStr) + +# --------------------------------------------------------------------------- +# Per-IP rate limiter — 5 requests / 60 s on the magic-link endpoint. +# Stdlib only; no new dependencies. For multi-worker deployments swap for +# a Redis-backed limiter (slowapi + limits) so the counter is shared. +# --------------------------------------------------------------------------- +_RATE_LIMIT_WINDOW = 60 # seconds +_RATE_LIMIT_MAX = 5 # requests per window +_MAX_STORE_SIZE = 10000 # Max IPs to track before eviction +_rate_store: dict[str, list[float]] = defaultdict(list) +_rate_lock = Lock() + + +def _is_rate_limited(ip: str) -> bool: + """Return True if `ip` has exceeded the magic-link rate limit.""" + now = time.monotonic() + cutoff = now - _RATE_LIMIT_WINDOW + with _rate_lock: + if len(_rate_store) >= _MAX_STORE_SIZE: + # Simple eviction: clear expired entries + for k in list(_rate_store.keys()): + _rate_store[k] = [t for t in _rate_store[k] if t > cutoff] + if not _rate_store[k]: + del _rate_store[k] + # If still full, clear the entire store to prevent memory leak + if len(_rate_store) >= _MAX_STORE_SIZE: + _rate_store.clear() + + _rate_store[ip] = [t for t in _rate_store[ip] if t > cutoff] + if len(_rate_store[ip]) >= _RATE_LIMIT_MAX: + return True + _rate_store[ip].append(now) + return False + def _is_authenticated(request: Request) -> bool: """Check if the current request has a verified (non-temporary) session.""" @@ -34,6 +73,34 @@ async def request_magic_link( return RedirectResponse(url="/portals", status_code=303) email = email.lower().strip() + + # --- Email format validation --- + try: + email_adapter.validate_python(email) + except ValidationError: + return template_response( + request, + "auth-error.html", + { + "error": "Invalid email", + "message": "Please enter a valid email address.", + }, + status_code=400 + ) + + # --- Per-IP rate limiting --- + client_ip = request.client.host if request.client else "unknown" + if _is_rate_limited(client_ip): + return template_response( + request, + "auth-error.html", + { + "error": "Too many requests", + "message": "Please wait a moment before requesting another sign-in link.", + }, + status_code=429 + ) + db = SessionLocal() try: # Get current session to link with token @@ -50,7 +117,7 @@ async def request_magic_link( session_id=session_id, expires_at=datetime.now(UTC) + timedelta(minutes=settings.MAGIC_LINK_EXPIRY_MINUTES), - ip_address=request.client.host if request.client else None, + ip_address=client_ip, ) db.add(magic_token) db.commit() diff --git a/finbot/config.py b/finbot/config.py index df362f5c..9e68f833 100644 --- a/finbot/config.py +++ b/finbot/config.py @@ -77,7 +77,7 @@ class Settings(BaseSettings): # Cookie Config SESSION_COOKIE_NAME: str = "finbot_session" - SESSION_COOKIE_SECURE: bool = False # Set to True in production with https + SESSION_COOKIE_SECURE: bool = True # Set to False in .env for local HTTP dev only SESSION_COOKIE_HTTP_ONLY: bool = True # Always HTTP-only for security SESSION_COOKIE_SAMESITE: str = "Lax"