foresight / server /auth.py
umangchaudhry's picture
Deploy 31bce77 from GitHub
f4fa8d3 verified
Raw
History Blame Contribute Delete
10.8 kB
"""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)