96 lines
3.6 KiB
Python
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}
|