Initial commit: webmail client (M0/M1)
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>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
"""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="/")
|
||||
Reference in New Issue
Block a user