UMBRA/backend/app/config.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

82 lines
3.2 KiB
Python

import sys
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/umbra"
SECRET_KEY: str = "your-secret-key-change-in-production"
ENVIRONMENT: str = "development"
COOKIE_SECURE: bool | None = None # Auto-derives from ENVIRONMENT if not explicitly set
OPENWEATHERMAP_API_KEY: str = ""
# Session config — sliding window
SESSION_MAX_AGE_DAYS: int = 7 # Sliding window: inactive sessions expire after 7 days
SESSION_TOKEN_HARD_CEILING_DAYS: int = 30 # Absolute token lifetime for itsdangerous max_age
# MFA token config (short-lived token bridging password OK → TOTP verification)
MFA_TOKEN_MAX_AGE_SECONDS: int = 300 # 5 minutes
# TOTP issuer name shown in authenticator apps
TOTP_ISSUER: str = "UMBRA"
# CORS allowed origins (comma-separated). Auto-derives from UMBRA_URL in
# production or http://localhost:5173 in development. Set explicitly to override.
CORS_ORIGINS: str | None = None
# Public-facing URL used in ntfy notification click links and CORS derivation
UMBRA_URL: str = "http://localhost"
# Concurrent session limit per user (oldest evicted when exceeded)
MAX_SESSIONS_PER_USER: int = 10
# WebAuthn / Passkey configuration
WEBAUTHN_RP_ID: str = "localhost" # eTLD+1 domain, e.g. "umbra.ghost6.xyz"
WEBAUTHN_RP_NAME: str = "UMBRA"
WEBAUTHN_ORIGIN: str = "http://localhost" # Full origin with scheme, e.g. "https://umbra.ghost6.xyz"
WEBAUTHN_CHALLENGE_TTL: int = 60 # Challenge token lifetime in seconds
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True
)
@model_validator(mode="after")
def derive_defaults(self) -> "Settings":
if self.COOKIE_SECURE is None:
self.COOKIE_SECURE = self.ENVIRONMENT == "production"
if self.CORS_ORIGINS is None:
if self.ENVIRONMENT == "production":
self.CORS_ORIGINS = self.UMBRA_URL
else:
self.CORS_ORIGINS = "http://localhost:5173"
assert self.COOKIE_SECURE is not None # type narrowing
assert self.CORS_ORIGINS is not None
# Validate WebAuthn origin includes scheme (S-04)
if not self.WEBAUTHN_ORIGIN.startswith(("http://", "https://")):
raise ValueError("WEBAUTHN_ORIGIN must include scheme (http:// or https://)")
return self
settings = Settings()
print(
f"INFO: COOKIE_SECURE={settings.COOKIE_SECURE} "
f"(ENVIRONMENT={settings.ENVIRONMENT})",
file=sys.stderr,
)
if settings.SECRET_KEY == "your-secret-key-change-in-production":
if settings.ENVIRONMENT != "development":
print(
"FATAL: Default SECRET_KEY detected in non-development environment. "
"Set a unique SECRET_KEY in .env immediately.",
file=sys.stderr,
)
sys.exit(1)
else:
print(
"WARNING: Using default SECRET_KEY. Set SECRET_KEY in .env for production.",
file=sys.stderr,
)