Files
sales-trainer/backend/app/config.py
Macky d6e7cffc84 feat(auth): Google + Facebook OAuth login/register
Public social signup into OAUTH_DEFAULT_ORG (role user, seat-checked);
email-match links existing active user instead of duplicating. Server-side
provider token validation via stdlib urllib only (no new dep): Google
tokeninfo (aud + email_verified) and Facebook app/debug-token/me (is_valid,
app_id, me.id==user_id). Fail-closed when creds unconfigured, rate-limited
per-IP + per-email, /oauth/config leaks no secrets. Frontend: login buttons
(only enabled providers), GSI + FB SDK on-demand, monochrome glyphs, TH/EN.

Login page shows social buttons only when backend reports provider enabled.

348 backend tests pass (337 + 11 new OAuth), frontend build + 4/4 unit
clean, manual security review PASS. Not pushed (push auto-deploys).
2026-08-20 10:57:04 +07:00

153 lines
6.4 KiB
Python

"""Configuration from environment / .env."""
from __future__ import annotations
import os
from pathlib import Path
from dotenv import load_dotenv
# Load .env from backend/ (project root for this app)
_BACKEND_DIR = Path(__file__).resolve().parent.parent
load_dotenv(_BACKEND_DIR / ".env", override=False)
def _get_bool(name: str, default: bool = False) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def resolve_llm() -> tuple[str, str, str, str | None]:
"""Return (base_url, model, api_key, provider_name)."""
provider = os.environ.get("LLM_PROVIDER", "").strip()
explicit_base = os.environ.get("LLM_BASE_URL", "").strip()
explicit_model = os.environ.get("LLM_MODEL_NAME", "").strip()
api_key = os.environ.get("LLM_API_KEY", "").strip() or None
presets = {
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
"openai": ("https://api.openai.com/v1", "gpt-4o-mini"),
"custom": ("", ""),
}
if provider and provider in presets:
base_url, model = presets[provider]
if explicit_base:
base_url = explicit_base
if explicit_model:
model = explicit_model
return base_url, model, api_key or "", provider
# No/unknown provider: fall back to explicit config
return (
explicit_base or "https://api.openai.com/v1",
explicit_model or "gpt-4o-mini",
api_key or "",
provider or None,
)
class Config:
APP_NAME = "Sales Trainer"
# Secure-by-default: deployments must opt into development/test explicitly.
APP_ENV = os.environ.get("APP_ENV", "production").strip().lower()
BOOTSTRAP_ADMIN_PASSWORD = os.environ.get("BOOTSTRAP_ADMIN_PASSWORD", "").strip()
SECRET_KEY = os.environ.get("JWT_SECRET", "").strip()
JWT_ALGO = "HS256"
JWT_EXPIRES_HOURS = int(os.environ.get("JWT_EXPIRES_HOURS", "24"))
MIN_PASSWORD_LENGTH = 12
DATA_DIR = Path(
os.environ.get("DATA_DIR", str(_BACKEND_DIR / "data"))
).resolve()
DATABASE_URL = os.environ.get("DATABASE_URL", "").strip()
FLASK_HOST = os.environ.get("FLASK_HOST", "0.0.0.0")
FLASK_PORT = int(os.environ.get("FLASK_PORT", "5001"))
FLASK_DEBUG = _get_bool("FLASK_DEBUG", False)
CORS_ORIGINS = tuple(
origin.strip()
for origin in os.environ.get("CORS_ORIGINS", "").split(",")
if origin.strip()
)
UPLOAD_MAX_MB = int(os.environ.get("UPLOAD_MAX_MB", "15"))
ALLOWED_UPLOAD_EXTS = {"pdf", "md", "txt"}
UPLOAD_TEXT_MAX_KB = int(os.environ.get("UPLOAD_TEXT_MAX_KB", "256"))
UPLOAD_TEXT_MAX_BYTES = UPLOAD_TEXT_MAX_KB * 1024
UPLOAD_MAX_PDF_PAGES = int(os.environ.get("UPLOAD_MAX_PDF_PAGES", "100"))
UPLOAD_MAX_EXTRACTED_CHARS = int(os.environ.get("UPLOAD_MAX_EXTRACTED_CHARS", "60000"))
UPLOAD_MAX_FILES = int(os.environ.get("UPLOAD_MAX_FILES", "10"))
UPLOAD_MAX_PDF_CHUNK_CHARS = int(os.environ.get("UPLOAD_MAX_PDF_CHUNK_CHARS", "8192"))
ANALYTICS_EXPORT_MAX_ROWS = int(os.environ.get("ANALYTICS_EXPORT_MAX_ROWS", "10000"))
ANALYTICS_EXPORT_MAX_BYTES = int(
os.environ.get("ANALYTICS_EXPORT_MAX_BYTES", str(4 * 1024 * 1024))
)
ANALYTICS_EXPORT_MAX_CELL_CHARS = int(
os.environ.get("ANALYTICS_EXPORT_MAX_CELL_CHARS", "10000")
)
ANALYTICS_EXPORT_MAX_SCAN_RECORDS = int(
os.environ.get("ANALYTICS_EXPORT_MAX_SCAN_RECORDS", "100000")
)
# LLM
LLM_BASE_URL, LLM_MODEL, LLM_API_KEY, LLM_PROVIDER = resolve_llm()
# OAuth (Google + Facebook) — all optional; OAuth is disabled unless creds
# are fully configured (fail closed). Client IDs / app IDs are public and
# may be exposed to the frontend; the *secrets* must never be.
OAUTH_GOOGLE_CLIENT_ID = os.environ.get("OAUTH_GOOGLE_CLIENT_ID", "").strip()
OAUTH_GOOGLE_CLIENT_SECRET = os.environ.get("OAUTH_GOOGLE_CLIENT_SECRET", "").strip()
OAUTH_FACEBOOK_APP_ID = os.environ.get("OAUTH_FACEBOOK_APP_ID", "").strip()
OAUTH_FACEBOOK_APP_SECRET = os.environ.get("OAUTH_FACEBOOK_APP_SECRET", "").strip()
# Tenant id that public social signups land in. Missing/placeholder disables OAuth.
OAUTH_DEFAULT_ORG = os.environ.get("OAUTH_DEFAULT_ORG", "").strip()
_OAUTH_PROVIDER_CREDS = {
"google": ("OAUTH_GOOGLE_CLIENT_ID", "OAUTH_GOOGLE_CLIENT_SECRET"),
"facebook": ("OAUTH_FACEBOOK_APP_ID", "OAUTH_FACEBOOK_APP_SECRET"),
}
ROLES = ("super_admin", "admin", "user")
@classmethod
def oauth_provider_enabled(cls, provider: str) -> bool:
"""A provider is enabled only when every one of its creds + the default
org are configured and not a placeholder. Fail closed otherwise."""
names = cls._OAUTH_PROVIDER_CREDS.get(provider)
if names is None or cls._is_placeholder(cls.OAUTH_DEFAULT_ORG):
return False
for name in names:
if cls._is_placeholder(getattr(cls, name)):
return False
return True
@staticmethod
def _is_placeholder(value: str) -> bool:
normalized = (value or "").strip().lower()
return (
not normalized
or normalized in {"replace_me", "changeme", "change_me", "default", "password"}
or normalized.startswith("replace_with_")
or normalized.startswith("your_")
)
@classmethod
def validate_runtime_security(cls, *, require_bootstrap: bool = False) -> None:
"""Fail closed for production secrets and first-run initialization."""
secure_runtime = cls.APP_ENV not in {"development", "test"} or not cls.FLASK_DEBUG
if secure_runtime and (cls._is_placeholder(cls.SECRET_KEY) or len(cls.SECRET_KEY) < 32):
raise RuntimeError("JWT_SECRET must be configured with at least 32 characters")
if require_bootstrap:
if cls._is_placeholder(cls.BOOTSTRAP_ADMIN_PASSWORD):
raise RuntimeError("BOOTSTRAP_ADMIN_PASSWORD is required to initialize the first admin")
if len(cls.BOOTSTRAP_ADMIN_PASSWORD) < cls.MIN_PASSWORD_LENGTH:
raise RuntimeError(
"BOOTSTRAP_ADMIN_PASSWORD must be at least "
f"{cls.MIN_PASSWORD_LENGTH} characters"
)
@classmethod
def ensure_dirs(cls) -> None:
for name in ("users", "orgs", "groups", "sessions"):
(cls.DATA_DIR / name).mkdir(parents=True, exist_ok=True)