gmarti's picture
Upload folder using huggingface_hub
b6ada1c verified
Raw
History Blame Contribute Delete
5.92 kB
"""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)