Spaces:
Running
Running
File size: 10,816 Bytes
6b7e4ba f4fa8d3 6b7e4ba 25928a4 6b7e4ba 25928a4 6b7e4ba 25928a4 6b7e4ba 25928a4 6b7e4ba 25928a4 6b7e4ba f4fa8d3 6b7e4ba f4fa8d3 6b7e4ba f4fa8d3 6b7e4ba 25928a4 6b7e4ba | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 | """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)
|