"""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)