Spaces:
Sleeping
Sleeping
| """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 <form> to /login carries no cookie | |
| # and needs none, since login() only reads the posted email. That's login | |
| # CSRF -- a forged page can silently hand a victim's browser a session for an | |
| # attacker-chosen identity. /submit has the same shape of gap for POSTs. | |
| # | |
| # Fix: every GET that renders a form gets (or reuses) a random per-browser | |
| # CSRF cookie, and the SAME value is rendered into that form as a hidden | |
| # field. On POST, the cookie value and the posted field must match. An | |
| # attacker's forged cross-site form can copy the *shape* of our form but | |
| # can't read the victim's httponly CSRF cookie to put the right value in it | |
| # (browser same-origin policy blocks that, and SameSite=Lax additionally | |
| # stops the cookie itself from ever reaching a forged cross-site POST) -- so | |
| # the two values won't match and the request is rejected. | |
| def get_csrf_token(request: Request) -> str: | |
| """The CSRF token for this browser: whatever it already has, or a fresh | |
| one if not. Pure/read-only -- call this before rendering a template (so | |
| the value can go into the form's hidden field), then pass the result to | |
| `set_csrf_cookie` once you have the Response object to set it on.""" | |
| return request.cookies.get(CSRF_COOKIE_NAME) or secrets.token_hex(32) | |
| def set_csrf_cookie(response: Response, token: str) -> None: | |
| response.set_cookie( | |
| CSRF_COOKIE_NAME, token, max_age=60 * 60 * 24 * 365, httponly=True, samesite="lax", secure=COOKIE_SECURE | |
| ) | |
| def verify_csrf(request: Request, posted_token: str | None) -> bool: | |
| cookie_token = request.cookies.get(CSRF_COOKIE_NAME) | |
| if not cookie_token or not posted_token: | |
| return False | |
| return hmac.compare_digest(cookie_token, posted_token) | |