"""FlowDeck — v5.2.0 Security: per-user API tokens & active sessions. Backend for the Settings → API tokens / Sessions UI. """ from __future__ import annotations import hashlib import logging from secrets import token_urlsafe from fastapi import APIRouter, HTTPException, Request from app.auth.session import SessionManager from app.db import get_conn logger = logging.getLogger(__name__) router = APIRouter(tags=["security"], prefix="/api/settings") def _hash_token(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() def _current_user_id(request: Request) -> int: user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user or not user.get("id"): raise HTTPException(status_code=401, detail="Not authenticated") return user["id"] # ═══════════ API tokens ═══════════ @router.get("/tokens") def list_tokens(request: Request): """List the current user's API tokens (prefix only, no secrets).""" uid = _current_user_id(request) with get_conn() as conn: rows = conn.execute( "SELECT id, name, token_prefix, last_used_at, revoked, created_at " "FROM api_tokens WHERE user_id=? ORDER BY created_at DESC", (uid,), ).fetchall() return {"tokens": [dict(r) for r in rows]} @router.post("/tokens") async def create_token(request: Request): """Create an API token for the current user. The secret is returned once.""" uid = _current_user_id(request) body = await request.json() name = (body.get("name") or "").strip() or "API token" token = f"fd_{token_urlsafe(24)}" with get_conn() as conn: cur = conn.execute( "INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)", (uid, name[:80], _hash_token(token), token[:12]), ) conn.commit() tid = cur.lastrowid return {"id": tid, "name": name, "token": token, "note": "Copy this token now — it won't be shown again."} @router.delete("/tokens/{token_id:int}") def revoke_token(token_id: int, request: Request): """Revoke an API token (soft delete).""" uid = _current_user_id(request) with get_conn() as conn: row = conn.execute( "SELECT id FROM api_tokens WHERE id=? AND user_id=?", (token_id, uid) ).fetchone() if not row: raise HTTPException(status_code=404, detail="Token not found") conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,)) conn.commit() return {"status": "revoked"} # ═══════════ Active sessions ═══════════ @router.get("/sessions") def list_sessions(request: Request): """List the current user's active sessions with their devices.""" uid = _current_user_id(request) current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", "")) sessions = SessionManager.list_sessions(uid) now = __import__("datetime").datetime.now() for s in sessions: s["is_current"] = (s["id"] == current_sid) # A session older than 7 days is implicitly expired (cookie max-age). created = s.get("created_at") or "" try: from datetime import datetime created_dt = datetime.fromisoformat(str(created).replace("Z", "")) s["expired"] = (now - created_dt).days >= 7 except Exception: s["expired"] = False return {"sessions": sessions} @router.post("/sessions/{sid}/revoke") def revoke_session(sid: str, request: Request): """Revoke an active session. If it's the current one, the user is logged out.""" uid = _current_user_id(request) with get_conn() as conn: row = conn.execute( "SELECT id FROM user_sessions WHERE id=? AND user_id=?", (sid, uid) ).fetchone() if not row: raise HTTPException(status_code=404, detail="Session not found") SessionManager.revoke_session(sid) # Also wipe the OAuth state cookie if the current session was revoked. current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", "")) if current_sid == sid: try: request.session.clear() except Exception: logger.exception("revoke_session") return {"status": "revoked"}