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:
+81
-120
@@ -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,))
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user