diff --git a/Dockerfile b/Dockerfile index 3d06bbe..9843c5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,9 @@ COPY server/ server/ USER 10001 # Config via env: HOTDATA_API_KEY (required), HOTUSAGE_INGEST_TOKEN (required -# outside dev), HOTDATA_WORKSPACE / HOTDATA_API_HOST optional overrides. +# outside dev; its value is not itself a credential unless +# HOTUSAGE_ALLOW_SHARED_INGEST=1), HOTDATA_WORKSPACE / HOTDATA_API_HOST +# optional overrides. # This image only ever runs behind App Runner, which rewrites X-Forwarded-For, # so the rate limiter may believe it here. A bare `python3 server.py` gets the diff --git a/README.md b/README.md index 6c6d1be..2c9933b 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,9 @@ emailed: You can restrict a team link to an email domain and cap how many times it is used; it expires after 30 days by default, 90 at most. **Restrict it to your domain unless you are sharing it privately** — anyone holding an open link can -join and read your organization's usage. +join and read your organization's usage. Every new account has to confirm its +email address before it can see or report anything, so a restricted link really +does admit only people who can read mail at your domain. **Managing members.** Promote or demote admins, and remove people. Removing someone revokes their logins and their machines; usage they already reported diff --git a/docs/operating.md b/docs/operating.md index 17006e8..4cdc5e1 100644 --- a/docs/operating.md +++ b/docs/operating.md @@ -18,7 +18,7 @@ isolated rather than filtered out of a shared table. ```bash uv venv && uv pip install hotdata duckdb export HOTDATA_API_KEY= # or ~/.hotdata/hotdata.json -export HOTUSAGE_INGEST_TOKEN= # unset = dev mode (accepts all) +export HOTUSAGE_INGEST_TOKEN= # unset = dev mode (accepts all) .venv/bin/python server/server.py --host 0.0.0.0 --port 8377 ``` @@ -78,9 +78,46 @@ unknown scope fails the approval outright, rather than minting a token for different powers than the page just described to the person clicking Approve. **Per-user tokens identify their owner**: ingest signed in as someone reports as -that account whatever the payload claims. The shared `HOTUSAGE_INGEST_TOKEN` -still works but only *admits* — anyone holding it can report as any registered -colleague — so prefer sign-in. +that account whatever the payload claims. `HOTUSAGE_INGEST_TOKEN` being set is +what makes a token required at all; the value itself is **not** accepted as a +credential unless `HOTUSAGE_ALLOW_SHARED_INGEST=1`, because it only *admits* — +anyone holding it could report as any registered colleague in any organization. +Leave that off in production. + +## Email verification + +A registered address is a *claim* until a link mailed to it is opened while +signed in as that account. Until then the account sees nothing (every page +bounces to `/verify`, every `/api/` call and ingest is a 403), and anyone else +may register the same address over it — new password, old sessions revoked — +so an address cannot be squatted ahead of its owner. This is also what makes a +domain-restricted team link mean something: without it, anyone holding the link +could type any `@yourdomain` address and read the organization. + +The link only counts together with the session on purpose. It lands in the +mailbox whoever registered the address; if the click alone verified, a stranger +could register your address, wait for you to click the mail, and end up with a +verified account under your name. + +Mail goes out through a transactional mail API key and a From address on a +domain that provider has verified for you. Both are required or verification +is **off** (the server says so at boot, and then behaves exactly as before this +feature existed): + +```bash +export RESEND_API_KEY= +export HOTUSAGE_EMAIL_FROM="hototel " +``` + +Accounts created before `AuthStore.VERIFICATION_SINCE` (the moment this +shipped) are trusted as they were. A person whose mail never arrives can be +vouched for by an operator: + +```bash +server.py markverified jane@acme.com +``` + +Links live 24 hours; resends are rate limited per IP. ## Platform operators (system admins) @@ -114,6 +151,7 @@ server.py resetpw jane@acme.com # prints new password server.py deluser jane@acme.com # + revokes their logins and machines server.py makeadmin jane@acme.com # org admin (unadmin to demote) server.py makesysadmin jane@acme.com # platform operator (unsysadmin to revoke) +server.py markverified jane@acme.com # vouch for a mailbox whose link never arrived # invites and signed-in machines server.py listinvites # both kinds, with uses + days left diff --git a/server/server.py b/server/server.py index e69c9e1..49fc017 100644 --- a/server/server.py +++ b/server/server.py @@ -13,7 +13,9 @@ org database, and the dashboard reads only the viewer's org database. Auth: dashboard login (scrypt passwords, 30-day cookie sessions); collectors -send `Authorization: Bearer $HOTUSAGE_INGEST_TOKEN` (unset = dev mode). Usage +send a per-user bearer token from the device flow. HOTUSAGE_INGEST_TOKEN unset +means dev mode (accept all); set, it is the switch that requires a token, and +is itself accepted only with HOTUSAGE_ALLOW_SHARED_INGEST=1. Usage reported by an email that is not a registered user is rejected (403) — there is no org database to put it in. @@ -46,7 +48,10 @@ from datetime import datetime, timedelta, timezone from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler import html +from html import escape as html_escape from urllib.parse import urlparse, parse_qs, quote +import urllib.error +import urllib.request sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import core # noqa: E402 @@ -177,13 +182,29 @@ "key": ["token"], "cols": [("token", "VARCHAR"), ("scope", "VARCHAR")], }, + # Email verification. A registered address is a CLAIM until a link sent + # to it is opened while signed in as that account; until then the account + # sees and reports nothing, and anyone else may register the address over + # it. `verified_emails` is a side table (users' columns are fixed at first + # write); absence of a row means unverified, unless the account predates + # AuthStore.VERIFICATION_SINCE. + "email_verifications": { + "key": ["token"], + "cols": [("token", "VARCHAR"), ("email", "VARCHAR"), + ("created_at", "TIMESTAMPTZ"), ("expires_at", "DOUBLE")], + }, + "verified_emails": { + "key": ["email"], + "cols": [("email", "VARCHAR"), ("verified_at", "TIMESTAMPTZ")], + }, } USAGE_TABLES = ("sessions", "requests", "daily_usage") SYSTEM_TABLES = ("orgs", "users", "auth_sessions", "invites", "team_invites", "device_codes", "device_scopes", "collector_tokens", "collector_token_usage", "token_scopes", - "org_admins", "system_admins", "org_memberships") + "org_admins", "system_admins", "org_memberships", + "email_verifications", "verified_emails") # --------------------------------------------------------------------------- @@ -258,12 +279,23 @@ def sql(self, query, timeout=None): return [dict(zip(cols, r)) for r in rows] def rows(self, query): - """sql() that treats declared-but-empty tables as empty results.""" + """sql() that treats declared-but-empty tables as empty results. + + Only a 400 qualifies. hotdata answers a missing or never-written table + with HTTP 400 and text like "not found" / "has no data" (measured + 2026-09-17); a 404 is the API saying the DATABASE or workspace is not + there, and the SDK formats that as "Reason: Not Found" -- the same + words. Reading a 404 as "no rows" made every "is this org empty?" + check fail open: a transient 404 during a join would have made the + joiner an admin of an established org, and one during delete-org would + have removed an org that still had members. So anything but a 400 + propagates, and those callers fail the request instead of guessing.""" try: return self.sql(query) except Exception as e: msg = str(e).lower() - if "not found" in msg or "has no data" in msg: + if getattr(e, "status", None) == 400 and \ + ("not found" in msg or "has no data" in msg): # Text matching, not exception classes: the SDK import is lazy, # so there is no class to catch here. That makes this branch wide # enough to swallow a genuinely broken query, so say so -- an @@ -369,6 +401,36 @@ def gather(tasks=None, /, **thunks): # AuthStore: orgs, users, cookie sessions in the system database. Creating an # org provisions its dedicated usage database. # --------------------------------------------------------------------------- +class ResendMailer: + """The one thing this server ever emails: a verification link. + + Resend's REST API over urllib -- one POST, a bearer key, JSON in and out -- + so there is still no dependency beyond the standard library. Configured + from RESEND_API_KEY and HOTUSAGE_EMAIL_FROM; with either unset there is no + mailer, and verification is off (see main).""" + URL = "https://api.resend.com/emails" + TIMEOUT = 10 + + def __init__(self, api_key, sender): + self.api_key = api_key + self.sender = sender + + def send(self, to, subject, text, html): + body = json.dumps({"from": self.sender, "to": [to], "subject": subject, + "text": text, "html": html}).encode() + req = urllib.request.Request( + self.URL, data=body, method="POST", + headers={"Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=self.TIMEOUT) as r: + return json.loads(r.read() or b"{}").get("id") + except urllib.error.HTTPError as e: + # the body names the problem (bad key, unverified sending domain); + # a prefix of it is enough for the log + raise RuntimeError(f"resend {e.code}: {e.read()[:200]!r}") from None + + SESSION_TTL = 30 * 24 * 3600 SYS = core.SYSTEM_CATALOG @@ -397,6 +459,11 @@ def __init__(self, system, pool): self.org_cache = {} # org_slug -> (org dict, fetched_at) self.scope_cache = {} # token -> (scope, fetched_at); real tokens only self.read_cache = {} # token -> (viewer dict, fetched_at) + self.verified_cache = {} # email -> (verified, fetched_at) + # Whether addresses must be verified at all. Set by main() when a + # mailer is configured; off, is_verified is always True, so dev, CI + # and a deployment without Resend keep working as before. + self.verification = False self.lock = threading.Lock() def get_org(self, slug, ttl=60): @@ -509,7 +576,8 @@ def logout(self, token): # (table, table whose rows share this one's key and die with it) EXPIRING_TABLES = (("auth_sessions", None), ("invites", None), - ("team_invites", None), ("device_codes", "device_scopes")) + ("team_invites", None), ("device_codes", "device_scopes"), + ("email_verifications", None)) def _purge_expired(self): """Sweep every expired row. Per table, because one unreachable or @@ -614,6 +682,13 @@ def create_account(self, email, password): so the account exists first and outlives either.""" email = email.strip().lower() if self.get_user(email): + if self.verification and not self.is_verified(email): + # nobody has proved this mailbox yet, so the row is a claim, + # not an account: whoever registers now takes it over and the + # link in the mailbox decides. This is what stops an address + # being squatted ahead of its owner. + self.take_over(email, password) + return raise ValueError("that email is already registered") self.sysdb.load("users", [{"email": email, "password_hash": hash_password(password), @@ -622,6 +697,7 @@ def create_account(self, email, password): "upsert") with self.lock: self.route_cache.pop(email, None) + self.verified_cache.pop(email, None) def create_org_for(self, email, org_name): """Create an org around an account that already exists, making it the @@ -697,12 +773,24 @@ def accept_invite(self, token, password=None, as_user=None): if not inv: raise ValueError("this invite is invalid or has expired") email = inv["email"] - if self.get_user(email): - # possession of the link is not proof of the mailbox; the session is - if (as_user or "").strip().lower() != email: - raise ValueError("this invite belongs to an existing account - " - "sign in as " + email + " first") + user = self.get_user(email) + if user and (as_user or "").strip().lower() == email: + # signed in as the invited address: the session already proved the + # password, verified or not, so this is a plain join. (Unverified, + # the account still cannot see anything until the mailbox link.) self.join_org(email, inv["org_slug"]) + elif user and self.verification and not self.is_verified(email): + # an unverified row is a claim on the address, not an account (see + # create_account): the acceptor sets the password and the mailbox + # decides who really joined + if not password: + raise ValueError("choose a password") + self.take_over(email, password) + self.join_org(email, inv["org_slug"]) + elif user: + # possession of the link is not proof of the mailbox; the session is + raise ValueError("this invite belongs to an existing account - " + "sign in as " + email + " first") else: self.create_user(email, password, inv["org_slug"]) self.sysdb.load("invites", [{"token": token}], "delete") @@ -805,7 +893,10 @@ def all_orgs(self): # one query spanning both tables cannot degrade per table -- so an # empty org_memberships would zero every count. Re-read separately. counts = self._counts_from_full_reads() - return [{**o, "members": counts.get(o["slug"], 0)} for o in got["orgs"]] + # database ids stay on the server: the admin page names orgs by slug, + # and the id is an internal handle no browser needs + return [{"slug": o["slug"], "name": o["name"], "created_at": o["created_at"], + "members": counts.get(o["slug"], 0)} for o in got["orgs"]] def _counts_from_full_reads(self): both = gather( @@ -914,6 +1005,13 @@ def remove_from_org(self, email, org_slug): email = email.strip().lower() remaining = self.memberships(email) - {org_slug} if not remaining: + # this was their only org, so removal means deleting the account -- + # and an org admin must not be able to delete a platform operator + # (remove_user also strips the system-admin grant). The CLI's + # deluser is the operator path and calls remove_user directly. + if self.is_system_admin(email): + raise ValueError("that person is a platform operator; only a " + "system admin can remove them (server.py deluser)") self.remove_user(email) return self.set_admin(email, org_slug, False) @@ -956,9 +1054,20 @@ def remove_user(self, email): # and the platform grant: otherwise whoever re-registers this address # at the public /register endpoint inherits system admin self.set_system_admin(email, False) + for table in ("verified_emails", "email_verifications"): + try: + rows = self.sysdb.rows(f"SELECT * FROM {SYS}.public.{table} " + f"WHERE email = {sql_str(email)}") + if rows: + key = TABLES[table]["key"] + self.sysdb.load(table, [{k: r[k] for k in key} for r in rows], "delete") + except Exception as e: + print(f"warn: {table} row delete: {e}", file=sys.stderr) self.sysdb.load("users", [{"email": email}], "delete") with self.lock: self.route_cache.pop(email, None) + # or whoever registers this address next inherits a verified answer + self.verified_cache.pop(email, None) def rename_org(self, slug, name): # read every column: an upsert must carry the whole row, and get_org @@ -1147,7 +1256,21 @@ def accept_team_invite(self, token, email, password, as_user=None): raise ValueError("that does not look like an email address") if inv["domain"] and email.rsplit("@", 1)[1] != inv["domain"]: raise ValueError(f"this link only accepts @{inv['domain']} addresses") - if self.get_user(email): + user = self.get_user(email) + # signed in as the address, the session already proved the password: + # that is a plain join (the elif below), not a takeover of oneself + signed_in_as = (as_user or "").strip().lower() == email + if user and self.verification and not self.is_verified(email) \ + and not signed_in_as: + # same takeover as accept_invite: the row is a claim until the + # mailbox confirms it, and the domain check above is only worth + # anything because of that + if not password: + raise ValueError("choose a password") + self.take_over(email, password) + if inv["org_slug"] not in self.memberships(email): + self.join_org(email, inv["org_slug"]) + elif user: if (as_user or "").strip().lower() != email: raise ValueError("that email already has an account - sign in first") if inv["org_slug"] in self.memberships(email): @@ -1434,6 +1557,137 @@ def _touch_token(self, token): "last_used_at": datetime.now(timezone.utc).isoformat(), }], "upsert") + # --- email verification -------------------------------------------------- + VERIFY_TTL = 24 * 3600 + # Both answers expire. A verified answer never reverts for one address, + # but remove_user deletes the row and the address may be registered again + # by someone else -- and a delete is seen only by the instance that served + # it, so a positive entry cached forever on another instance would let the + # next registrant inherit it (TOKEN_CACHE_TTL exists for the same reason). + # An unverified answer is re-read often, since the next request after the + # click should see the change. + VERIFIED_TTL = 60 + VERIFIED_NEG_TTL = 15 + # Accounts created before this predate verification and are trusted as + # they were. Everyone after it proves the mailbox before seeing or + # reporting anything. The instant the change shipped, not a round date: + # a later cutoff would grandfather whoever registered in the gap. + VERIFICATION_SINCE = datetime(2026, 9, 17, 19, 0, tzinfo=timezone.utc) + + @staticmethod + def _as_datetime(value): + if isinstance(value, str): + try: + value = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if not isinstance(value, datetime): + return None + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value + + def is_verified(self, email): + """Has this address been proved -- or does it predate the requirement? + Always True with verification off.""" + if not self.verification: + return True + email = email.strip().lower() + now = time.time() + with self.lock: + hit = self.verified_cache.get(email) + ttl = self.VERIFIED_TTL if hit and hit[0] else self.VERIFIED_NEG_TTL + if hit and now - hit[1] < ttl: + return hit[0] + ok = bool(self.sysdb.rows(f"SELECT email FROM {SYS}.public.verified_emails " + f"WHERE email = {sql_str(email)}")) + if not ok: + rows = self.sysdb.rows(f"SELECT created_at FROM {SYS}.public.users " + f"WHERE email = {sql_str(email)}") + created = self._as_datetime(rows[0]["created_at"]) if rows else None + ok = created is not None and created < self.VERIFICATION_SINCE + with self.lock: + self.verified_cache[email] = (ok, now) + return ok + + def mark_verified(self, email): + email = email.strip().lower() + self.sysdb.load("verified_emails", [{ + "email": email, + "verified_at": datetime.now(timezone.utc).isoformat()}], "upsert") + with self.lock: + self.verified_cache.pop(email, None) + + def take_over(self, email, password): + """Re-register an UNVERIFIED address: new password, every session the + previous claimant held is revoked, and every org membership, admin + grant and the active org they acquired is dropped. Otherwise someone + could pre-register a victim's address through a team link into their + own org, and the victim, registering later, would land in it and + report every session there. Same rule as remove_user: whoever claims + an address next inherits nothing. Invite-driven callers join their org + right after. Callers check is_verified first.""" + email = email.strip().lower() + for slug in self.memberships(email): + self.set_admin(email, slug, False) + self.sysdb.load("org_memberships", + [{"email": email, "org_slug": slug}], "delete") + rows = self.sysdb.rows(f"SELECT email, created_at FROM {SYS}.public.users " + f"WHERE email = {sql_str(email)}") + if not rows: + raise ValueError(f"no such user: {email}") + self.sysdb.load("users", [{"email": email, + "password_hash": hash_password(password), + "org_slug": "", + "created_at": str(rows[0]["created_at"])}], + "upsert") + toks = self.sysdb.rows(f"SELECT token FROM {SYS}.public.auth_sessions " + f"WHERE user_email = {sql_str(email)}") + if toks: + self.sysdb.load("auth_sessions", toks, "delete") + with self.lock: + for t in toks: + self.token_cache.pop(t["token"], None) + self.route_cache.pop(email, None) + self.verified_cache.pop(email, None) + + def start_verification(self, email): + """Mint a link token for `email`. Returns the token; the caller mails it.""" + email = email.strip().lower() + if not self.get_user(email): + raise ValueError("no such user") + token = secrets.token_urlsafe(32) + self.sysdb.load("email_verifications", [{ + "token": token, "email": email, + "created_at": datetime.now(timezone.utc).isoformat(), + "expires_at": time.time() + self.VERIFY_TTL}], "upsert") + return token + + def consume_verification(self, token, as_user): + """Mark the address verified if `token` is live AND the person opening + it is signed in as that account. Returns the email, or None. + + The session requirement is the point: a link lands in the mailbox + whoever registered the address, so a stranger who registered it and + then waited for the owner to click would otherwise end up with a + verified account under the owner's name. Every link for the address + dies with the one that was used.""" + if not token or not re.match(r"^[A-Za-z0-9_-]{20,64}$", token): + return None + rows = self.sysdb.rows(f"SELECT token, email, expires_at " + f"FROM {SYS}.public.email_verifications " + f"WHERE token = {sql_str(token)}") + if not rows or float(rows[0]["expires_at"]) < time.time(): + return None + email = rows[0]["email"] + if (as_user or "").strip().lower() != email or not self.get_user(email): + return None + self.mark_verified(email) + siblings = self.sysdb.rows(f"SELECT token FROM {SYS}.public.email_verifications " + f"WHERE email = {sql_str(email)}") + self.sysdb.load("email_verifications", siblings or [{"token": token}], "delete") + return email + def seed(self): """First boot: system tables; org hotdata (with its dedicated database) and eddie@hotdata.dev as the first user.""" @@ -1559,7 +1813,7 @@ def _fetch_data(self, days=None): } for r in daily_rows] return { "generatedAt": datetime.now(timezone.utc).isoformat(), - "source": f"hotdata {self.hd.db}", + "source": "hotdata", "sessions": sessions, "daily": daily, } @@ -1622,6 +1876,10 @@ def apply_ingest(auth, pool, payload): raise PermissionError( f"{user} is not a registered user; an admin must add them " f"(server.py adduser {user} --org ) before their usage is accepted") + if not auth.is_verified(user): + raise PermissionError( + f"{user} has not confirmed their email address yet; open the link " + f"hototel sent them (or sign in and request a new one at /verify)") org_slug, db_id = route sessions = payload.get("sessions") or [] if not sessions: @@ -1645,6 +1903,14 @@ class Handler(BaseHTTPRequestHandler): stores = None # StorePool auth = None # AuthStore (system database) token = None # ingest bearer token ('' = dev mode, accept all) + # Whether that shared token is itself accepted as an ingest credential. + # Off by default: it only ADMITS -- anyone holding it can report as any + # registered address in any organization -- so in production the token is + # just the switch that says "authentication is required" and every write + # must carry a per-user token. HOTUSAGE_ALLOW_SHARED_INGEST=1 turns it on. + allow_shared = False + mailer = None # ResendMailer, or None when verification is off + VERIFY_RATE_LIMIT = 5 # resends per IP per hour max_body = 64 * 1024 * 1024 # socketserver sets this on the connection socket, so a peer that opens a @@ -1849,6 +2115,71 @@ def _install_skipped(self): return any(part.strip().startswith("hotusage_install_skipped=1") for part in header.split(";")) + def _gated_on_verify(self, viewer, path): + """An account that has not confirmed its address sees and does + nothing: pages go to /verify, API calls get a 403. One gate for the + cookie session AND the read token, since both arrive as a viewer. + Returns True when it handled the request. No-op with verification off.""" + if self.auth.is_verified(viewer["email"]): + return False + if path.startswith("/api/"): + self._json({"error": "confirm your email address first: sign in at " + "/verify and open the link we sent"}, 403) + else: + self._redirect("/verify") + return True + + def _send_verification(self, email): + """Mail a fresh link. Failure is logged, never raised: the account + exists either way and /verify offers a resend.""" + if not self.mailer: + return + try: + token = self.auth.start_verification(email) + proto, host = self._public_origin() + link = f"{proto}://{host}/verify/{token}" + text = (f"Confirm this address to finish setting up hototel:\n\n{link}\n\n" + f"The link works for 24 hours, and only while you are signed in " + f"to hototel as {email}. If you did not create a hototel account, " + f"you can ignore this message.") + html = (f"

Confirm this address to finish setting up hototel:

" + f"

{html_escape(link)}

" + f"

The link works for 24 hours, and only while you are signed in " + f"to hototel as {html_escape(email)}. If you did not create a " + f"hototel account, you can ignore this message.

") + self.mailer.send(email, "Confirm your email for hototel", text, html) + except Exception as e: + print(f"error: verification mail to {email}: {e}", file=sys.stderr) + + def _handle_verify_page(self, parsed): + """GET /verify (the waiting page) and GET /verify/ (the link).""" + viewer = self._viewer() + if not viewer: + # the link only counts while signed in as its account -- see + # consume_verification -- so sign in first, then come back to it + self._redirect("/login?next=" + quote(self.path)) + return + token = parsed.path[len("/verify/"):] if parsed.path.startswith("/verify/") else "" + if token: + if self.auth.consume_verification(token, as_user=viewer["email"]): + self._redirect("/") + return + self._page("verify.html", { + "EMAIL": viewer["email"], "MSG": "", + "ERROR": "That link is invalid, expired, or for a different " + "account. Request a new one below."}) + return + if self.auth.is_verified(viewer["email"]): + self._redirect("/") + return + q = parse_qs(parsed.query) + msg, err = "", "" + if q.get("sent"): + msg = "A new link is on its way." + elif q.get("err") == ["rate"]: + err = "Too many requests; try again in a while." + self._page("verify.html", {"EMAIL": viewer["email"], "MSG": msg, "ERROR": err}) + def _gated_on_install(self, viewer, path): """Send an account with nothing reporting to the install prompt. @@ -2000,9 +2331,11 @@ def _handle_register(self): except ValueError as e: self._redirect("/register?err=" + quote(str(e))) return + self._send_verification(email) token = self.auth.login(email, password) - # the account exists now; _needs_org sends them on to make one - self._redirect("/setup", extra=[("Set-Cookie", self._session_cookie(token))]) + # the account exists now: confirm the address, then make an org + self._redirect("/verify" if self.auth.verification else "/setup", + extra=[("Set-Cookie", self._session_cookie(token))]) def _handle_setup(self): """Create the org for a signed-in account that has none.""" @@ -2010,6 +2343,8 @@ def _handle_setup(self): if not viewer: self._redirect("/login?next=%2Fsetup") return + if self._gated_on_verify(viewer, "/setup"): + return form = self._read_form() nxt = self._safe_next(form.get("next", "/")) if viewer.get("org_slug"): # already belongs somewhere: nothing to make @@ -2045,10 +2380,17 @@ def _handle_invite_accept(self, token): as_user = viewer["email"] if viewer else None try: inv = self.auth.get_invite(token) - joining_existing = ( - (inv and self.auth.get_user(inv["email"])) or - (not inv and form.get("email") and - self.auth.get_user(form.get("email", "")))) + + def established(addr): + # an unverified row is a claim, not an account: the acceptor + # sets a password and takes it over (see AuthStore.take_over). + # Signed in as the address, the session is the proof instead. + addr = (addr or "").strip().lower() + return bool(addr and self.auth.get_user(addr) + and (addr == (as_user or "").strip().lower() + or self.auth.is_verified(addr))) + joining_existing = established(inv["email"]) if inv \ + else established(form.get("email", "")) if not joining_existing and len(password) < 8: raise ValueError("password must be at least 8 characters") if inv: @@ -2061,7 +2403,9 @@ def _handle_invite_accept(self, token): self._redirect(f"/invite/{quote(token, safe='')}?err=" + quote(str(e)) + "&email=" + quote(form.get("email", "")[:120])) return - if viewer and viewer["email"] == email: + if not joining_existing: + self._send_verification(email) + if viewer and viewer["email"] == email and joining_existing: # already signed in; switch_org cleared the token cache, so the # next request re-reads the new active org self._redirect("/") @@ -2112,12 +2456,32 @@ def do_POST(self): print(f"error: /invite: {e}", file=sys.stderr) self._json({"error": "invite failed"}, 500) return + if path == "/verify/resend": + try: + viewer = self._viewer() + if not viewer: + self._redirect("/login?next=%2Fverify") + return + if self.auth.is_verified(viewer["email"]): + self._redirect("/") + return + if self._rate_limited("verify", limit=self.VERIFY_RATE_LIMIT): + self._redirect("/verify?err=rate") + return + self._send_verification(viewer["email"]) + self._redirect("/verify?sent=1") + except Exception as e: + print(f"error: /verify/resend: {e}", file=sys.stderr) + self._json({"error": "could not send that"}, 500) + return if path == "/api/invite": try: viewer = self._viewer() if not viewer: self._json({"error": "unauthorized"}, 401) return + if self._gated_on_verify(viewer, path): + return # inviting grows the org: management, so admins only if not self.auth.is_admin(viewer["email"], viewer["org_slug"]): self._json({"error": "only an organization admin can invite"}, 403) @@ -2188,6 +2552,8 @@ def do_POST(self): if not viewer: self._json({"error": "unauthorized"}, 401) return + if self._gated_on_verify(viewer, path): + return org = viewer["org_slug"] body = self._read_json() action = path[len("/api/admin/"):] @@ -2219,8 +2585,8 @@ def do_POST(self): else: if body.get("slug") == viewer["org_slug"]: raise ValueError("you cannot delete your own organization") - db = self.auth.delete_empty_org(body.get("slug", "")) - self._json({"ok": True, "database_kept": db}) + self.auth.delete_empty_org(body.get("slug", "")) + self._json({"ok": True, "database_kept": True}) return if not self.auth.is_admin(viewer["email"], org): @@ -2280,6 +2646,8 @@ def do_POST(self): if not viewer: self._json({"error": "unauthorized"}, 401) return + if self._gated_on_verify(viewer, path): + return body = self._read_json() self.auth.switch_org(viewer["email"], body.get("slug", "")) self._json({"ok": True, "active": body.get("slug", "")}) @@ -2324,6 +2692,8 @@ def do_POST(self): if not viewer: self._redirect("/login") return + if self._gated_on_verify(viewer, path): + return form = self._read_form() # TYPED, never prefilled: approving binds a collector to this # account, and the person copying the code off that machine's @@ -2353,15 +2723,15 @@ def do_POST(self): self._json({"error": "not found"}, 404) return try: - # Two credentials are accepted. A per-user collector token (minted - # by the sign-in flow) also *identifies* the reporter, so it - # overrides whatever address the payload claims. The shared ingest - # token only admits: it cannot say who is reporting, so the claimed - # address stands. Prefer the former. + # A per-user collector token (minted by the sign-in flow) also + # *identifies* the reporter, so it overrides whatever address the + # payload claims. The shared ingest token only admits: it cannot + # say who is reporting, so the claimed address stands -- which is + # why it is a credential only when allow_shared is set. presented = self._bearer() # constant-time: the shared token admits ingest for the whole # company, and == leaks its prefix length to a timing probe - shared = bool(self.token) and hmac.compare_digest( + shared = self.allow_shared and bool(self.token) and hmac.compare_digest( presented.encode(), self.token.encode()) owner = None if shared or not presented \ else self.auth.collector_token_user(presented) @@ -2477,11 +2847,16 @@ def do_GET(self): err = parse_qs(parsed.query).get("err", [""])[0] self._page("register.html", {"ERROR": err[:200]}) return + if path == "/verify" or path.startswith("/verify/"): + self._handle_verify_page(parsed) + return if path == "/setup": viewer = self._viewer() if not viewer: self._redirect("/login?next=%2Fsetup") return + if self._gated_on_verify(viewer, path): + return if viewer.get("org_slug"): # already in one: nothing to ask for self._redirect("/") return @@ -2496,10 +2871,17 @@ def do_GET(self): inv = self.auth.get_invite(token) if inv: # single-use: the address is fixed by the inviter viewer = self._viewer() + # "join" needs no password (the session is the proof); + # "login" bounces through sign-in; "new" shows the password + # field. An unverified row is a claim, not an account, so + # it gets "new": the password typed here takes it over, and + # hiding the field would leave the invite unacceptable. mode = "new" if self.auth.get_user(inv["email"]): - mode = "join" if viewer and viewer["email"] == inv["email"] \ - else "login" + if viewer and viewer["email"] == inv["email"]: + mode = "join" + elif self.auth.is_verified(inv["email"]): + mode = "login" self._page("invite.html", {"TOKEN": token, "EMAIL": inv["email"], "FIXED": "1", "HINT": "", "MODE": mode, "ORG": inv["org_name"] or inv["org_slug"], @@ -2525,6 +2907,8 @@ def do_GET(self): if not viewer: self._redirect("/login?next=" + quote(self.path)) return + if self._gated_on_verify(viewer, path): + return if not viewer.get("org_slug"): # nothing to administer yet self._redirect("/setup?next=" + quote(self.path)) return @@ -2539,6 +2923,8 @@ def do_GET(self): # sign in first, then come back to this exact approval self._redirect("/login?next=" + quote(self.path)) return + if self._gated_on_verify(viewer, path): + return # a collector reports into its owner's active org, so there has # to be one before a device can be approved at all. Carry the # code through: it is in the URL the collector printed, and @@ -2588,6 +2974,10 @@ def do_GET(self): self._redirect("/login") return + # before anything else: an unproved address is not yet anyone + if self._gated_on_verify(viewer, path): + return + # registration creates the account alone, so a viewer can be signed # in with no org. Everything below reads or writes one org's data, # so ask for the org first rather than letting each handler meet an @@ -2651,8 +3041,7 @@ def do_GET(self): is_admin = email in roster["admins"] payload = { "viewer": {"email": email, "isAdmin": is_admin}, - "org": {"slug": org, "name": viewer["org_name"], - "database": viewer.get("database_id")}, + "org": {"slug": org, "name": viewer["org_name"]}, "members": roster["members"], } extra = {} @@ -2672,8 +3061,7 @@ def do_GET(self): for s in slugs] self._json({"active": viewer["org_slug"], "orgs": orgs}) elif path == "/api/status": - self._json({"ok": True, "org": viewer["org_slug"], - "orgDatabase": viewer.get("database_id")}) + self._json({"ok": True, "org": viewer["org_slug"]}) elif path.startswith("/api/session/"): sid = path.rsplit("/", 1)[1] if not viewer.get("database_id"): @@ -2748,7 +3136,7 @@ def log_message(self, fmt, *args): ADMIN_VERBS = ("adduser", "addorg", "resetpw", "deluser", "delorg", "listusers", "listorgs", "listinvites", "revokeinvite", "listtokens", "revoketoken", "makeadmin", "unadmin", - "makesysadmin", "unsysadmin") + "makesysadmin", "unsysadmin", "markverified") def drop_usage_rows(auth, rows): """Best-effort cleanup of the side rows that hang off a token: its usage @@ -2929,6 +3317,15 @@ def user_admin_cli(argv): drop_usage_rows(auth, [{"token": a.target}]) print(f"revoked collector token {a.target}") + elif verb == "markverified": + # support path: the mail never arrived and the person is who they say + # they are -- the operator is vouching for the mailbox + user = auth.get_user(a.target) + if not user: + sys.exit(f"no such user: {a.target}") + auth.mark_verified(user["email"]) + print(f"{user['email']} marked as verified") + elif verb == "revokeinvite": # match the raw rows, so an exhausted or expired link (which the live # getters correctly hide) can still be cleaned out of the table @@ -3000,7 +3397,20 @@ def seed_retry(): time.sleep(60) threading.Thread(target=seed_retry, daemon=True, name="seed-retry").start() Handler.stores = StorePool(Handler.pool, args.ttl) + resend_key = os.environ.get("RESEND_API_KEY", "") + sender = os.environ.get("HOTUSAGE_EMAIL_FROM", "") + if resend_key and sender: + Handler.mailer = ResendMailer(resend_key, sender) + Handler.auth.verification = True + else: + print("warn: RESEND_API_KEY / HOTUSAGE_EMAIL_FROM unset - email verification " + "is OFF: any address can register and no mailbox is ever proved", + file=sys.stderr) Handler.token = token + Handler.allow_shared = os.environ.get("HOTUSAGE_ALLOW_SHARED_INGEST") == "1" + if Handler.allow_shared: + print("warn: HOTUSAGE_ALLOW_SHARED_INGEST=1 - the shared token can report " + "usage as any registered user", file=sys.stderr) if not Handler.token: print("warn: HOTUSAGE_INGEST_TOKEN unset - accepting unauthenticated ingest (dev mode)", file=sys.stderr) diff --git a/server/static/admin.js b/server/static/admin.js index a72bdd0..f532669 100644 --- a/server/static/admin.js +++ b/server/static/admin.js @@ -294,7 +294,7 @@ async function load() { clearPlaceholders(); // anything that throws below must not leave them pulsing window.setViewer(state.viewer.email, state.org.name); $('#orgName').value = state.org.name; - $('#orgMeta').textContent = `slug ${state.org.slug} - database ${state.org.database || 'none'}`; + $('#orgMeta').textContent = `slug ${state.org.slug}`; const admin = state.viewer.isAdmin; $('#readonly').hidden = admin; $('#orgName').disabled = !admin; diff --git a/server/static/verify.html b/server/static/verify.html new file mode 100644 index 0000000..337d372 --- /dev/null +++ b/server/static/verify.html @@ -0,0 +1,39 @@ + + + + + + hototel - confirm your email + + + + + + + + + diff --git a/server/tests/test_auth_store.py b/server/tests/test_auth_store.py index 7d0b633..9a5cf7f 100644 --- a/server/tests/test_auth_store.py +++ b/server/tests/test_auth_store.py @@ -85,7 +85,9 @@ class StatefulSysDb: "device_codes": ("device_code",), "device_scopes": ("device_code",), "collector_tokens": ("token",), "token_scopes": ("token",), "collector_token_usage": ("token",), "auth_sessions": ("token",), - "system_admins": ("email",)} + "system_admins": ("email",), "invites": ("token",), + "team_invites": ("token",), + "email_verifications": ("token",), "verified_emails": ("email",)} def __init__(self, db="dbidsystem00000000000000"): self.db = db @@ -106,8 +108,8 @@ def with_org(r): r["database_id"] = org["database_id"] if org else None return r - if table == "users" and "LEFT JOIN" in flat: # get_user - out = [with_org(r) for r in out] + if table in ("users", "invites", "team_invites") and "LEFT JOIN" in flat: + out = [with_org(r) for r in out] # get_user / get_invite / get_team_invite # user_for_token: session -> user -> org, in one read if table == "auth_sessions" and "JOIN" in flat: by_email = {u["email"]: u for u in self.tables["users"]} @@ -480,6 +482,182 @@ def test_removing_a_member_revokes_their_read_token_too(): print(" bob removed, his read token is dead") +def test_an_org_admin_cannot_delete_a_platform_operator(): + """Removing someone whose only org is yours deletes their account, and + remove_user also strips the system-admin grant. An org admin must not be + able to do that to a platform operator; the CLI's deluser still can.""" + print("an org admin removing a platform operator:") + auth, db = scoped_store() + auth.create_account("ops@x.dev", "hunter2hunter2") + auth.join_org("ops@x.dev", "acme-inc") + auth.set_system_admin("ops@x.dev") + refuses(lambda: auth.remove_from_org("ops@x.dev", "acme-inc"), "platform operator") + assert auth.get_user("ops@x.dev"), "the account survives" + assert auth.is_system_admin("ops@x.dev"), "and so does the grant" + # a plain member on the same path is still removed outright + auth.create_account("bob@x.dev", "hunter2hunter2") + auth.join_org("bob@x.dev", "acme-inc") + auth.remove_from_org("bob@x.dev", "acme-inc") + assert auth.get_user("bob@x.dev") is None, "an ordinary member is removed" + print(" refused; an ordinary member on the same path is still removed") + + +def verifying_store(): + """A store with verification ON and one verified admin, ada, in acme-inc.""" + auth, db = signup_store() + auth.verification = True + auth.create_account("ada@x.dev", "hunter2hunter2") + auth.mark_verified("ada@x.dev") + auth.create_org_for("ada@x.dev", "Acme Inc") + return auth, db + + +def test_a_new_account_is_unverified_until_its_link_is_opened_signed_in(): + """The link proves the mailbox only together with the session: whoever + registered the address holds the password, and the link lands in the + mailbox regardless of who that was.""" + print("verification:") + auth, db = verifying_store() + auth.create_account("bob@x.dev", "hunter2hunter2") + assert not auth.is_verified("bob@x.dev"), "a fresh account is a claim" + token = auth.start_verification("bob@x.dev") + assert auth.consume_verification(token, as_user="ada@x.dev") is None, \ + "someone else's session must not verify it" + assert not auth.is_verified("bob@x.dev") + assert auth.consume_verification(token, as_user="bob@x.dev") == "bob@x.dev" + assert auth.is_verified("bob@x.dev"), "verified once opened as bob" + assert db.tables["email_verifications"] == [], "the link is spent" + assert auth.consume_verification(token, as_user="bob@x.dev") is None + print(" unverified -> wrong session refused -> right session verified -> spent") + + +def test_an_unverified_address_can_be_registered_over(): + """Squatting: an attacker registers ceo@corp first. With the row a claim + rather than an account, the owner registers over it, the attacker's + sessions die, and only the owner (holding the mailbox AND the new + password) can complete verification.""" + print("re-registering an unverified address:") + auth, db = verifying_store() + auth.create_account("ceo@corp.dev", "attackerpass") + squatter = auth.login("ceo@corp.dev", "attackerpass") + assert auth.user_for_token(squatter), "the squatter is signed in" + auth.create_account("ceo@corp.dev", "ownerpassword") + assert auth.user_for_token(squatter) is None, "the squatter's session is gone" + assert auth.login("ceo@corp.dev", "attackerpass") is None, "old password dead" + assert auth.login("ceo@corp.dev", "ownerpassword"), "the owner signs in" + auth.mark_verified("ceo@corp.dev") + refuses(lambda: auth.create_account("ceo@corp.dev", "attackagain"), "already registered") + print(" taken over, squatter signed out; verified, it is an account nobody can take") + + +def test_a_team_link_joiner_is_a_member_but_unverified(): + """The domain restriction on a team link is only worth anything because of + this: joining with a made-up @corp address gives a membership that grants + nothing until the mailbox confirms it.""" + print("a team-link joiner:") + auth, db = verifying_store() + link = auth.create_team_invite("acme-inc", "ada@x.dev", domain="x.dev") + auth.accept_team_invite(link, "mallory@x.dev", "hunter2hunter2") + assert "acme-inc" in auth.memberships("mallory@x.dev"), "a member on paper" + assert not auth.is_verified("mallory@x.dev"), "but not verified" + # and the real mallory can take the claim over through the same link + auth.accept_team_invite(link, "mallory@x.dev", "therealone1") + assert auth.login("mallory@x.dev", "therealone1") + assert auth.login("mallory@x.dev", "hunter2hunter2") is None + print(" member, unverified; the address can still be claimed by its owner") + + +def test_accounts_older_than_the_requirement_are_trusted(): + print("grandfathering:") + auth, db = verifying_store() + auth.create_account("old@x.dev", "hunter2hunter2") + for u in db.tables["users"]: + if u["email"] == "old@x.dev": + u["created_at"] = "2026-01-01T00:00:00+00:00" + assert auth.is_verified("old@x.dev"), "an account from before the cutoff needs no link" + auth.verification = False + auth.create_account("any@x.dev", "hunter2hunter2") + assert auth.is_verified("any@x.dev"), "with verification off everyone is trusted" + print(" pre-cutoff account trusted; verification off trusts everyone") + + +def test_removing_a_user_forgets_their_verification(): + print("a removed user's verification does not outlive them:") + auth, db = verifying_store() + auth.create_account("bob@x.dev", "hunter2hunter2") + auth.mark_verified("bob@x.dev") + assert auth.is_verified("bob@x.dev") + auth.remove_user("bob@x.dev") + assert [r["email"] for r in db.tables["verified_emails"]] == ["ada@x.dev"], \ + db.tables["verified_emails"] + auth.create_account("bob@x.dev", "hunter2hunter2") + assert not auth.is_verified("bob@x.dev"), "whoever registers the address next starts over" + print(" row and cache gone; a re-registration is unverified") + + +def test_a_takeover_inherits_nothing_the_squatter_acquired(): + """mallory pre-registers victim@corp through her own team link, so the + row is an unverified member of mallory's org. When the victim registers, + the takeover must drop that membership (and any admin grant, and the + active org): otherwise the victim lands in mallory's org and every + collector report goes where mallory can read it.""" + print("a takeover drops the squatter's memberships:") + auth, db = verifying_store() + auth.create_account("mallory@x.dev", "hunter2hunter2") + auth.mark_verified("mallory@x.dev") + auth.create_org_for("mallory@x.dev", "Evil Corp") + link = auth.create_team_invite("evil-corp", "mallory@x.dev") + auth.accept_team_invite(link, "victim@corp.dev", "squatpass") + auth.set_admin("victim@corp.dev", "evil-corp") + assert "evil-corp" in auth.memberships("victim@corp.dev") + auth.create_account("victim@corp.dev", "ownerpassword") + assert auth.memberships("victim@corp.dev") == set(), "membership dropped" + assert not auth.is_admin("victim@corp.dev", "evil-corp"), "admin grant dropped" + assert auth.get_user("victim@corp.dev")["org_slug"] == "", "no active org" + assert auth.login("victim@corp.dev", "ownerpassword") + # an invite-driven takeover still joins the inviting org afterwards + tok = auth.create_invite("bob@corp.dev", "acme-inc", "ada@x.dev") + auth.accept_team_invite(link, "bob@corp.dev", "squatpass") + auth.accept_invite(tok, password="bobsrealpass") + assert auth.memberships("bob@corp.dev") == {"acme-inc"}, auth.memberships("bob@corp.dev") + print(" victim is in no org; an invite takeover joins only the inviting org") + + +def test_a_signed_in_unverified_account_joins_through_a_single_use_invite(): + """bob registers (unverified), then an admin invites him. Signed in as + bob, accepting is a plain join -- no password, no takeover that would + sign him out. Signed in as someone else it is still a takeover.""" + print("single-use invite for an unverified, signed-in account:") + auth, db = verifying_store() + auth.create_account("bob@x.dev", "hunter2hunter2") + session = auth.login("bob@x.dev", "hunter2hunter2") + tok = auth.create_invite("bob@x.dev", "acme-inc", "ada@x.dev") + auth.accept_invite(tok, as_user="bob@x.dev") + assert "acme-inc" in auth.memberships("bob@x.dev") + assert auth.user_for_token(session), "joining did not sign bob out" + assert auth.login("bob@x.dev", "hunter2hunter2"), "password unchanged" + print(" joined without a password, session intact") + + +def test_a_verified_answer_expires_from_the_cache(): + """Another instance deletes the user; this one must stop answering True + once its entry ages out, or the next registrant of the address inherits + verification without holding the mailbox.""" + print("positive verification cache expires:") + auth, db = verifying_store() + auth.create_account("bob@x.dev", "hunter2hunter2") + auth.mark_verified("bob@x.dev") + assert auth.is_verified("bob@x.dev") + # the delete happens elsewhere: the row goes, this instance's cache stays + db.tables["verified_emails"] = [r for r in db.tables["verified_emails"] + if r["email"] != "bob@x.dev"] + assert auth.is_verified("bob@x.dev"), "still cached for now" + ok, at = auth.verified_cache["bob@x.dev"] + auth.verified_cache["bob@x.dev"] = (ok, at - auth.VERIFIED_TTL - 1) + assert not auth.is_verified("bob@x.dev"), "re-read after the TTL" + print(f" True served from cache, then re-read after {auth.VERIFIED_TTL}s") + + def test_the_admin_listing_hands_out_no_collector_token(): """The admin page is JSON in a browser tab. A collector token in it is a live credential for reporting (and, with `read`, for the whole org's usage) diff --git a/server/tests/test_client_pool.py b/server/tests/test_client_pool.py index 86c61ca..32ab8f8 100644 --- a/server/tests/test_client_pool.py +++ b/server/tests/test_client_pool.py @@ -48,6 +48,41 @@ def __init__(self, configuration): import server # noqa: E402 +def test_only_a_400_reads_as_an_empty_table(): + """hotdata says "not found" both for a table with no rows (HTTP 400) and + for a database that is not there (HTTP 404). Only the first is an empty + result; the second must propagate, or every "is this org empty?" check + fails open on a backend hiccup.""" + print("rows() and the two kinds of not-found:") + client = server.HotdataClient("dbid-test") + + def raising(status, text): + class Err(Exception): + pass + e = Err(f"({status}) Reason: {text}") + e.status = status + def sql(query, timeout=None): + raise e + return sql + + client.sql = raising(400, "Bad Request | table not found") + assert client.rows("SELECT 1") == [], "a 400 not-found is an empty table" + print(" 400 'not found' -> []") + client.sql = raising(400, "relation has no data") + assert client.rows("SELECT 1") == [] + print(" 400 'has no data' -> []") + for status, text in ((404, "Not Found"), (500, "not found upstream"), + (None, "not found")): + client.sql = raising(status, text) + try: + client.rows("SELECT 1") + except Exception as e: + assert getattr(e, "status", None) == status + print(f" {status} {text!r} -> raised") + else: + raise AssertionError(f"{status} {text!r} must propagate") + + def test_concurrent_first_use_builds_one_client(): """gather() is the first thing to reach a cold client, on several threads at once. Unlocked, each sees None and builds its own ApiClient; all but the diff --git a/server/tests/test_http_read_token.py b/server/tests/test_http_read_token.py index 736cb02..f4347e6 100644 --- a/server/tests/test_http_read_token.py +++ b/server/tests/test_http_read_token.py @@ -154,6 +154,69 @@ def test_the_read_token_cannot_ingest(): print(f" {status} POST /ingest with an ingest token") +def test_the_shared_token_is_not_a_credential_by_default(): + """The shared HOTUSAGE_INGEST_TOKEN only admits: anyone holding it could + report as any registered address in any organization. So by default it is + the switch that requires a token, not a token, and only + HOTUSAGE_ALLOW_SHARED_INGEST=1 (Handler.allow_shared) changes that.""" + print("the shared ingest token:") + body = {"user_email": "ada@x.dev", "hostname": "laptop", "sessions": []} + status, raw = call(BASE, "/ingest", server.Handler.token, method="POST", body=body) + assert status == 401, (status, raw[:200]) + print(" 401 by default") + server.Handler.allow_shared = True + try: + status, raw = call(BASE, "/ingest", server.Handler.token, method="POST", + body=body) + assert status == 200, (status, raw[:200]) + print(" 200 once explicitly allowed") + finally: + server.Handler.allow_shared = False + + +def test_no_response_names_a_database_id(): + """Database ids are internal handles. They used to ride along in + /api/status, /api/data's source and the admin state; nothing a browser or + the skill does needs them.""" + print("no read route names the org database:") + for path in ("/api/status", "/api/data"): + status, raw = call(BASE, path, READ) + assert status == 200, (path, status) + text = raw.decode() + assert "dbid" not in text and "orgDatabase" not in text, (path, text[:200]) + print(f" {path}: clean") + + +def test_an_unverified_account_reaches_nothing(): + """One gate for every viewer: a read token minted for an account that has + not confirmed its address is refused on every read route, and so is its + ingest. ada is grandfathered here by marking her verified, since the + fixture created her just now.""" + print("an unverified account:") + auth = server.Handler.auth + auth.verification = True + try: + auth.mark_verified("ada@x.dev") + auth.create_account("newbie@x.dev", "hunter2hunter2") + auth.join_org("newbie@x.dev", "acme-inc") + token = fakes.approved_token(auth, "newbie@x.dev", scope="ingest,read") + for path in ("/api/data", "/api/status", "/api/orgs", "/api/session/sess-1"): + status, raw = call(BASE, path, token) + assert status == 403 and b"confirm your email" in raw, (path, status, raw[:200]) + print(f" 403 {path}") + body = {"user_email": "newbie@x.dev", "hostname": "laptop", "sessions": []} + status, raw = call(BASE, "/ingest", token, method="POST", body=body) + assert status == 403 and b"not confirmed" in raw, (status, raw[:200]) + print(" 403 POST /ingest") + # ada, verified, is unaffected on the same routes + assert call(BASE, "/api/status", READ)[0] == 200 + auth.mark_verified("newbie@x.dev") + assert call(BASE, "/api/status", token)[0] == 200, "verified, the same token works" + print(" 200 once verified") + finally: + auth.verification = False + + def test_an_unauthenticated_api_call_is_401(): for path in ("/api/data", "/api/status", "/api/orgs"): status, _ = call(BASE, path) diff --git a/server/tests/test_install_gate.py b/server/tests/test_install_gate.py index b90c331..9718c69 100644 --- a/server/tests/test_install_gate.py +++ b/server/tests/test_install_gate.py @@ -217,6 +217,74 @@ def test_the_api_is_not_gated(): print(f" /api/status {resp.status}, answered normally") +def test_an_unverified_invitee_accepts_their_own_invite_without_a_password(): + """bob registers and has not opened his mail yet when an admin sends him a + single-use invite. Signed in as bob, the page is in join mode (no + password field), and the POST must be a plain join: the session already + proved the password. Before this, the handler demanded a password the + page never showed, so the invite was unacceptable. A stranger holding + the link still has to set a password (and take the claim over).""" + print("unverified invitee, signed in, accepts without a password:") + ada = signed_up("ada-inv@x.dev", "Invite Co") + org = AUTH.user_for_token(ada.cookies["hotusage_session"])["org_slug"] + AUTH.verification = True + try: + server.Handler._rate.clear() + bob = Browser() + bob.go("/register", {"email": "bob-inv@x.dev", "password": "hunter2hunter2"}) + assert bob.cookies.get("hotusage_session"), "bob has no session" + assert not AUTH.is_verified("bob-inv@x.dev") + token = AUTH.create_invite("bob-inv@x.dev", org, "ada-inv@x.dev") + status, _, page = bob.go(f"/invite/{token}") + assert status == 200 and b'data-mode="join"' in page, (status, page[:300]) + status, loc, _ = bob.go(f"/invite/{token}", {}) + assert status == 302 and loc == "/", (status, loc) + assert org in AUTH.memberships("bob-inv@x.dev"), AUTH.memberships("bob-inv@x.dev") + assert AUTH.user_for_token(bob.cookies["hotusage_session"]), "bob was signed out" + print(" join mode, 302 to /, member, still signed in") + + AUTH.create_account("carol-inv@x.dev", "hunter2hunter2") # unverified claim + token = AUTH.create_invite("carol-inv@x.dev", org, "ada-inv@x.dev") + stranger = Browser() + status, _, page = stranger.go(f"/invite/{token}") + assert b'data-mode="new"' in page, page[:300] + status, loc, _ = stranger.go(f"/invite/{token}", {}) + assert status == 302 and "err=password" in (loc or ""), (status, loc) + print(" a stranger sees the password field and is refused without one") + finally: + AUTH.verification = False + + +def test_an_unverified_member_joins_through_a_team_link_without_a_password(): + """Same person, reusable team link: signed in and unverified, bob types + his own address and leaves the password blank as the page tells him to. + That must be a plain join that keeps his session, not a takeover of his + own account (which would set whatever he typed as the password, since + the handler no longer length-checks it, and sign him out).""" + print("unverified member, signed in, joins through a team link:") + ada = signed_up("ada-team@x.dev", "Team Co") + org = AUTH.user_for_token(ada.cookies["hotusage_session"])["org_slug"] + AUTH.verification = True + try: + server.Handler._rate.clear() + bob = Browser() + bob.go("/register", {"email": "bob-team@x.dev", "password": "hunter2hunter2"}) + session = bob.cookies.get("hotusage_session") + assert session and not AUTH.is_verified("bob-team@x.dev") + link = AUTH.create_team_invite(org, "ada-team@x.dev") + status, loc, _ = bob.go(f"/invite/{link}", {"email": "bob-team@x.dev", "password": ""}) + assert status == 302 and loc == "/", (status, loc) + assert org in AUTH.memberships("bob-team@x.dev"), AUTH.memberships("bob-team@x.dev") + assert AUTH.user_for_token(session), "bob was signed out" + assert AUTH.login("bob-team@x.dev", "hunter2hunter2"), "password changed" + # and a short password typed anyway is not installed as his new one + status, loc, _ = bob.go(f"/invite/{link}", {"email": "bob-team@x.dev", "password": "a"}) + assert AUTH.login("bob-team@x.dev", "a") is None, "a 1-char password took over" + print(" 302 to /, member, session and password intact") + finally: + AUTH.verification = False + + if __name__ == "__main__": tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] for t in tests: