UMBRA/backend/app/main.py
Kyle Pope e8e3f62ff8 Phase 1: Add passkey (WebAuthn/FIDO2) backend
New files:
- models/passkey_credential.py: PasskeyCredential model with indexed credential_id
- alembic 061: Create passkey_credentials table
- services/passkey.py: Challenge token management (itsdangerous + nonce replay
  protection) and py_webauthn wrappers for registration/authentication
- routers/passkeys.py: 6 endpoints (register begin/complete, login begin/complete,
  list, delete) with full security hardening

Changes:
- config.py: WEBAUTHN_RP_ID, RP_NAME, ORIGIN, CHALLENGE_TTL settings
- main.py: Mount passkey router, add CSRF exemptions for login endpoints
- auth.py: Add has_passkeys to /auth/status response
- nginx.conf: Rate limiting on all passkey endpoints, Permissions-Policy
  updated for publickey-credentials-get/create
- requirements.txt: Add webauthn>=2.1.0

Security: password re-entry for registration (V-02), single-use nonce
challenges (V-01), constant-time login/begin (V-03), shared lockout
counter, generic 401 errors, audit logging on all events.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:46:00 +08:00

157 lines
6.8 KiB
Python

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from app.config import settings
from app.database import engine
from app.routers import auth, todos, events, calendars, reminders, projects, people, locations, settings as settings_router, dashboard, weather, event_templates
from app.routers import totp, admin, notifications as notifications_router, connections as connections_router, shared_calendars as shared_calendars_router, event_invitations as event_invitations_router, passkeys as passkeys_router
from app.jobs.notifications import run_notification_dispatch
# Import models so Alembic's autogenerate can discover them
from app.models import user as _user_model # noqa: F401
from app.models import session as _session_model # noqa: F401
from app.models import totp_usage as _totp_usage_model # noqa: F401
from app.models import backup_code as _backup_code_model # noqa: F401
from app.models import system_config as _system_config_model # noqa: F401
from app.models import audit_log as _audit_log_model # noqa: F401
from app.models import notification as _notification_model # noqa: F401
from app.models import connection_request as _connection_request_model # noqa: F401
from app.models import user_connection as _user_connection_model # noqa: F401
from app.models import calendar_member as _calendar_member_model # noqa: F401
from app.models import event_lock as _event_lock_model # noqa: F401
from app.models import event_invitation as _event_invitation_model # noqa: F401
from app.models import passkey_credential as _passkey_credential_model # noqa: F401
# ---------------------------------------------------------------------------
# Pure ASGI CSRF middleware — SEC-08 (global)
# ---------------------------------------------------------------------------
class CSRFHeaderMiddleware:
"""
Require X-Requested-With: XMLHttpRequest on all state-mutating requests.
Browsers never send this header cross-origin without a CORS preflight,
which our CORS policy blocks. This prevents CSRF attacks from simple
form submissions and cross-origin fetches.
Uses pure ASGI (not BaseHTTPMiddleware) to avoid streaming/memory overhead.
"""
_EXEMPT_PATHS = frozenset({
"/health",
"/",
"/api/auth/login",
"/api/auth/setup",
"/api/auth/register",
"/api/auth/totp-verify",
"/api/auth/totp/enforce-setup",
"/api/auth/totp/enforce-confirm",
"/api/auth/passkeys/login/begin",
"/api/auth/passkeys/login/complete",
})
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
method = scope.get("method", "")
path = scope.get("path", "")
if method in self._MUTATING_METHODS and path not in self._EXEMPT_PATHS:
headers = dict(scope.get("headers", []))
if headers.get(b"x-requested-with") != b"XMLHttpRequest":
body = b'{"detail":"Invalid request origin"}'
await send({
"type": "http.response.start",
"status": 403,
"headers": [
[b"content-type", b"application/json"],
[b"content-length", str(len(body)).encode()],
],
})
await send({"type": "http.response.body", "body": body})
return
await self.app(scope, receive, send)
@asynccontextmanager
async def lifespan(app: FastAPI):
scheduler = AsyncIOScheduler()
scheduler.add_job(
run_notification_dispatch,
"interval",
minutes=1,
id="ntfy_dispatch",
max_instances=1, # prevent overlap if a run takes longer than 60s
)
scheduler.start()
yield
scheduler.shutdown(wait=False)
await engine.dispose()
_is_dev = settings.ENVIRONMENT == "development"
app = FastAPI(
title="UMBRA API",
description="Backend API for UMBRA application",
version="1.0.0",
lifespan=lifespan,
docs_url="/docs" if _is_dev else None,
redoc_url="/redoc" if _is_dev else None,
openapi_url="/openapi.json" if _is_dev else None,
)
# Middleware stack — added in reverse order (last added = outermost).
# CSRF is added first (innermost), then CORS wraps it (outermost).
# This ensures CORS headers appear on CSRF 403 responses.
app.add_middleware(CSRFHeaderMiddleware)
# CORS configuration — outermost layer
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "Cookie", "X-Requested-With"],
)
# Include routers with /api prefix
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
app.include_router(todos.router, prefix="/api/todos", tags=["Todos"])
app.include_router(events.router, prefix="/api/events", tags=["Calendar Events"])
app.include_router(calendars.router, prefix="/api/calendars", tags=["Calendars"])
app.include_router(reminders.router, prefix="/api/reminders", tags=["Reminders"])
app.include_router(projects.router, prefix="/api/projects", tags=["Projects"])
app.include_router(people.router, prefix="/api/people", tags=["People"])
app.include_router(locations.router, prefix="/api/locations", tags=["Locations"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"])
app.include_router(dashboard.router, prefix="/api", tags=["Dashboard"])
app.include_router(weather.router, prefix="/api/weather", tags=["Weather"])
app.include_router(event_templates.router, prefix="/api/event-templates", tags=["Event Templates"])
app.include_router(totp.router, prefix="/api/auth", tags=["TOTP MFA"])
app.include_router(passkeys_router.router, prefix="/api/auth/passkeys", tags=["Passkeys"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
app.include_router(notifications_router.router, prefix="/api/notifications", tags=["Notifications"])
app.include_router(connections_router.router, prefix="/api/connections", tags=["Connections"])
app.include_router(shared_calendars_router.router, prefix="/api/shared-calendars", tags=["Shared Calendars"])
app.include_router(event_invitations_router.events_router, prefix="/api/events", tags=["Event Invitations"])
app.include_router(event_invitations_router.router, prefix="/api/event-invitations", tags=["Event Invitations"])
@app.get("/")
async def root():
return {"message": "UMBRA API is running"}
@app.get("/health")
async def health_check():
return {"status": "healthy"}