RICS / app /api /auth.py
StormShadow308's picture
Add demo documentation and Docker setup for v2 report generation system
aad7814
Raw
History Blame Contribute Delete
8.01 kB
"""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)