Files
dsh/server/dsh_sync/routers/tokens.py
T

96 lines
3.6 KiB
Python

"""Token lifecycle: redeem an invite, list, self-issue, revoke."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from ..auth import Ctx, issue_token
from ..db import record_audit
from ..deps import get_ctx
from ..ratelimit import SlidingWindow, limit_invite_attempts
from ..util import USERNAME_RE, now_iso
router = APIRouter()
class TokenRequest(BaseModel):
username: str
invite_code: str | None = None
token_name: str = "cli"
class SelfTokenRequest(BaseModel):
token_name: str = "cli"
def _limiter(request: Request) -> SlidingWindow:
return request.app.state.invite_limiter
@router.post("/v1/tokens")
def create_token(body: TokenRequest, request: Request, ctx: Ctx = Depends(get_ctx)):
"""Exchange a team invite code for a personal token (new users only).
Unauthenticated, so it is the one endpoint worth brute-forcing: rate limited
per peer address before the invite code is compared.
"""
limiter = _limiter(request)
bucket = limit_invite_attempts(request, limiter)
cfg = ctx.cfg
if not USERNAME_RE.match(body.username or ""):
raise HTTPException(400, "invalid username")
if cfg.invite_code is None or body.invite_code != cfg.invite_code:
raise HTTPException(403, "invalid invite code")
row = ctx.conn.execute("SELECT id FROM users WHERE username = %s", (body.username,)).fetchone()
if row is not None:
raise HTTPException(409, "user already exists; ask an admin for a new token")
cur = ctx.conn.execute(
"INSERT INTO users (username, role, created_at) VALUES (%s, 'member', %s) RETURNING id",
(body.username, now_iso()),
)
user_id = cur.fetchone()["id"]
raw = issue_token(ctx.conn, user_id, body.token_name)
record_audit(ctx.conn, user_id, "user.create", {"username": body.username, "via": "invite"})
record_audit(ctx.conn, user_id, "token.create", {"name": body.token_name})
limiter.clear(bucket)
return {
"token": raw,
"user": {"id": user_id, "username": body.username, "role": "member"},
}
@router.post("/v1/tokens/self")
def create_self_token(body: SelfTokenRequest, request: Request, ctx: Ctx = Depends(get_ctx)):
"""Issue an additional token for the calling user (re-login helper)."""
user = ctx.require_user(request)
raw = issue_token(ctx.conn, user["id"], body.token_name)
record_audit(ctx.conn, user["id"], "token.create", {"name": body.token_name, "via": "self"})
return {"token": raw}
@router.get("/v1/tokens")
def list_tokens(request: Request, ctx: Ctx = Depends(get_ctx)):
user = ctx.require_user(request)
rows = ctx.conn.execute(
"""SELECT token_hash, name, created_at, last_used_at FROM tokens
WHERE user_id = %s AND revoked = FALSE ORDER BY created_at DESC""",
(user["id"],),
).fetchall()
return {"tokens": [dict(r) for r in rows]}
@router.delete("/v1/tokens/{token_hash}")
def revoke_token(token_hash: str, request: Request, ctx: Ctx = Depends(get_ctx)):
user = ctx.require_user(request)
row = ctx.conn.execute(
"SELECT token_hash, user_id FROM tokens WHERE token_hash = %s", (token_hash,)
).fetchone()
if row is None:
raise HTTPException(404, "token not found")
if user["role"] != "admin" and row["user_id"] != user["id"]:
raise HTTPException(403, "cannot revoke another user's token")
ctx.conn.execute("UPDATE tokens SET revoked = TRUE WHERE token_hash = %s", (token_hash,))
ctx.conn.commit()
record_audit(ctx.conn, user["id"], "token.revoke", {"token_hash": token_hash[:12] + "…"})
return {"revoked": True}