style: format backend with black
Also drops the session-sliding comment in login_required.
This commit is contained in:
+43
-13
@@ -1,4 +1,5 @@
|
||||
"""Auth slice: password hashing (ADR-0001), server-side sessions (ADR-0002), and auth routes."""
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import hashlib
|
||||
@@ -35,7 +36,14 @@ def init_app(app: Flask) -> None:
|
||||
|
||||
# --- Passwords -----------------------------------------------------------------
|
||||
|
||||
def hash_password(password: str, pepper: bytes, *, salt: bytes | None = None, iterations: int = ITERATIONS) -> str:
|
||||
|
||||
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)}"
|
||||
@@ -98,14 +106,13 @@ def load_pepper(pepper_file: Path) -> bytes:
|
||||
|
||||
# --- 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
|
||||
@@ -135,25 +142,32 @@ def _pepper() -> bytes:
|
||||
|
||||
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(
|
||||
"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")
|
||||
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")
|
||||
raise ApiError(
|
||||
400, f"password must be at most {MAX_PASSWORD} characters", "password"
|
||||
)
|
||||
require_utf8(password, "password")
|
||||
return password
|
||||
|
||||
@@ -163,17 +177,25 @@ 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")
|
||||
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")
|
||||
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
|
||||
raise ApiError(
|
||||
409, "Username already taken; choose another", "username"
|
||||
) from None
|
||||
return _start_session(user, 201)
|
||||
|
||||
|
||||
@@ -185,7 +207,8 @@ def login():
|
||||
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,)
|
||||
"SELECT id, username, password_hash FROM users WHERE lower(username) = lower(%s)",
|
||||
(username,),
|
||||
)
|
||||
if user is None:
|
||||
verify_password(password, _dummy_hash(), _pepper())
|
||||
@@ -193,7 +216,10 @@ def 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"]))
|
||||
query(
|
||||
"UPDATE users SET password_hash = %s WHERE id = %s",
|
||||
(hash_password(password, _pepper()), user["id"]),
|
||||
)
|
||||
return _start_session(user, 200)
|
||||
|
||||
|
||||
@@ -203,11 +229,15 @@ def logout():
|
||||
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")
|
||||
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,)))
|
||||
return jsonify(
|
||||
query_one("SELECT id, username FROM users WHERE id = %s", (g.user_id,))
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user