Files
Clarium/backend/auth.py
T

214 lines
7.7 KiB
Python

"""Auth slice: password hashing (ADR-0001), 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 pathlib import Path
import psycopg2.errors
from flask import Blueprint, Flask, current_app, g, jsonify, make_response, request
from db import query, query_one
from validation import ApiError, 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"
def init_app(app: Flask) -> None:
app.extensions["password_pepper"] = load_pepper(Path(app.root_path) / ".pepper")
_dummy_hash() # pay the one-time cost now so the first unknown-user login isn't measurably slower
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:
# One statement validates and slides the idle window. The created_at predicate enforces
# the 12-hour cap even while expires_at is still in the future (ADR-0002).
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)
return wrapper
def _token_hash(token: str) -> bytes:
return hashlib.sha256(token.encode("utf-8")).digest()
def _pepper() -> bytes:
return current_app.extensions["password_pepper"]
def _start_session(user: dict, status: int):
token = secrets.token_urlsafe(32)
query("DELETE FROM sessions WHERE user_id = %s AND expires_at <= now()", (user["id"],))
query(
"INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (%s, %s, now() + interval '30 minutes')",
(_token_hash(token), user["id"]),
)
response = jsonify(id=user["id"], username=user["username"])
response.status_code = status
response.set_cookie(SESSION_COOKIE, token, httponly=True, secure=True, samesite="Lax", path="/api")
return response
# --- Routes ----------------------------------------------------------------------
def _password(body: dict) -> str:
password = body.get("password")
if not isinstance(password, str):
raise ApiError(400, "password is required and must be a string", "password")
if len(password) > MAX_PASSWORD:
raise ApiError(400, f"password must be at most {MAX_PASSWORD} characters", "password")
require_utf8(password, "password")
return password
@bp.post("/register")
def register():
body = json_body({"username", "password"})
username = body.get("username")
if not isinstance(username, str) or not USERNAME_PATTERN.fullmatch(username):
raise ApiError(400, "username must be 3-64 characters: letters, digits, '.', '_' or '-'", "username")
password = _password(body)
if len(password) < MIN_PASSWORD:
raise ApiError(400, f"password must be at least {MIN_PASSWORD} characters", "password")
try:
user = query_one(
"INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id, username",
(username, hash_password(password, _pepper())),
)
except psycopg2.errors.UniqueViolation:
raise ApiError(409, "Username already taken; choose another", "username") from None
return _start_session(user, 201)
@bp.post("/login")
def login():
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,)
)
if user is None:
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)
if needs_rehash(user["password_hash"]):
query("UPDATE users SET password_hash = %s WHERE id = %s", (hash_password(password, _pepper()), user["id"]))
return _start_session(user, 200)
@bp.post("/logout")
def logout():
token = request.cookies.get(SESSION_COOKIE)
if token:
query("DELETE FROM sessions WHERE token_hash = %s", (_token_hash(token),))
response = make_response("", 204)
response.delete_cookie(SESSION_COOKIE, path="/api", secure=True, httponly=True, samesite="Lax")
return response
@bp.get("/me")
@login_required
def me():
return jsonify(query_one("SELECT id, username FROM users WHERE id = %s", (g.user_id,)))