Spaces:
Runtime error
Runtime error
| """Tenant authentication: passphrase login + HMAC-signed bearer tokens. | |
| The token's subject (``sub``) is the tenant_id. Because the token is signed with | |
| a server-side secret, a client cannot forge a token for an arbitrary tenant, so | |
| ``request.state.tenant_id`` derived from a verified token is trustworthy β this | |
| is what enforces user isolation. Stdlib only (``hashlib``, ``hmac``, ``secrets``). | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import base64 | |
| import hashlib | |
| import hmac | |
| import json | |
| import logging | |
| import secrets | |
| import time | |
| from fastapi import APIRouter, Depends, HTTPException, Request | |
| from sqlalchemy import select | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| from app.config import settings | |
| from app.db.database import get_db | |
| from app.db.models import Tenant | |
| from app.models.schemas import AuthRequest, AuthTokenResponse, AuthWhoAmIResponse | |
| logger = logging.getLogger(__name__) | |
| router = APIRouter() | |
| _PBKDF2_ITERATIONS = 200_000 | |
| _DEFAULT_SECRETS = frozenset({"", "dev-secret-change-me", "change-me-in-production"}) | |
| # Process-ephemeral fallback secret, generated once if the operator shipped a | |
| # sentinel TENANT_SECRET_KEY in production. Tokens signed with it are | |
| # unforgeable but do not survive a restart (every restart forces re-login). | |
| _EPHEMERAL_SECRET: str | None = None | |
| def _signing_key() -> bytes: | |
| global _EPHEMERAL_SECRET | |
| configured = (settings.tenant_secret_key or "").strip() | |
| if configured and configured not in _DEFAULT_SECRETS: | |
| return configured.encode("utf-8") | |
| if settings.dev_mode: | |
| # Stable across the dev session; fine for local use. | |
| return (configured or "dev-secret-change-me").encode("utf-8") | |
| if _EPHEMERAL_SECRET is None: | |
| _EPHEMERAL_SECRET = secrets.token_urlsafe(48) | |
| logger.warning( | |
| "TENANT_SECRET_KEY is unset/default in production β using an " | |
| "ephemeral signing key. Set TENANT_SECRET_KEY so issued tokens " | |
| "survive restarts." | |
| ) | |
| return _EPHEMERAL_SECRET.encode("utf-8") | |
| # ββ Password hashing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def hash_password(password: str, *, salt: str | None = None, iterations: int = _PBKDF2_ITERATIONS) -> tuple[str, str, int]: | |
| """Return ``(hash_hex, salt_hex, iterations)`` for ``password``.""" | |
| salt_bytes = bytes.fromhex(salt) if salt else secrets.token_bytes(16) | |
| dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt_bytes, iterations) | |
| return dk.hex(), salt_bytes.hex(), iterations | |
| def verify_password(password: str, *, hash_hex: str, salt_hex: str, iterations: int) -> bool: | |
| candidate, _, _ = hash_password(password, salt=salt_hex, iterations=iterations) | |
| return hmac.compare_digest(candidate, hash_hex) | |
| # ββ Token mint / verify βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _b64u_encode(raw: bytes) -> str: | |
| return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") | |
| def _b64u_decode(data: str) -> bytes: | |
| pad = "=" * (-len(data) % 4) | |
| return base64.urlsafe_b64decode(data + pad) | |
| def mint_token(tenant_id: str, *, ttl_seconds: int | None = None) -> tuple[str, int]: | |
| """Create a signed token for ``tenant_id``; returns ``(token, expires_at)``.""" | |
| now = int(time.time()) | |
| exp = now + int(ttl_seconds if ttl_seconds is not None else settings.auth_token_ttl_seconds) | |
| payload = _b64u_encode(json.dumps({"sub": tenant_id, "iat": now, "exp": exp}, separators=(",", ":")).encode("utf-8")) | |
| sig = _b64u_encode(hmac.new(_signing_key(), payload.encode("ascii"), hashlib.sha256).digest()) | |
| return f"{payload}.{sig}", exp | |
| def verify_token(token: str) -> str | None: | |
| """Return the tenant_id if ``token`` is valid and unexpired, else ``None``.""" | |
| if not token or "." not in token: | |
| return None | |
| payload_b64, _, sig_b64 = token.partition(".") | |
| expected = _b64u_encode(hmac.new(_signing_key(), payload_b64.encode("ascii"), hashlib.sha256).digest()) | |
| if not hmac.compare_digest(sig_b64, expected): | |
| return None | |
| try: | |
| payload = json.loads(_b64u_decode(payload_b64)) | |
| except (ValueError, json.JSONDecodeError): | |
| return None | |
| sub = payload.get("sub") | |
| exp = payload.get("exp") | |
| if not isinstance(sub, str) or not isinstance(exp, int): | |
| return None | |
| if exp < int(time.time()): | |
| return None | |
| return sub | |
| # ββ Validation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _normalise_tenant_id(raw: str) -> str: | |
| tid = (raw or "").strip() | |
| if not (3 <= len(tid) <= 128): | |
| raise HTTPException(status_code=422, detail="Tenant/User ID must be 3β128 characters.") | |
| if not all(c.isalnum() or c in "._-" for c in tid): | |
| raise HTTPException( | |
| status_code=422, | |
| detail="Tenant/User ID may only contain letters, digits, '.', '_' and '-'.", | |
| ) | |
| return tid | |
| def _validate_passphrase(pw: str) -> None: | |
| if len(pw or "") < 8: | |
| raise HTTPException(status_code=422, detail="Passphrase must be at least 8 characters.") | |
| # ββ Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def register(body: AuthRequest, db: AsyncSession = Depends(get_db)) -> AuthTokenResponse: | |
| """Create a new tenant and return a signed access token.""" | |
| tenant_id = _normalise_tenant_id(body.tenant_id) | |
| _validate_passphrase(body.passphrase) | |
| existing = await db.get(Tenant, tenant_id) | |
| if existing is not None: | |
| raise HTTPException(status_code=409, detail="That Tenant/User ID is already taken. Log in instead.") | |
| h, salt, iters = await asyncio.to_thread(hash_password, body.passphrase) | |
| db.add(Tenant(id=tenant_id, password_hash=h, password_salt=salt, pbkdf2_iterations=iters)) | |
| await db.commit() | |
| token, exp = mint_token(tenant_id) | |
| logger.info("Registered tenant=%s", tenant_id) | |
| return AuthTokenResponse(tenant_id=tenant_id, access_token=token, expires_at=exp) | |
| async def login(body: AuthRequest, db: AsyncSession = Depends(get_db)) -> AuthTokenResponse: | |
| """Verify a tenant passphrase and return a signed access token.""" | |
| tenant_id = (body.tenant_id or "").strip() | |
| tenant = await db.get(Tenant, tenant_id) if tenant_id else None | |
| # Run a dummy verify even when the tenant is unknown to keep timing uniform. | |
| if tenant is None: | |
| await asyncio.to_thread(hash_password, body.passphrase or "x") | |
| raise HTTPException(status_code=401, detail="Invalid Tenant/User ID or passphrase.") | |
| ok = await asyncio.to_thread( | |
| verify_password, | |
| body.passphrase or "", | |
| hash_hex=tenant.password_hash, | |
| salt_hex=tenant.password_salt, | |
| iterations=tenant.pbkdf2_iterations, | |
| ) | |
| if not ok: | |
| raise HTTPException(status_code=401, detail="Invalid Tenant/User ID or passphrase.") | |
| from datetime import UTC, datetime | |
| tenant.last_login_at = datetime.now(UTC) | |
| await db.commit() | |
| token, exp = mint_token(tenant_id) | |
| return AuthTokenResponse(tenant_id=tenant_id, access_token=token, expires_at=exp) | |
| async def whoami(request: Request) -> AuthWhoAmIResponse: | |
| """Return the tenant resolved from the current request's verified token/header.""" | |
| return AuthWhoAmIResponse(tenant_id=request.state.tenant_id) | |