Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
77 lines
2.4 KiB
Python
77 lines
2.4 KiB
Python
"""Session and CSRF cookie handling (SPEC §6.1, §10.3).
|
|
|
|
Over HTTPS we emit the spec-mandated ``__Host-session`` prefix with ``Secure``.
|
|
Over plain HTTP (dev only) we fall back to a plain ``session`` name without
|
|
``Secure`` so login works behind the Vite HTTP proxy. See docs/open-questions.md
|
|
OQ-3. ``SameSite=Lax``, ``HttpOnly`` (session), ``Path=/`` are always applied.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
|
|
from fastapi import Request, Response
|
|
|
|
SESSION_COOKIE_SECURE = "__Host-session"
|
|
SESSION_COOKIE_INSECURE = "session"
|
|
CSRF_COOKIE_SECURE = "__Host-csrf"
|
|
CSRF_COOKIE_INSECURE = "csrf"
|
|
CSRF_HEADER = "X-CSRF"
|
|
|
|
|
|
def _is_secure(request: Request) -> bool:
|
|
return request.url.scheme == "https"
|
|
|
|
|
|
def session_cookie_name(request: Request) -> str:
|
|
"""Return the session cookie name appropriate to the request scheme."""
|
|
return SESSION_COOKIE_SECURE if _is_secure(request) else SESSION_COOKIE_INSECURE
|
|
|
|
|
|
def csrf_cookie_name(request: Request) -> str:
|
|
"""Return the CSRF cookie name appropriate to the request scheme."""
|
|
return CSRF_COOKIE_SECURE if _is_secure(request) else CSRF_COOKIE_INSECURE
|
|
|
|
|
|
def read_session_token(request: Request) -> str:
|
|
"""Read the session token from either cookie name (whichever is present)."""
|
|
return request.cookies.get(SESSION_COOKIE_SECURE) or request.cookies.get(
|
|
SESSION_COOKIE_INSECURE, ""
|
|
)
|
|
|
|
|
|
def new_csrf_token() -> str:
|
|
"""Return a fresh CSRF token for the double-submit pattern."""
|
|
return secrets.token_urlsafe(32)
|
|
|
|
|
|
def set_auth_cookies(
|
|
request: Request, response: Response, session_token: str, csrf_token: str, max_age: int
|
|
) -> None:
|
|
"""Set the session (HttpOnly) and CSRF (JS-readable) cookies."""
|
|
secure = _is_secure(request)
|
|
response.set_cookie(
|
|
session_cookie_name(request),
|
|
session_token,
|
|
max_age=max_age,
|
|
httponly=True,
|
|
secure=secure,
|
|
samesite="lax",
|
|
path="/",
|
|
)
|
|
response.set_cookie(
|
|
csrf_cookie_name(request),
|
|
csrf_token,
|
|
max_age=max_age,
|
|
httponly=False, # double-submit: client JS echoes it in the X-CSRF header
|
|
secure=secure,
|
|
samesite="lax",
|
|
path="/",
|
|
)
|
|
|
|
|
|
def clear_auth_cookies(request: Request, response: Response) -> None:
|
|
"""Clear both auth cookies (logout)."""
|
|
for name in (session_cookie_name(request), csrf_cookie_name(request)):
|
|
response.delete_cookie(name, path="/")
|