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)