244 lines
7.7 KiB
Python
244 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()
|
|
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)
|
|
|
|
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,))
|
|
)
|