feat: auth by default, theme toggle, CoderPad startup, review fixes

- Require a session on every route; public routes opt out with @allow_anonymous
- Split password hashing and pepper loading into passwords.py
- Add a system/light/dark theme toggle backed by light-dark() colors
- Ignore stale 401s from an earlier session, PATCH only changed book fields,
  and block overlapping journal-entry saves
- Add bin/start and CoderPad Vite server settings for the pad's start/restart
- Rewrite README as a mise onboarding guide; expand .gitignore
- Include review-round fixes and tests
This commit is contained in:
2026-10-02 16:49:14 -05:00
parent 9d7b0e805c
commit 7f5d034a1f
46 changed files with 1797 additions and 497 deletions
+81 -120
View File
@@ -1,135 +1,90 @@
"""Auth slice: password hashing (ADR-0001), server-side sessions (ADR-0002), and auth routes."""
"""Auth slice: server-side sessions (ADR-0002) and auth routes."""
import base64
import functools
import hashlib
import hmac
import logging
import os
import re
import secrets
from collections.abc import Callable
from pathlib import Path
from typing import ParamSpec, TypedDict, TypeVar, cast
import psycopg2.errors
from flask import Blueprint, Flask, current_app, g, jsonify, make_response, request
from flask import (
Blueprint,
Flask,
Response,
current_app,
g,
jsonify,
make_response,
request,
)
from db import query, query_one
from validation import ApiError, json_body, require_utf8
from db import query, query_one, query_row
from passwords import (
dummy_hash,
hash_password,
load_pepper,
needs_rehash,
verify_password,
)
from validation import ApiError, JsonObject, json_body, require_utf8
log = logging.getLogger(__name__)
bp = Blueprint("auth", __name__, url_prefix="/api/auth")
ITERATIONS = 600_000
SALT_BYTES = 16
MIN_PEPPER_CHARS = 32
SESSION_COOKIE = "sid"
USERNAME_PATTERN = re.compile(r"[A-Za-z0-9_.-]{3,64}")
MIN_PASSWORD, MAX_PASSWORD = 12, 1024
INVALID_LOGIN = "Invalid username or password"
P = ParamSpec("P")
R = TypeVar("R")
class UserRow(TypedDict):
id: int
username: str
class UserWithHash(UserRow):
password_hash: str
def init_app(app: Flask) -> None:
app.extensions["password_pepper"] = load_pepper(Path(app.root_path) / ".pepper")
_dummy_hash()
dummy_hash()
app.before_request(_require_session)
app.register_blueprint(bp)
# --- Passwords -----------------------------------------------------------------
def hash_password(
password: str,
pepper: bytes,
*,
salt: bytes | None = None,
iterations: int = ITERATIONS,
) -> str:
salt = secrets.token_bytes(SALT_BYTES) if salt is None else salt
derived = _derive(password, pepper, salt, iterations)
return f"pbkdf2_sha256${iterations}${_b64(salt)}${_b64(derived)}"
def verify_password(password: str, stored: str, pepper: bytes) -> bool:
_, iterations, salt, expected = stored.split("$")
derived = _derive(password, pepper, base64.b64decode(salt), int(iterations))
return hmac.compare_digest(derived, base64.b64decode(expected))
def needs_rehash(stored: str) -> bool:
return int(stored.split("$")[1]) < ITERATIONS
def _derive(password: str, pepper: bytes, salt: bytes, iterations: int) -> bytes:
peppered = hmac.new(pepper, password.encode("utf-8"), hashlib.sha256).digest()
return hashlib.pbkdf2_hmac("sha256", peppered, salt, iterations)
def _b64(raw: bytes) -> str:
return base64.b64encode(raw).decode()
@functools.cache
def _dummy_hash() -> str:
# Verified against for unknown usernames so their response time matches a real account.
return hash_password(secrets.token_urlsafe(16), b"\0" * MIN_PEPPER_CHARS)
def load_pepper(pepper_file: Path) -> bytes:
from_env = os.environ.get("PASSWORD_PEPPER")
if from_env is not None:
if len(from_env) < MIN_PEPPER_CHARS:
raise RuntimeError(
f"PASSWORD_PEPPER must be at least {MIN_PEPPER_CHARS} characters. "
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
)
return from_env.encode()
try:
fd = os.open(pepper_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
pass
else:
with os.fdopen(fd, "w") as f:
f.write(secrets.token_hex(32))
log.warning(
"PASSWORD_PEPPER is not set; using %s. Development only: production must supply the pepper "
"from a secrets manager.",
pepper_file,
)
pepper = pepper_file.read_text().strip()
if len(pepper) < MIN_PEPPER_CHARS:
raise RuntimeError(
f"{pepper_file} holds fewer than {MIN_PEPPER_CHARS} characters. Set PASSWORD_PEPPER, or delete "
"the file to regenerate it (existing passwords will stop verifying)."
)
return pepper.encode()
# --- Sessions ------------------------------------------------------------------
def login_required(view):
@functools.wraps(view)
def wrapper(*args, **kwargs):
token = request.cookies.get(SESSION_COOKIE)
row = None
if token:
row = query_one(
"""
UPDATE sessions
SET expires_at = LEAST(now() + interval '30 minutes', created_at + interval '12 hours')
WHERE token_hash = %s
AND expires_at > now()
AND created_at > now() - interval '12 hours'
RETURNING user_id
""",
(_token_hash(token),),
)
if row is None:
raise ApiError(401, "Not signed in or session expired; log in again")
g.user_id = row["user_id"]
return view(*args, **kwargs)
def allow_anonymous(view: Callable[P, R]) -> Callable[P, R]:
view.allow_anonymous = True # type: ignore[attr-defined]
return view
return wrapper
def _require_session() -> None:
view = current_app.view_functions.get(request.endpoint or "")
if view is None or getattr(view, "allow_anonymous", False):
return
token = request.cookies.get(SESSION_COOKIE)
row = None
if token:
row = query_one(
"""
UPDATE sessions
SET expires_at = LEAST(now() + interval '30 minutes', created_at + interval '12 hours')
WHERE token_hash = %s
AND expires_at > now()
AND created_at > now() - interval '12 hours'
RETURNING user_id
""",
(_token_hash(token),),
)
if row is None:
raise ApiError(401, "Not signed in or session expired; log in again")
g.user_id = row["user_id"]
def _token_hash(token: str) -> bytes:
@@ -137,10 +92,11 @@ def _token_hash(token: str) -> bytes:
def _pepper() -> bytes:
return current_app.extensions["password_pepper"]
pepper: bytes = current_app.extensions["password_pepper"]
return pepper
def _start_session(user: dict, status: int):
def _start_session(user: UserRow, status: int) -> Response:
token = secrets.token_urlsafe(32)
query(
"DELETE FROM sessions WHERE user_id = %s AND expires_at <= now()", (user["id"],)
@@ -160,7 +116,7 @@ def _start_session(user: dict, status: int):
# --- Routes ----------------------------------------------------------------------
def _password(body: dict) -> str:
def _password(body: JsonObject) -> str:
password = body.get("password")
if not isinstance(password, str):
raise ApiError(400, "password is required and must be a string", "password")
@@ -173,7 +129,8 @@ def _password(body: dict) -> str:
@bp.post("/register")
def register():
@allow_anonymous
def register() -> Response:
body = json_body({"username", "password"})
username = body.get("username")
if not isinstance(username, str) or not USERNAME_PATTERN.fullmatch(username):
@@ -188,7 +145,7 @@ def register():
400, f"password must be at least {MIN_PASSWORD} characters", "password"
)
try:
user = query_one(
user = query_row(
"INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id, username",
(username, hash_password(password, _pepper())),
)
@@ -196,22 +153,26 @@ def register():
raise ApiError(
409, "Username already taken; choose another", "username"
) from None
return _start_session(user, 201)
return _start_session(cast(UserRow, user), 201)
@bp.post("/login")
def login():
@allow_anonymous
def login() -> Response:
body = json_body({"username", "password"})
password = _password(body)
username = body.get("username")
user = None
if isinstance(username, str) and USERNAME_PATTERN.fullmatch(username):
user = query_one(
"SELECT id, username, password_hash FROM users WHERE lower(username) = lower(%s)",
(username,),
user = cast(
UserWithHash | None,
query_one(
"SELECT id, username, password_hash FROM users WHERE lower(username) = lower(%s)",
(username,),
),
)
if user is None:
verify_password(password, _dummy_hash(), _pepper())
verify_password(password, dummy_hash(), _pepper())
raise ApiError(401, INVALID_LOGIN)
if not verify_password(password, user["password_hash"], _pepper()):
raise ApiError(401, INVALID_LOGIN)
@@ -224,7 +185,8 @@ def login():
@bp.post("/logout")
def logout():
@allow_anonymous
def logout() -> Response:
token = request.cookies.get(SESSION_COOKIE)
if token:
query("DELETE FROM sessions WHERE token_hash = %s", (_token_hash(token),))
@@ -236,8 +198,7 @@ def logout():
@bp.get("/me")
@login_required
def me():
def me() -> Response:
return jsonify(
query_one("SELECT id, username FROM users WHERE id = %s", (g.user_id,))
)