93 lines
3.1 KiB
Python
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
|