Auth (V2.1.1/V3.1.2, V2.1.3, V3.1.1): - Pin JWT iss/aud/typ at mint and require+verify them at decode; revocation (jti denylist + tokens_valid_from) still enforced. - Change-password now requires min_length=12. - SSE auth moves off JWT-in-URL to a single-use 60s opaque ticket (POST /auth/sse-ticket); raw JWT in query no longer authenticates a stream. Removed dead fail-open get_stream_user helper. Egress (V5.1.1, V9.1.1/V14.1.3): - Webhook delivery + CRUD reject SSRF destinations (private/loopback/link-local/ metadata, IPv4-mapped, multi-A-record) via resolved-IP validation, pin to the vetted IP, and never auto-follow redirects. Opt-out via DECNET_WEBHOOK_ALLOW_PRIVATE. - UpdaterClient pins the worker leaf cert SHA-256 against the stored per-host fingerprint (fail closed on missing/mismatch); DECNET_VERIFY_HOSTNAME now defaults True. Hardening (V13.1.3, V4.1.4, V13.1.2): - Rate-limit change-password (5/min), enroll-bundle (10/min), webhook-create (20/min), host-delete (20/min) via the existing slowapi limiter. - Correct false 'global auth middleware' comment; document enroll-bundle proxy trust. Correctness (BUG-7..11): - BUG-7 unbound bus in finally; BUG-8 apply_ceiling clamps to min(base,ceiling); BUG-9 commit before emit; BUG-10 multi-actor rearm for sub-threshold identities; BUG-11 normalize naive timestamps to UTC. Already-closed (no change): V14.1.1, V2.1.2/V3.1.3, V5.1.2. Tests added for every fix; unanimous adversarial review.
413 lines
16 KiB
Python
413 lines
16 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
import asyncio
|
|
import secrets
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Optional
|
|
|
|
import jwt
|
|
from fastapi import HTTPException, status, Request
|
|
from fastapi.security import OAuth2PasswordBearer
|
|
|
|
from decnet.web.auth import (
|
|
ALGORITHM,
|
|
JWT_AUDIENCE,
|
|
JWT_ISSUER,
|
|
JWT_TYPE,
|
|
SECRET_KEY,
|
|
)
|
|
from decnet.web.db.repository import BaseRepository
|
|
from decnet.web.db.factory import get_repository
|
|
|
|
# Shared repository singleton
|
|
_repo: Optional[BaseRepository] = None
|
|
|
|
def get_repo() -> BaseRepository:
|
|
"""FastAPI dependency to inject the configured repository."""
|
|
global _repo
|
|
if _repo is None:
|
|
_repo = get_repository()
|
|
return _repo
|
|
|
|
repo = get_repo()
|
|
|
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
|
|
|
|
|
# Per-request user lookup was the hidden tax behind every authed endpoint —
|
|
# SELECT users WHERE uuid=? ran once per call, serializing through aiosqlite.
|
|
# 10s TTL is well below JWT expiry and we invalidate on all user writes.
|
|
_USER_TTL = 10.0
|
|
_user_cache: dict[str, tuple[Optional[dict[str, Any]], float]] = {}
|
|
_user_cache_lock: Optional[asyncio.Lock] = None
|
|
|
|
# Username cache for the login hot path. Short TTL — the bcrypt verify
|
|
# still runs against the cached hash, so security is unchanged. The
|
|
# staleness window is: if a password is changed, the old password is
|
|
# usable for up to _USERNAME_TTL seconds until the cache expires (or
|
|
# invalidate_user_cache fires). We invalidate on every user write.
|
|
# Missing lookups are NOT cached to avoid locking out a just-created user.
|
|
_USERNAME_TTL = 5.0
|
|
_username_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
|
_username_cache_lock: Optional[asyncio.Lock] = None
|
|
|
|
# Denylist membership cache for revoked jti lookups. Same 10s envelope as the
|
|
# user cache: a token revoked elsewhere stops working within _REVOKED_TTL. In
|
|
# this process we drop the stale entry on revoke (see invalidate_token_cache),
|
|
# so logout is immediate locally; the TTL only bounds cross-worker staleness.
|
|
_REVOKED_TTL = 10.0
|
|
_revoked_cache: dict[str, tuple[bool, float]] = {}
|
|
_revoked_cache_lock: Optional[asyncio.Lock] = None
|
|
|
|
|
|
def _reset_user_cache() -> None:
|
|
global _user_cache, _user_cache_lock, _username_cache, _username_cache_lock
|
|
global _revoked_cache, _revoked_cache_lock
|
|
_user_cache = {}
|
|
_user_cache_lock = None
|
|
_username_cache = {}
|
|
_username_cache_lock = None
|
|
_revoked_cache = {}
|
|
_revoked_cache_lock = None
|
|
|
|
|
|
def invalidate_user_cache(user_uuid: Optional[str] = None) -> None:
|
|
"""Drop a single user (or all users) from the auth caches.
|
|
|
|
Callers: password change, role change, user create/delete.
|
|
The username cache is always cleared wholesale — we don't track
|
|
uuid→username and user writes are rare, so the cost is trivial.
|
|
"""
|
|
if user_uuid is None:
|
|
_user_cache.clear()
|
|
else:
|
|
_user_cache.pop(user_uuid, None)
|
|
_username_cache.clear()
|
|
|
|
|
|
def invalidate_token_cache(jti: Optional[str] = None) -> None:
|
|
"""Drop a single jti (or the whole denylist cache) so the next request
|
|
re-reads revocation state from the DB. Called right after ``revoke_token``
|
|
so a logged-out token stops working immediately in this process."""
|
|
if jti is None:
|
|
_revoked_cache.clear()
|
|
else:
|
|
_revoked_cache.pop(jti, None)
|
|
|
|
|
|
async def get_user_by_username_cached(username: str) -> Optional[dict[str, Any]]:
|
|
"""Cached read of get_user_by_username for the login path.
|
|
|
|
Positive hits are cached for _USERNAME_TTL seconds. Misses bypass
|
|
the cache so a freshly-created user can log in immediately.
|
|
"""
|
|
global _username_cache_lock
|
|
entry = _username_cache.get(username)
|
|
now = time.monotonic()
|
|
if entry is not None and now - entry[1] < _USERNAME_TTL:
|
|
return entry[0]
|
|
if _username_cache_lock is None:
|
|
_username_cache_lock = asyncio.Lock()
|
|
async with _username_cache_lock:
|
|
entry = _username_cache.get(username)
|
|
now = time.monotonic()
|
|
if entry is not None and now - entry[1] < _USERNAME_TTL:
|
|
return entry[0]
|
|
user = await repo.get_user_by_username(username)
|
|
if user is not None:
|
|
_username_cache[username] = (user, time.monotonic())
|
|
return user
|
|
|
|
|
|
async def _get_user_cached(user_uuid: str) -> Optional[dict[str, Any]]:
|
|
global _user_cache_lock
|
|
entry = _user_cache.get(user_uuid)
|
|
now = time.monotonic()
|
|
if entry is not None and now - entry[1] < _USER_TTL:
|
|
return entry[0]
|
|
if _user_cache_lock is None:
|
|
_user_cache_lock = asyncio.Lock()
|
|
async with _user_cache_lock:
|
|
entry = _user_cache.get(user_uuid)
|
|
now = time.monotonic()
|
|
if entry is not None and now - entry[1] < _USER_TTL:
|
|
return entry[0]
|
|
user = await repo.get_user_by_uuid(user_uuid)
|
|
_user_cache[user_uuid] = (user, time.monotonic())
|
|
return user
|
|
|
|
|
|
async def _is_revoked_cached(jti: str) -> bool:
|
|
global _revoked_cache_lock
|
|
entry = _revoked_cache.get(jti)
|
|
now = time.monotonic()
|
|
if entry is not None and now - entry[1] < _REVOKED_TTL:
|
|
return entry[0]
|
|
if _revoked_cache_lock is None:
|
|
_revoked_cache_lock = asyncio.Lock()
|
|
async with _revoked_cache_lock:
|
|
entry = _revoked_cache.get(jti)
|
|
now = time.monotonic()
|
|
if entry is not None and now - entry[1] < _REVOKED_TTL:
|
|
return entry[0]
|
|
revoked = await repo.is_token_revoked(jti)
|
|
_revoked_cache[jti] = (revoked, time.monotonic())
|
|
return revoked
|
|
|
|
|
|
_CREDENTIALS_EXCEPTION = HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Could not validate credentials",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
|
|
def _epoch(value: Any) -> float:
|
|
"""Coerce a JWT ``iat`` (int seconds) or a stored datetime to UTC epoch
|
|
seconds so the two can be compared regardless of source. Naive datetimes
|
|
(SQLite round-trips lose tzinfo) are treated as the UTC we wrote."""
|
|
if isinstance(value, (int, float)):
|
|
return float(value)
|
|
if isinstance(value, datetime):
|
|
aware = value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
|
|
return aware.timestamp()
|
|
raise _CREDENTIALS_EXCEPTION
|
|
|
|
|
|
def _decode_payload(token: str) -> dict[str, Any]:
|
|
"""Decode + signature/expiry-verify a raw JWT, or raise 401.
|
|
|
|
Beyond signature + expiry, this pins the issuer and audience and requires
|
|
the registered claims to be present, so a token minted with the same shared
|
|
secret for a different purpose (or omitting exp/iat/iss/aud) is rejected.
|
|
``uuid`` (not ``sub``) is this app's identity claim, so it is in ``require``.
|
|
``typ`` is a custom payload claim PyJWT does not validate natively, so it is
|
|
checked explicitly below.
|
|
"""
|
|
try:
|
|
payload: dict[str, Any] = jwt.decode(
|
|
token,
|
|
SECRET_KEY,
|
|
algorithms=[ALGORITHM],
|
|
audience=JWT_AUDIENCE,
|
|
issuer=JWT_ISSUER,
|
|
options={"require": ["exp", "iat", "iss", "aud", "uuid"]},
|
|
)
|
|
except jwt.PyJWTError:
|
|
raise _CREDENTIALS_EXCEPTION
|
|
if payload.get("uuid") is None:
|
|
raise _CREDENTIALS_EXCEPTION
|
|
if payload.get("typ") != JWT_TYPE:
|
|
raise _CREDENTIALS_EXCEPTION
|
|
return payload
|
|
|
|
|
|
async def _resolve_token(token: str) -> tuple[str, dict[str, Any]]:
|
|
"""Decode a token, load its user, and enforce revocation. Returns
|
|
``(user_uuid, user_dict)`` or raises 401. Single chokepoint so every auth
|
|
path (header, SSE query param, role gates) shares identical revocation
|
|
semantics."""
|
|
payload = _decode_payload(token)
|
|
user_uuid: str = payload["uuid"]
|
|
user = await _get_user_cached(user_uuid)
|
|
if not user:
|
|
# Unknown / deleted user — also covers the user-delete revocation case.
|
|
raise _CREDENTIALS_EXCEPTION
|
|
# 1. Legacy tokens minted before jti existed cannot be revoked — fail closed
|
|
# so a deploy of this feature forces exactly one re-login.
|
|
jti = payload.get("jti")
|
|
if not jti:
|
|
raise _CREDENTIALS_EXCEPTION
|
|
# 2. Bulk cutoff: password/role change moves tokens_valid_from forward.
|
|
# JWT iat is whole-seconds, so floor the cutoff to whole seconds too —
|
|
# otherwise a re-login landing in the SAME second as the change gets an
|
|
# iat that truncates below a sub-second cutoff and is wrongly rejected.
|
|
# Cost: tokens issued earlier in that same second survive (≤1s), which is
|
|
# negligible against a 24h lifetime.
|
|
cutoff = user.get("tokens_valid_from")
|
|
if cutoff is not None and _epoch(payload.get("iat", 0)) < int(_epoch(cutoff)):
|
|
raise _CREDENTIALS_EXCEPTION
|
|
# 3. Single-token denylist (logout).
|
|
if await _is_revoked_cached(jti):
|
|
raise _CREDENTIALS_EXCEPTION
|
|
return user_uuid, user
|
|
|
|
|
|
def _bearer_from_header(request: Request) -> Optional[str]:
|
|
auth = request.headers.get("Authorization")
|
|
if auth and auth.startswith("Bearer "):
|
|
return auth.split(" ", 1)[1]
|
|
return None
|
|
|
|
|
|
async def _resolve_request(request: Request) -> tuple[str, dict[str, Any]]:
|
|
"""Bearer-header variant of :func:`_resolve_token`."""
|
|
token = _bearer_from_header(request)
|
|
if not token:
|
|
raise _CREDENTIALS_EXCEPTION
|
|
return await _resolve_token(token)
|
|
|
|
|
|
async def get_token_claims(request: Request) -> dict[str, Any]:
|
|
"""Return the validated claims of the presented Bearer token (decode +
|
|
signature + user-exists + revocation checks, but NOT must_change). Used by
|
|
logout, which needs the token's own ``jti``/``exp`` to denylist *this*
|
|
session — and must still reject an already-revoked token."""
|
|
token = _bearer_from_header(request)
|
|
if not token:
|
|
raise _CREDENTIALS_EXCEPTION
|
|
await _resolve_token(token) # enforce user-exists + revocation; raises 401
|
|
return _decode_payload(token)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SSE stream tickets (V3.1.1)
|
|
# ---------------------------------------------------------------------------
|
|
# EventSource cannot set an Authorization header, so SSE auth historically rode
|
|
# in ?token=<JWT>, leaking the full-lifetime bearer into access/proxy logs,
|
|
# browser history, and Referer. Instead the client exchanges its header JWT for
|
|
# a single-use, short-lived OPAQUE ticket via POST /api/v1/auth/sse-ticket and
|
|
# connects with ?ticket=<opaque>. The JWT never appears in any URL.
|
|
#
|
|
# Security-boundary store — FAIL CLOSED. The map is keyed on the opaque ticket
|
|
# and holds (expiry_monotonic, bound_identity). Redemption validates presence +
|
|
# freshness, then DELETES the entry (single-use). Unknown / expired / reused
|
|
# tickets all resolve to 401.
|
|
#
|
|
# This is a MODULE-LEVEL dict: tickets live only in the process that minted
|
|
# them. A multi-process / multi-worker deployment needs a SHARED store (Redis,
|
|
# DB) so a ticket minted on worker A can be redeemed on worker B — out of scope
|
|
# here, deliberately. No background sweeper daemon (project rule: library, not
|
|
# new worker); expiry is enforced opportunistically on every redeem + mint.
|
|
_SSE_TICKET_TTL = 60.0 # seconds
|
|
_sse_tickets: dict[str, tuple[float, dict[str, Any]]] = {}
|
|
|
|
|
|
def _reset_sse_tickets() -> None:
|
|
"""Test hook: drop all outstanding stream tickets."""
|
|
_sse_tickets.clear()
|
|
|
|
|
|
def _sweep_sse_tickets(now: Optional[float] = None) -> None:
|
|
"""Opportunistic eviction of expired tickets. O(n) over a tiny map (tickets
|
|
are single-use and 60s-lived), called on every mint/redeem — no daemon."""
|
|
_now = time.monotonic() if now is None else now
|
|
expired = [t for t, (exp, _) in _sse_tickets.items() if exp <= _now]
|
|
for t in expired:
|
|
_sse_tickets.pop(t, None)
|
|
|
|
|
|
def mint_sse_ticket(user_uuid: str, role: str) -> str:
|
|
"""Mint a single-use, 60s opaque SSE ticket bound to ``user_uuid``+``role``.
|
|
|
|
Called by POST /auth/sse-ticket AFTER the header JWT has been validated, so
|
|
the bound identity is already trusted. Returns the opaque token the client
|
|
passes as ?ticket=. Sweeps expired entries on the way in.
|
|
"""
|
|
_sweep_sse_tickets()
|
|
ticket = secrets.token_urlsafe(32)
|
|
expiry = time.monotonic() + _SSE_TICKET_TTL
|
|
_sse_tickets[ticket] = (expiry, {"uuid": user_uuid, "role": role})
|
|
return ticket
|
|
|
|
|
|
def _redeem_sse_ticket(ticket: str) -> dict[str, Any]:
|
|
"""Redeem a stream ticket: validate exists + unexpired, then DELETE it
|
|
(single-use). Returns the bound ``{"uuid","role"}`` identity or raises 401.
|
|
Fail closed: unknown / expired / already-redeemed all raise."""
|
|
now = time.monotonic()
|
|
_sweep_sse_tickets(now)
|
|
entry = _sse_tickets.pop(ticket, None) # pop = single-use, even on expiry
|
|
if entry is None:
|
|
raise _CREDENTIALS_EXCEPTION
|
|
expiry, identity = entry
|
|
if expiry <= now:
|
|
raise _CREDENTIALS_EXCEPTION
|
|
return identity
|
|
|
|
|
|
async def get_current_user(request: Request) -> str:
|
|
"""Auth dependency — enforces must_change_password."""
|
|
_user_uuid, _user = await _resolve_request(request)
|
|
if _user.get("must_change_password"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Password change required before accessing this resource",
|
|
)
|
|
return _user_uuid
|
|
|
|
|
|
async def get_current_user_unchecked(request: Request) -> str:
|
|
"""Auth dependency — skips must_change_password enforcement (but still
|
|
enforces signature, user existence, and revocation).
|
|
Use only for endpoints that must remain reachable with the flag set (e.g. change-password).
|
|
"""
|
|
_user_uuid, _user = await _resolve_request(request)
|
|
return _user_uuid
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Role-based access control
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def require_role(*allowed_roles: str):
|
|
"""Factory that returns a FastAPI dependency enforcing role membership.
|
|
|
|
Inlines JWT decode + user lookup + must_change_password + role check so the
|
|
user is only loaded from the DB once per request (not once in
|
|
``get_current_user`` and again here). Returns the full user dict so
|
|
endpoints can inspect ``user["uuid"]``, ``user["role"]``, etc.
|
|
"""
|
|
async def _check(request: Request) -> dict:
|
|
_user_uuid, user = await _resolve_request(request)
|
|
if user.get("must_change_password"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Password change required before accessing this resource",
|
|
)
|
|
if user["role"] not in allowed_roles:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Insufficient permissions",
|
|
)
|
|
return user
|
|
return _check
|
|
|
|
|
|
def require_stream_role(*allowed_roles: str):
|
|
"""Like ``require_role`` but for SSE endpoints.
|
|
|
|
Two ingress paths:
|
|
* Bearer header → full ``_resolve_token`` (revocation + cutoff enforced).
|
|
* ?ticket=<opaque> → single-use stream ticket minted by /auth/sse-ticket,
|
|
which already validated the header JWT and bound the uuid+role. The
|
|
ticket carries no jti, so the per-token denylist cannot apply here; the
|
|
60s single-use lifetime is the bounded exposure we accept for SSE.
|
|
|
|
Raw ?token=<JWT> is intentionally NOT accepted (V3.1.1)."""
|
|
async def _check(request: Request, ticket: Optional[str] = None) -> dict:
|
|
header_token = _bearer_from_header(request)
|
|
if header_token:
|
|
_user_uuid, user = await _resolve_token(header_token)
|
|
if user["role"] not in allowed_roles:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Insufficient permissions",
|
|
)
|
|
return user
|
|
if not ticket:
|
|
raise _CREDENTIALS_EXCEPTION
|
|
identity = _redeem_sse_ticket(ticket)
|
|
if identity["role"] not in allowed_roles:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Insufficient permissions",
|
|
)
|
|
return identity
|
|
return _check
|
|
|
|
|
|
require_admin = require_role("admin")
|
|
require_viewer = require_role("viewer", "admin")
|
|
require_stream_viewer = require_stream_role("viewer", "admin")
|