Files

93 lines
3.1 KiB
Python

"""Token issuing, request authentication, and per-request context."""
from __future__ import annotations
import secrets
from fastapi import HTTPException, Request
from .config import Config
from .db import connect
from .util import now_iso, sha256_text
def hash_token(raw: str) -> str:
return sha256_text(raw)
def issue_token(conn, user_id: int, name: str) -> str:
raw = "dsh_" + secrets.token_hex(32)
conn.execute(
"INSERT INTO tokens (token_hash, user_id, name, created_at) VALUES (%s, %s, %s, %s)",
(hash_token(raw), user_id, name, now_iso()),
)
conn.commit()
return raw
def ensure_bootstrap_admin(cfg: Config) -> None:
"""Idempotently materialize the admin user + token from env on startup."""
if not cfg.bootstrap_admin_token:
return
conn = connect(cfg)
try:
row = conn.execute(
"SELECT id FROM users WHERE username = %s", (cfg.admin_username,)
).fetchone()
if row is None:
cur = conn.execute(
"INSERT INTO users (username, role, created_at) VALUES (%s, 'admin', %s) RETURNING id",
(cfg.admin_username, now_iso()),
)
user_id = cur.fetchone()["id"]
else:
user_id = row["id"]
conn.execute(
"""INSERT INTO tokens (token_hash, user_id, name, created_at)
VALUES (%s, %s, 'bootstrap-admin', %s)
ON CONFLICT (token_hash) DO NOTHING""",
(hash_token(cfg.bootstrap_admin_token), user_id, now_iso()),
)
conn.commit()
finally:
conn.close()
def authenticate(conn, authorization: str | None) -> dict:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="missing bearer token")
raw = authorization[len("Bearer "):].strip()
row = conn.execute(
"""SELECT t.token_hash, t.revoked, u.id AS user_id, u.username, u.role
FROM tokens t JOIN users u ON u.id = t.user_id
WHERE t.token_hash = %s""",
(hash_token(raw),),
).fetchone()
if row is None or row["revoked"]:
raise HTTPException(status_code=401, detail="invalid or revoked token")
conn.execute(
"UPDATE tokens SET last_used_at = %s WHERE token_hash = %s",
(now_iso(), row["token_hash"]),
)
conn.commit()
return {"id": row["user_id"], "username": row["username"], "role": row["role"]}
class Ctx:
"""Per-request context: config + DB connection + lazily authenticated user."""
def __init__(self, cfg: Config, conn):
self.cfg = cfg
self.conn = conn
self._user: dict | None = None
def require_user(self, request: Request) -> dict:
if self._user is None:
self._user = authenticate(self.conn, request.headers.get("authorization"))
return self._user
def require_admin(self, request: Request) -> dict:
user = self.require_user(request)
if user["role"] != "admin":
raise HTTPException(status_code=403, detail="admin role required")
return user