loopable / api /routes_auth.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
bf8519f verified
Raw
History Blame Contribute Delete
10.3 kB
"""routes_auth.py β€” X2's auth leg: login / logout / me (EXIT-3a).
Password auth against the SAME `core/users` accounts the Streamlit `gate()` uses, so both
front-ends share one identity today. The amended D-3 (our own OIDC client against Authentik, with
a branded login page) lands when D-1/D-2 unblock (owner blocker B-4); this is the interim leg it
replaces, and it is deliberately the same account store so the migration is a swap of the
CREDENTIAL check, not of the user model.
"""
import base64 as _b64
import hmac
import os
import re as _re
import time
from collections import OrderedDict
from fastapi import APIRouter, Body, Depends, Request, Response
import aios_session
from deps import Session, err, require_session, users, perms
router = APIRouter(prefix="/api/v1/auth")
#: THE SERVER OWNS BACKOFF (X5): the login page shows an inline error and never paces itself, so
#: the pacing has to be here or it does not exist. Bounded so it cannot grow into a memory leak.
#: ⚠ HONEST LIMIT: per-PROCESS. Behind several workers an attacker gets this budget per worker,
#: and it resets on deploy. It raises the cost of online guessing against PBKDF2-200k; it is not
#: a substitute for the real thing, which arrives with Authentik (D-1) owning rate limits centrally.
#: ⚠ KEYED BY (TENANT, USERNAME), not by username. Two tenants can each have an 'admin' or a
#: 'boss', and a shared bucket would mean failed logins against tenant B lock the same name out of
#: tenant A β€” a cross-tenant coupling in the one wave whose point is that tenants cannot reach
#: each other. It is also a (weak) existence oracle: a 429 for a name you never tried here would
#: tell you somebody else did.
_FAILS = OrderedDict()
_MAX_TRACKED = 2048
_LOCKOUT_AFTER = 8
_LOCKOUT_SECONDS = 60
def _throttled(key, now=None):
now = now or time.time()
hit = _FAILS.get(key)
if not hit:
return 0
count, last = hit
if count < _LOCKOUT_AFTER:
return 0
remaining = int(_LOCKOUT_SECONDS - (now - last))
return remaining if remaining > 0 else 0
def _note_failure(key, now=None):
now = now or time.time()
count, last = _FAILS.get(key, (0, 0.0))
# A window that has fully elapsed starts the count again β€” a user who mistypes twice today
# and twice next week is not an attacker.
if now - last > _LOCKOUT_SECONDS:
count = 0
_FAILS[key] = (count + 1, now)
_FAILS.move_to_end(key)
while len(_FAILS) > _MAX_TRACKED:
_FAILS.popitem(last=False)
def _clear_failures(key):
_FAILS.pop(key, None)
def _public_user(user):
"""What the client is allowed to know about itself. Never a hash, a salt, or the epoch β€”
the epoch is a server-side revocation handle and a client has no use for it."""
return {"username": user.get("username", ""), "name": user.get("name", ""),
"role": user.get("role", "user"),
# Wave 18 (C1-TENANT, R1): the company this session is bound to β€” the client shows
# it and tests assert the binding; the SERVER-side truth stays in the signed cookie.
"tenant": str(user.get("tenant") or "royal-imports").strip().lower(),
"bus": perms.allowed_bu_labels(user),
"team_id": perms.scope_team_id(user),
"agent": perms.scope_agent(user),
"modules": sorted(perms.allowed_modules(user) or []) or "all",
"landing": perms.landing_page(user),
# Wave 14 C-AVATAR β€” the user's OWN photo (Settings preview + the shell chip).
# Everyone else's rides workspace.userAvatars, keyed by display name.
"avatar": user.get("avatar") or None}
#: Wave 14 C-AVATAR β€” the write-side wall. PNG/JPEG data URLs only; the client downscales to
#: <=128px before posting, and the server re-validates because a stored data URL is served to
#: every grid session verbatim.
_AVATAR_RE = _re.compile(r"^data:image/(png|jpeg);base64,([A-Za-z0-9+/=]+)$")
_AVATAR_MAX_BYTES = 64 * 1024
@router.post("/me/avatar")
def set_avatar(body: dict = Body(default=None),
session: Session = Depends(require_session)):
import core.store as _store
raw = str((body or {}).get("dataUrl") or "")
m = _AVATAR_RE.match(raw)
if not m:
raise err(400, "bad_avatar",
"expected a data:image/png;base64,... or image/jpeg data URL")
try:
blob = _b64.b64decode(m.group(2), validate=True)
except Exception:
raise err(400, "bad_avatar", "that data URL is not valid base64")
if len(blob) > _AVATAR_MAX_BYTES:
raise err(400, "bad_avatar",
f"avatar too large - {_AVATAR_MAX_BYTES // 1024}KB decoded max "
f"(downscale to 128px before posting)")
if not _store.available():
raise err(503, "store_unavailable", "the tenant store is unavailable")
# session.uname is the canonical account handle; the session's user RECORD does not
# carry a "username" key (it is the registry's dict key), so keying the write off the
# record would silently no-op β€” caught by X7's fresh /me read.
users.set_avatar(session.uname, raw)
u2 = dict(session.user)
u2["avatar"] = raw
return {"user": _public_user(u2)}
@router.delete("/me/avatar")
def clear_avatar(session: Session = Depends(require_session)):
import core.store as _store
if not _store.available():
raise err(503, "store_unavailable", "the tenant store is unavailable")
users.set_avatar(session.uname, None)
u2 = dict(session.user)
u2.pop("avatar", None)
return {"user": _public_user(u2)}
@router.post("/login")
def login(request: Request, response: Response, body: dict = Body(default=None)):
"""Wave 18 (C1-TENANT, R1): ONE login box β€” the ACCOUNT decides the tenant.
The pre-wave flow validated a caller-posted tenant slug and minted the session for it, so
the same credential could sign in to any registered tenant. Now the credential resolves
FIRST (username or email β€” `users.verify` takes both) and the session binds to the tenant
ON THE RECORD; the posted `tenant` field is accepted for wire-compat and ignored. An
account whose tenant no longer resolves (deleted / suspended record) gets the same 401 as
a bad password β€” "which tenants exist" is not the login form's question to answer.
"""
body = body or {}
uname = str(body.get("username") or "").strip().lower()
pw = str(body.get("password") or "")
# Throttle on the IDENTIFIER alone: the tenant is not known until the credential resolves,
# and a per-(tenant, uname) key would let an attacker reset their budget by rotating slugs.
throttle_key = ("*", uname)
wait = _throttled(throttle_key)
if wait:
raise err(429, "too_many_attempts",
f"too many failed attempts β€” try again in {wait} seconds")
user = users.verify(uname, pw) if (uname and pw) else None
if not user:
# CONSTANT-TIME-ISH 401. `users.verify` returns immediately for an unknown username (no
# record, no PBKDF2) and burns ~200k iterations for a known one, so the response time
# answers "does this account exist?" to anyone with a stopwatch. Burning one equivalent
# hash on the failure path removes that oracle. It costs nothing on the happy path.
try:
users._hash(pw or "x", "00" * 16)
except Exception:
pass
_note_failure(throttle_key)
raise err(401, "invalid_credentials", "that username and password do not match")
tenant = str(user.get("tenant") or "royal-imports").strip().lower()
from harness import runtime
try:
runtime.get_runtime(tenant)
except KeyError:
_note_failure(throttle_key)
raise err(401, "invalid_credentials", "that username and password do not match")
_clear_failures(throttle_key)
value, _claims = aios_session.mint(tenant, user["username"], int(user.get("epoch") or 0))
aios_session.set_cookie(response, request, value)
# Wave 19 (R4): the login stamp. THE RESOLVED username, never the posted identifier β€” the
# person may have typed an email address and `users.verify` resolved it to the registry key;
# stamping what was typed would write onto a key that does not exist. Fail-silent inside
# `touch_login` and a no-op for the emergency-master identity (which has no record to stamp),
# so the one thing this cannot do is turn a good credential into a failed sign-in.
users.touch_login(user["username"])
# `touch_login` also sets `last_active`, so tell the session resolver it has been seen β€”
# otherwise the very next request stamps it again and the hourly throttle is off by one write
# per sign-in.
import deps as _deps
_deps.note_active(user["username"])
return {"user": _public_user(user)}
@router.post("/logout", status_code=204)
def logout(request: Request):
"""204 and the cookie is cleared. Deliberately NOT session-gated: logging out must work from
an already-invalid session, or a user holding a broken cookie has no way to get rid of it.
⚠ THE COOKIE IS CLEARED ON THE RETURNED RESPONSE, not on an injected `response` param. When a
handler RETURNS a Response object, FastAPI ships that object β€” anything written to the
injected `response` is silently dropped. The first version of this route took `response:
Response`, called `clear_cookie` on it, then returned a fresh `Response(204)`: the 204 was
correct, no `Set-Cookie` was ever sent, and the session stayed alive after a "successful"
logout. Caught by asserting `/auth/me` is 401 AFTER the logout rather than trusting the 204.
⚠ This clears the BROWSER's copy only β€” the signed value stays cryptographically valid until
it expires, which is the honest cost of a stateless session. "Sign me out everywhere" is
`core.users.bump_epoch` (a password change already does it); D2's Postgres session mirror
makes per-device revocation possible and arrives with C-2.
"""
out = Response(status_code=204)
aios_session.clear_cookie(out, request)
return out
@router.get("/me")
def me(session: Session = Depends(require_session)):
return {"user": _public_user(session.user), "tenant": session.tenant}