File size: 10,324 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
"""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}