diff --git a/.env.example b/.env.example index ce2fbff..1dff5c1 100644 --- a/.env.example +++ b/.env.example @@ -1,32 +1,34 @@ +# ────────────────────────────────────── # Database +# ────────────────────────────────────── POSTGRES_USER=umbra POSTGRES_PASSWORD=changeme_in_production POSTGRES_DB=umbra - -# Backend DATABASE_URL=postgresql+asyncpg://umbra:changeme_in_production@db:5432/umbra + +# ────────────────────────────────────── +# Application +# ────────────────────────────────────── +# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))" SECRET_KEY=change-this-to-a-random-secret-key-in-production -# Environment (development|production — controls Swagger/ReDoc visibility) -# ENVIRONMENT=development +# development | production — controls Swagger/ReDoc visibility and cookie defaults +ENVIRONMENT=development # CORS allowed origins (comma-separated, default: http://localhost:5173) -# CORS_ORIGINS=http://localhost:5173 +# CORS_ORIGINS=https://umbra.example.com # Timezone (applied to backend + db containers via env_file) TZ=Australia/Perth -# Session cookie security -# Set to true when serving over HTTPS. Required before any TLS deployment. -# COOKIE_SECURE=true - +# ────────────────────────────────────── # Integrations +# ────────────────────────────────────── OPENWEATHERMAP_API_KEY=your-openweathermap-api-key -# Production security checklist (enable all before any non-internal deployment): -# 1. Set SECRET_KEY to output of: openssl rand -hex 32 -# 2. Set POSTGRES_PASSWORD to a strong unique value -# 3. Set ENVIRONMENT=production (disables Swagger/ReDoc on backend:8000) -# 4. Set COOKIE_SECURE=true (requires TLS termination at nginx or upstream) -# 5. Add HSTS to nginx.conf: add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; -# 6. Complete user_id migration (migration 026) before enabling multi-user accounts +# ────────────────────────────────────── +# Overrides (rarely needed) +# ────────────────────────────────────── +# COOKIE_SECURE auto-derives from ENVIRONMENT (production → true). +# Only set explicitly to override, e.g. false for a non-TLS prod behind a proxy. +# COOKIE_SECURE=false diff --git a/README.md b/README.md index 01d27ac..db33f82 100644 --- a/README.md +++ b/README.md @@ -143,17 +143,13 @@ python3 -c "import secrets; print(secrets.token_hex(32))" python3 -c "import secrets; print(secrets.token_urlsafe(24))" # or: openssl rand -base64 24 -# Set ENVIRONMENT to disable Swagger/ReDoc +# Set ENVIRONMENT to disable Swagger/ReDoc and auto-enable secure cookies ENVIRONMENT=production - -# Enable secure cookies (requires HTTPS) -COOKIE_SECURE=true ``` Additionally for production: - Place behind a reverse proxy with TLS termination (e.g., Caddy, Traefik, or nginx with Let's Encrypt) -- Set `COOKIE_SECURE=true` to enforce HTTPS-only session cookies -- Set `ENVIRONMENT=production` to disable API documentation endpoints +- Set `ENVIRONMENT=production` — this disables API docs and auto-enables HTTPS-only session cookies (`COOKIE_SECURE` derives from `ENVIRONMENT`; override with `COOKIE_SECURE=false` if running non-TLS prod behind a proxy) - Set `CORS_ORIGINS` to your actual domain (e.g., `https://umbra.example.com`) - Consider adding HSTS headers at the TLS-terminating proxy layer diff --git a/backend/app/config.py b/backend/app/config.py index ca01e9c..73846f9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,4 +1,5 @@ import sys +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -6,7 +7,7 @@ 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 = False + COOKIE_SECURE: bool | None = None # Auto-derives from ENVIRONMENT if not explicitly set OPENWEATHERMAP_API_KEY: str = "" # Session config — sliding window @@ -28,8 +29,20 @@ class Settings(BaseSettings): case_sensitive=True ) + @model_validator(mode="after") + def derive_cookie_secure(self) -> "Settings": + if self.COOKIE_SECURE is None: + self.COOKIE_SECURE = self.ENVIRONMENT == "production" + assert self.COOKIE_SECURE is not None # type narrowing + 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":