Spaces:
Runtime error
Runtime error
File size: 8,006 Bytes
865bc90 aad7814 865bc90 aad7814 865bc90 aad7814 865bc90 aad7814 865bc90 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | """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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@router.post("/auth/register", response_model=AuthTokenResponse, status_code=201)
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)
@router.post("/auth/login", response_model=AuthTokenResponse)
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)
@router.get("/auth/me", response_model=AuthWhoAmIResponse)
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)
|