Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion finbot/apps/finbot/auth.py
Original file line number Diff line number Diff line change
@@ -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

from finbot.config import settings
from finbot.core.auth.session import session_manager
Expand All @@ -17,6 +21,28 @@

router = APIRouter(prefix="/auth", tags=["auth"])

# ---------------------------------------------------------------------------
# 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
_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:
_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."""
Expand All @@ -34,6 +60,32 @@ async def request_magic_link(
return RedirectResponse(url="/portals", status_code=303)

email = email.lower().strip()

# --- Email format validation ---
try:
EmailStr._validate(email)
except Exception: # pydantic v2 raises PydanticCustomError for bad addresses
return template_response(
request,
"auth-error.html",
{
"error": "Invalid email",
"message": "Please enter a valid email address.",
},
)

# --- 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.",
},
)

db = SessionLocal()
try:
# Get current session to link with token
Expand All @@ -50,7 +102,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()
Expand Down
2 changes: 1 addition & 1 deletion finbot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down