Spaces:
Running
Running
| """Accounts for Foresight: sign up, sign in, sessions. | |
| Anyone can create an account with a username and password. Credentials live in | |
| the dataset repo, one file per user: | |
| auth/users/{username}.json -> { username, student_id, salt, hash, … } | |
| **Passwords are hashed, not encrypted.** Encryption is reversible, so a copy of | |
| the dataset would hand over working credentials — and people reuse passwords, so | |
| the damage wouldn't stop at this app. `hashlib.scrypt` is standard library, so | |
| this costs no extra dependency. | |
| `student_id` is random rather than the username, so a username never appears in a | |
| storage path. `current_student(request)` is the seam a future SSO swap replaces. | |
| **Nothing here can remove a file.** `storage` is an upload-only wrapper over the | |
| dataset repo, so renaming an account and deleting one both work by *overwriting* the | |
| old record with a disabled tombstone. Two consequences worth knowing before changing | |
| this: | |
| * `sign_in` must check `disabled` explicitly. A tombstone keeps no salt or hash, so | |
| verification would fail anyway, but relying on that is one refactor away from a | |
| live credential for an account its owner deleted. | |
| * A username is never recycled. The tombstone still occupies the path, so `sign_up` | |
| keeps reporting it as taken — which is also the behaviour you want: handing a | |
| freed username to someone else makes every stale reference point at a stranger. | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import os | |
| import re | |
| import secrets | |
| from datetime import datetime, timezone | |
| from . import storage | |
| SESSION_KEY = "student_id" | |
| # Usernames become filenames in the dataset repo, so keep the character set tight | |
| # — this is also what stops a name like "../x" from escaping the folder. | |
| USERNAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{2,31}$") | |
| MIN_PASSWORD = 8 | |
| MAX_PASSWORD = 256 # cap what gets fed to scrypt | |
| MAX_NAME = 60 | |
| SCRYPT = {"n": 2 ** 14, "r": 8, "p": 1} # ~16 MB per hash | |
| DKLEN = 64 | |
| class AuthError(Exception): | |
| """Message is safe to show the user verbatim.""" | |
| def _key(raw: str) -> str: | |
| return (raw or "").strip().casefold() | |
| def user_path(username: str) -> str: | |
| return f"auth/users/{username}.json" | |
| def _hash(password: str, salt: bytes, cost: dict | None = None) -> str: | |
| """Verification uses the cost stored on the account, so SCRYPT can be raised | |
| later without invalidating existing passwords.""" | |
| c = cost or SCRYPT | |
| return hashlib.scrypt( | |
| password.encode("utf-8"), salt=salt, dklen=DKLEN, | |
| n=int(c["n"]), r=int(c["r"]), p=int(c["p"]), | |
| ).hex() | |
| def load_user(username: str) -> dict | None: | |
| return storage.read_json(user_path(username)) | |
| def initials(first: str, last: str, username: str = "") -> str: | |
| """Two letters for the avatar — "Umang Chaudhry" -> "UC". | |
| Falls back to the username for accounts created before names were collected, | |
| so an older account shows something sensible rather than a blank circle.""" | |
| letters = [part.strip()[0] for part in (first, last) if part and part.strip()] | |
| if letters: | |
| return "".join(letters).upper()[:2] | |
| return (username or "?").strip()[:1].upper() | |
| def sign_up(raw_username: str, password: str, first_name: str = "", last_name: str = "") -> dict: | |
| """Create an account. Raises AuthError with a user-facing message.""" | |
| username = _key(raw_username) | |
| first_name, last_name = (first_name or "").strip(), (last_name or "").strip() | |
| if not USERNAME_RE.match(username): | |
| raise AuthError("Usernames need 3–32 characters: letters, numbers, dots, " | |
| "dashes or underscores, starting with a letter or number.") | |
| if not first_name or not last_name: | |
| raise AuthError("Please enter your first and last name.") | |
| if len(first_name) > MAX_NAME or len(last_name) > MAX_NAME: | |
| raise AuthError(f"Names can be at most {MAX_NAME} characters.") | |
| if not password or len(password) < MIN_PASSWORD: | |
| raise AuthError(f"Passwords need at least {MIN_PASSWORD} characters.") | |
| if len(password) > MAX_PASSWORD: | |
| raise AuthError(f"Passwords can be at most {MAX_PASSWORD} characters.") | |
| if load_user(username) is not None: | |
| raise AuthError("That username is taken. Try another.") | |
| salt = secrets.token_bytes(16) | |
| record = { | |
| "username": username, | |
| "student_id": secrets.token_hex(8), | |
| "first_name": first_name, | |
| "last_name": last_name, | |
| "created_at": datetime.now(timezone.utc).isoformat(), | |
| "salt": salt.hex(), | |
| "hash": _hash(password, salt), | |
| **SCRYPT, | |
| } | |
| storage.write_json(user_path(username), record, message=f"auth: create {username}") | |
| return record | |
| def sign_in(raw_username: str, password: str) -> dict: | |
| """Return the user record for valid credentials, else raise AuthError. | |
| One message covers an unknown username, a wrong password and a tombstoned | |
| account, so this can't be used to find out who has an account — or who used | |
| to.""" | |
| username = _key(raw_username) | |
| record = load_user(username) if USERNAME_RE.match(username) else None | |
| if record is None or record.get("disabled"): | |
| raise AuthError("That username and password don't match.") | |
| try: | |
| salt = bytes.fromhex(record.get("salt", "")) | |
| expected = _hash(password or "", salt, record) | |
| except (ValueError, KeyError, TypeError): # malformed record, not a 500 | |
| raise AuthError("That username and password don't match.") | |
| if not secrets.compare_digest(expected, record.get("hash", "")): | |
| raise AuthError("That username and password don't match.") | |
| return record | |
| # --- changing an account ---------------------------------------------------- | |
| # Every one of these takes the *current* record rather than a username, so a caller | |
| # has to have loaded it — which in practice means it came from the session. | |
| def _check_password(record: dict, password: str) -> None: | |
| """Re-authenticate before a change that could lock the owner out.""" | |
| try: | |
| salt = bytes.fromhex(record.get("salt", "")) | |
| expected = _hash(password or "", salt, record) | |
| except (ValueError, KeyError, TypeError): | |
| raise AuthError("That password isn't right.") | |
| if not secrets.compare_digest(expected, record.get("hash", "")): | |
| raise AuthError("That password isn't right.") | |
| def _now() -> str: | |
| return datetime.now(timezone.utc).isoformat() | |
| def update_names(record: dict, first_name: str, last_name: str) -> dict: | |
| """Change the display name. No password: it isn't a credential.""" | |
| first_name, last_name = (first_name or "").strip(), (last_name or "").strip() | |
| if not first_name or not last_name: | |
| raise AuthError("Please enter your first and last name.") | |
| if len(first_name) > MAX_NAME or len(last_name) > MAX_NAME: | |
| raise AuthError(f"Names can be at most {MAX_NAME} characters.") | |
| updated = {**record, "first_name": first_name, "last_name": last_name, | |
| "updated_at": _now()} | |
| storage.write_json(user_path(record["username"]), updated, | |
| message=f"auth: names {record['username']}") | |
| return updated | |
| def change_password(record: dict, current_password: str, new_password: str) -> dict: | |
| _check_password(record, current_password) | |
| if not new_password or len(new_password) < MIN_PASSWORD: | |
| raise AuthError(f"Passwords need at least {MIN_PASSWORD} characters.") | |
| if len(new_password) > MAX_PASSWORD: | |
| raise AuthError(f"Passwords can be at most {MAX_PASSWORD} characters.") | |
| salt = secrets.token_bytes(16) | |
| updated = {**record, "salt": salt.hex(), "hash": _hash(new_password, salt), | |
| **SCRYPT, "updated_at": _now()} | |
| storage.write_json(user_path(record["username"]), updated, | |
| message=f"auth: password {record['username']}") | |
| return updated | |
| def change_username(record: dict, raw_username: str, password: str) -> dict: | |
| """Move an account to a new username, leaving a tombstone at the old one. | |
| Password-gated because the username is half the credential — someone on a | |
| borrowed session could otherwise lock the owner out of their own account. | |
| The account keeps its `student_id`, so every path under `students/{id}/` — the | |
| profile, the syllabi, the chat transcripts — follows it without being touched. | |
| That is the whole reason the id was never the username. | |
| """ | |
| username = _key(raw_username) | |
| if username == record["username"]: | |
| return record | |
| if not USERNAME_RE.match(username): | |
| raise AuthError("Usernames need 3–32 characters: letters, numbers, dots, " | |
| "dashes or underscores, starting with a letter or number.") | |
| _check_password(record, password) | |
| if load_user(username) is not None: | |
| raise AuthError("That username is taken. Try another.") | |
| old = record["username"] | |
| moved = {**record, "username": username, "renamed_from": old, "updated_at": _now()} | |
| # New record first: if the second write fails the account still works, just under | |
| # both names. The reverse order would strand it under neither. | |
| storage.write_json(user_path(username), moved, message=f"auth: move {old} -> {username}") | |
| storage.write_json( | |
| user_path(old), | |
| {"username": old, "student_id": record.get("student_id"), "disabled": True, | |
| "renamed_to": username, "renamed_at": _now()}, | |
| message=f"auth: tombstone {old}", | |
| ) | |
| return moved | |
| def delete_account(record: dict, password: str) -> dict: | |
| """Disable the account. Returns the tombstone. | |
| Password-gated, and irreversible from inside the app. The caller is responsible | |
| for the student's *data* — this only closes the door (see the module docstring on | |
| why closing it is an overwrite rather than a delete). | |
| """ | |
| _check_password(record, password) | |
| tomb = {"username": record["username"], "student_id": record.get("student_id"), | |
| "disabled": True, "deleted_at": _now()} | |
| storage.write_json(user_path(record["username"]), tomb, | |
| message=f"auth: delete {record['username']}") | |
| return tomb | |
| def current_student(request) -> str | None: | |
| return request.session.get(SESSION_KEY) | |
| def start_session(request, record: dict) -> None: | |
| request.session[SESSION_KEY] = record["student_id"] | |
| request.session["username"] = record.get("username") | |
| request.session["first_name"] = record.get("first_name") or "" | |
| request.session["last_name"] = record.get("last_name") or "" | |
| def session_secret() -> str: | |
| """Cookie-signing key. Random per process when unset, so local dev needs no | |
| setup — but then restarts sign everyone out. Set it in deployment.""" | |
| return os.environ.get("FORESIGHT_SESSION_SECRET") or secrets.token_hex(32) | |