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)