"""Deliberately light auth: no password, no email verification. An annotator types their email once; we sign it into a cookie (so it can't be trivially forged/edited by hand) and treat whoever holds that cookie as that annotator from then on. This is enough to (a) attribute labels for inter-annotator agreement and (b) stop one browser session from re-labeling an item it's already done -- it is NOT identity verification, and the project's docs should say so plainly (anyone can type anyone's email). Upgrading later to a verified magic-link flow only requires replacing `create_session_cookie`/`read_session_cookie` with a real token-issuance step; nothing else in the app depends on how the email was obtained. """ from __future__ import annotations import hashlib import hmac import os import secrets from pathlib import Path from fastapi import Request, Response COOKIE_NAME = "evasion_annotator_session" CSRF_COOKIE_NAME = "evasion_annotator_csrf" # Real browsers treat localhost/127.0.0.1 as a "potentially trustworthy" # origin, so Secure cookies work fine there even over plain http:// -- but # curl and some other HTTP clients/proxies enforce Secure strictly by URL # scheme and will silently refuse to send the cookie back, which breaks # scripted testing against a local plain-http server. Default to secure # (correct for the real HF Space / any real HTTPS deployment); only flip # this for local scripted testing, e.g. `ANNOTATOR_COOKIE_SECURE=false`. COOKIE_SECURE = os.environ.get("ANNOTATOR_COOKIE_SECURE", "true").strip().lower() != "false" # Deliberately NOT a hardcoded default string: this file is meant to be read # on public GitHub, and a well-known default HMAC secret would let anyone # forge a session cookie for ANY email, not just skip verifying their own -- # a materially worse hole than the "light auth" design intends. If # ANNOTATOR_SECRET isn't set (the expected path for a real deployment -- # e.g. a Hugging Face Space secret), generate a random one on first run and # persist it next to the DB, so local/dev usage stays zero-config without # ever shipping a guessable secret. _SECRET_FILE = Path( os.environ.get("ANNOTATOR_SECRET_FILE", os.path.join(os.path.dirname(__file__), "..", "data", ".session_secret")) ) def _load_or_create_secret() -> str: env_secret = os.environ.get("ANNOTATOR_SECRET") if env_secret: return env_secret if _SECRET_FILE.exists(): return _SECRET_FILE.read_text(encoding="utf-8").strip() _SECRET_FILE.parent.mkdir(parents=True, exist_ok=True) value = secrets.token_hex(32) # os.open with O_CREAT|O_EXCL|0o600 rather than Path.write_text: the file # is created owner-read/write-only from the instant it exists (no window # where a default umask -- e.g. 0644 under the common 022 -- makes it # world-readable), and O_EXCL makes two processes racing to initialize # the secret fail closed (one raises FileExistsError) rather than one # silently overwriting the other's already-in-use signing key. try: fd = os.open(str(_SECRET_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) with os.fdopen(fd, "w") as f: f.write(value) return value except FileExistsError: return _SECRET_FILE.read_text(encoding="utf-8").strip() SECRET = _load_or_create_secret() def _sign(email: str) -> str: return hmac.new(SECRET.encode(), email.encode(), hashlib.sha256).hexdigest() def create_session_cookie(email: str) -> str: email = email.strip().lower() return f"{email}|{_sign(email)}" def read_session_cookie(value: str | None) -> str | None: if not value or "|" not in value: return None email, sig = value.rsplit("|", 1) if hmac.compare_digest(sig, _sign(email)): return email return None def current_email(request: Request) -> str | None: return read_session_cookie(request.cookies.get(COOKIE_NAME)) # --- CSRF (synchronizer token pattern) ------------------------------------- # # /login is unauthenticated (that's the whole point -- there's no pre-existing # session to check), so the session cookie's SameSite=Lax setting can't # protect it: a cross-site auto-submitting