feat: Phase 1 - multi-user auth foundation

- New auth.py module: users table, invite_codes table, JWT auth,
  password hashing (bcrypt), auth dependencies for FastAPI
- KITHARA_MODE=single|multi in .env (default: single)
- Single mode: no auth required, no login, user_id='default'
- Multi mode: full JWT auth, login/register endpoints, admin bootstrap
- Health endpoint now reports mode: {status, version, mode}
- Auth endpoints: POST /api/auth/login, /register, GET /me
- Admin endpoints: GET /admin/users, POST /admin/users,
  POST /admin/invites, GET /admin/invites,
  DELETE /admin/users/:id, PUT /admin/users/:id/role
- BYOK: PUT /api/auth/api-key for per-user Lyria keys
- DB migrations: user_id, shared, remixed_from columns on clips
- DB indexes for user-based and shared-content queries
- requirements.txt: added PyJWT and passlib[bcrypt]
- .env.example: documented all new config options
This commit is contained in:
rin.kagamine committed 2026-05-31 11:59:07 -04:00
1 parent b008a6790a
commit b3f273942e
5 files changed
+540 -5

No files matched your search

+267
View File
@@ -0,0 +1,267 @@
import os
import uuid
import hashlib
import secrets
from datetime import datetime, timezone, timedelta
import aiosqlite
import jwt
from passlib.hash import bcrypt
from database import DB_PATH
# ─── Configuration ───────────────────────────────────────────────────────────
KITHARA_MODE = os.getenv("KITHARA_MODE", "single")
def is_multi_user() -> bool:
return KITHARA_MODE.lower() == "multi"
def is_single_user() -> bool:
return not is_multi_user()
# ─── JWT ────────────────────────────────────────────────────────────────────
def _get_jwt_secret() -> str:
"""Get or generate JWT secret. Persists in DB for durability across restarts."""
env_secret = os.getenv("KITHARA_JWT_SECRET")
if env_secret:
return env_secret
# Check DB for persisted secret
import asyncio
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# Can't await from here if event loop is already running
# Fall back to generating a new one each startup (sessions invalid on restart)
return secrets.token_hex(32)
except RuntimeError:
pass
return secrets.token_hex(32)
JWT_SECRET = _get_jwt_secret()
JWT_ALGORITHM = "HS256"
JWT_EXPIRY_HOURS = 72 # 3 days
def create_access_token(user_id: str, role: str) -> str:
"""Create a JWT token for a user."""
payload = {
"sub": user_id,
"role": role,
"exp": datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRY_HOURS),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
def decode_access_token(token: str) -> dict | None:
"""Decode and validate a JWT token. Returns payload or None."""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
# ─── Password Hashing ────────────────────────────────────────────────────────
def hash_password(password: str) -> str:
"""Hash a password using bcrypt."""
return bcrypt.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
"""Verify a password against a hash."""
return bcrypt.verify(password, password_hash)
# ─── User Management ─────────────────────────────────────────────────────────
async def ensure_users_table():
"""Create users and invite_codes tables if they don't exist."""
async with aiosqlite.connect(DB_PATH) as db:
await db.executescript("""
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT,
role TEXT DEFAULT 'user',
api_key TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE TABLE IF NOT EXISTS invite_codes (
code TEXT PRIMARY KEY,
created_by TEXT REFERENCES users(id),
max_uses INTEGER DEFAULT 1,
current_uses INTEGER DEFAULT 0,
expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
await db.commit()
async def create_user(username: str, password: str, role: str = "user", display_name: str = None) -> dict:
"""Create a new user. Returns user dict."""
user_id = str(uuid.uuid4())
password_hash = hash_password(password)
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
try:
await db.execute(
"INSERT INTO users (id, username, password_hash, role, display_name) VALUES (?, ?, ?, ?, ?)",
(user_id, username, password_hash, role, display_name or username)
)
await db.commit()
except aiosqlite.IntegrityError:
return None # Username taken
return await get_user_by_id(user_id)
async def get_user_by_id(user_id: str) -> dict | None:
"""Get user by ID."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute("SELECT * FROM users WHERE id = ?", (user_id,)) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def get_user_by_username(username: str) -> dict | None:
"""Get user by username."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute("SELECT * FROM users WHERE username = ?", (username,)) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def authenticate_user(username: str, password: str) -> dict | None:
"""Authenticate a user. Returns user dict if valid, None if not."""
user = await get_user_by_username(username)
if not user:
return None
if not verify_password(password, user["password_hash"]):
return None
return user
async def list_users() -> list[dict]:
"""List all users (admin tool)."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute("SELECT id, username, display_name, role, api_key IS NOT NULL as has_api_key, created_at FROM users ORDER BY created_at") as cursor:
rows = await cursor.fetchall()
return [dict(row) for row in rows]
async def set_user_role(user_id: str, role: str) -> bool:
"""Set a user's role. Returns True if successful."""
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id))
await db.commit()
return cursor.rowcount > 0
async def set_user_api_key(user_id: str, api_key: str | None) -> bool:
"""Set or clear a user's API key. Returns True if successful."""
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute("UPDATE users SET api_key = ? WHERE id = ?", (api_key, user_id))
await db.commit()
return cursor.rowcount > 0
async def delete_user(user_id: str) -> bool:
"""Delete a user."""
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute("DELETE FROM users WHERE id = ?", (user_id,))
await db.commit()
return cursor.rowcount > 0
# ─── Invite Codes ────────────────────────────────────────────────────────────
async def create_invite_code(created_by: str, max_uses: int = 1, expires_hours: int = None) -> str:
"""Generate an invite code. Returns the code string."""
code = secrets.token_urlsafe(16)
expires_at = None
if expires_hours:
expires_at = (datetime.now(timezone.utc) + timedelta(hours=expires_hours)).isoformat()
async with aiosqlite.connect(DB_PATH) as db:
await db.execute(
"INSERT INTO invite_codes (code, created_by, max_uses, expires_at) VALUES (?, ?, ?, ?)",
(code, created_by, max_uses, expires_at)
)
await db.commit()
return code
async def use_invite_code(code: str) -> bool:
"""Try to use an invite code. Returns True if valid and consumed."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute("SELECT * FROM invite_codes WHERE code = ?", (code,)) as cursor:
row = await cursor.fetchone()
if not row:
return False
invite = dict(row)
# Check expiry
if invite["expires_at"]:
expires = datetime.fromisoformat(invite["expires_at"])
if datetime.now(timezone.utc) > expires.replace(tzinfo=timezone.utc):
return False
# Check max uses
if invite["current_uses"] >= invite["max_uses"]:
return False
# Increment uses
await db.execute(
"UPDATE invite_codes SET current_uses = current_uses + 1 WHERE code = ?",
(code,)
)
await db.commit()
return True
async def list_invite_codes() -> list[dict]:
"""List all invite codes (admin tool)."""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute("SELECT * FROM invite_codes ORDER BY created_at DESC") as cursor:
rows = await cursor.fetchall()
return [dict(row) for row in rows]
# ─── Bootstrap ───────────────────────────────────────────────────────────────
async def bootstrap_admin():
"""Create the first admin user from env vars if in multi mode and no users exist."""
if not is_multi_user():
return
# Check if any users exist
async with aiosqlite.connect(DB_PATH) as db:
async with db.execute("SELECT COUNT(*) FROM users") as cursor:
row = await cursor.fetchone()
if row[0] > 0:
return # Users exist, skip bootstrap
admin_user = os.getenv("KITHARA_ADMIN_USER")
admin_pass = os.getenv("KITHARA_ADMIN_PASS")
if admin_user and admin_pass:
user = await create_user(admin_user, admin_pass, role="admin", display_name="Admin")
if user:
print(f"[kithara] Bootstrapped admin user: {admin_user}")
+25
View File
@@ -60,6 +60,7 @@ CREATE TABLE IF NOT EXISTS clip_favorites (
CREATE INDEX IF NOT EXISTS idx_favorites_user ON clip_favorites(user_id, created_at DESC);
-- Migration: add artwork_filename column to existing databases
-- ALTER TABLE clips ADD COLUMN artwork_filename TEXT; -- run separately if needed
"""
@@ -112,6 +113,30 @@ async def init_db():
await db.execute("ALTER TABLE clips ADD COLUMN genre TEXT")
except Exception:
pass # Column already exists
# Migration: add user_id column if it doesn't exist
try:
await db.execute("ALTER TABLE clips ADD COLUMN user_id TEXT DEFAULT 'default'")
except Exception:
pass # Column already exists
# Migration: add shared column if it doesn't exist
try:
await db.execute("ALTER TABLE clips ADD COLUMN shared INTEGER DEFAULT 0")
except Exception:
pass # Column already exists
# Migration: add remixed_from column if it doesn't exist
try:
await db.execute("ALTER TABLE clips ADD COLUMN remixed_from TEXT")
except Exception:
pass # Column already exists
# Create indexes for new columns (safe to run every time
try:
await db.execute("CREATE INDEX IF NOT EXISTS idx_clips_user ON clips(user_id, created_at DESC)")
except Exception:
pass
try:
await db.execute("CREATE INDEX IF NOT EXISTS idx_clips_shared ON clips(shared, created_at DESC)")
except Exception:
pass
await db.commit()
async def create_clip(prompt: str, model: str, duration: int, prompt_structure: str = None) -> str:
+234 -3
View File
@@ -1,6 +1,7 @@
from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile, File
from fastapi import FastAPI, HTTPException, BackgroundTasks, UploadFile, File, Depends, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pathlib import Path
import os
import shutil
@@ -11,6 +12,13 @@ load_dotenv(Path(__file__).parent.parent / ".env")
from models import GenerateRequest, GenerateResponse, ClipList, RenameRequest, MetadataRequest, GenerateArtworkRequest, LYRIA_MODELS, DEFAULT_MODEL
from database import init_db, create_clip, get_clips, get_clip, delete_clip, update_clip_status, rename_clip, set_artwork, remove_artwork, delete_all_clips, set_analysis, update_metadata, toggle_favorite, get_favorites, is_favorite
from auth import (
is_multi_user, is_single_user, create_access_token, decode_access_token,
authenticate_user, create_user, get_user_by_id, get_user_by_username,
list_users, set_user_role, set_user_api_key, delete_user,
create_invite_code, use_invite_code, list_invite_codes,
ensure_users_table, bootstrap_admin
)
from kithara_client import generate_clip, save_clip_file
from waveform import generate_waveform_svg, get_waveform_path
from analysis import analyze_clip
@@ -36,13 +44,236 @@ ARTWORK_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_ARTWORK_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
MAX_ARTWORK_SIZE = 5 * 1024 * 1024 # 5 MB
security = HTTPBearer(auto_error=False)
# ─── Auth Dependency ────────────────────────────────────────────────────────
async def get_current_user(request: Request, credentials: HTTPAuthorizationCredentials = Depends(security)):
"""
Dependency that returns the current user dict, or None in single-user mode.
In multi-user mode, requires a valid JWT token.
In single-user mode, returns None (no auth needed).
"""
if is_single_user():
return None
if not credentials:
raise HTTPException(status_code=401, detail="Not authenticated")
payload = decode_access_token(credentials.credentials)
if not payload:
raise HTTPException(status_code=401, detail="Invalid or expired token")
user = await get_user_by_id(payload["sub"])
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user
async def require_admin(user = Depends(get_current_user)):
"""Dependency that requires the current user to be an admin."""
if is_single_user():
return None
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
if user["role"] != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return user
def get_user_id(user) -> str:
"""Get user_id from auth context, or 'default' for single-user mode."""
if user is None:
return "default"
return user["id"]
@app.on_event("startup")
async def startup():
await init_db()
await ensure_users_table()
await bootstrap_admin()
@app.get("/api/health")
async def health():
return {"status": "ok", "version": "0.1.0"}
return {
"status": "ok",
"version": "0.1.0",
"mode": "multi" if is_multi_user() else "single"
}
# ─── Auth Endpoints ──────────────────────────────────────────────────────────
@app.post("/api/auth/login")
async def login(request: Request):
"""Authenticate user and return JWT token."""
if is_single_user():
raise HTTPException(status_code=400, detail="Login not available in single-user mode")
body = await request.json()
username = body.get("username", "").strip()
password = body.get("password", "")
if not username or not password:
raise HTTPException(status_code=400, detail="Username and password required")
user = await authenticate_user(username, password)
if not user:
raise HTTPException(status_code=401, detail="Invalid username or password")
token = create_access_token(user["id"], user["role"])
return {
"token": token,
"user": {
"id": user["id"],
"username": user["username"],
"display_name": user["display_name"],
"role": user["role"],
}
}
@app.post("/api/auth/register")
async def register(request: Request):
"""Register a new user using an invite code."""
if is_single_user():
raise HTTPException(status_code=400, detail="Registration not available in single-user mode")
body = await request.json()
invite_code = body.get("invite_code", "").strip()
username = body.get("username", "").strip()
password = body.get("password", "")
display_name = body.get("display_name", "").strip() or username
if not invite_code or not username or not password:
raise HTTPException(status_code=400, detail="Invite code, username, and password required")
if len(password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
if not await use_invite_code(invite_code):
raise HTTPException(status_code=400, detail="Invalid or expired invite code")
user = await create_user(username, password, role="user", display_name=display_name)
if not user:
raise HTTPException(status_code=400, detail="Username already taken")
token = create_access_token(user["id"], user["role"])
return {
"token": token,
"user": {
"id": user["id"],
"username": user["username"],
"display_name": user["display_name"],
"role": user["role"],
}
}
@app.get("/api/auth/me")
async def get_me(user = Depends(get_current_user)):
"""Get current user info."""
if is_single_user():
return {"mode": "single", "user": None}
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
return {
"mode": "multi",
"user": {
"id": user["id"],
"username": user["username"],
"display_name": user["display_name"],
"role": user["role"],
"has_api_key": bool(user.get("api_key")),
}
}
@app.put("/api/auth/api-key")
async def update_api_key(request: Request, user = Depends(get_current_user)):
"""Update the current user's Lyria API key (BYOK)."""
if is_single_user():
raise HTTPException(status_code=400, detail="Not available in single-user mode")
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
body = await request.json()
api_key = body.get("api_key", "").strip() or None # Empty string = clear
await set_user_api_key(user["id"], api_key)
return {"status": "ok"}
# ─── Admin Endpoints ─────────────────────────────────────────────────────────
@app.get("/api/admin/users")
async def admin_list_users(admin = Depends(require_admin)):
"""List all users. Admin only."""
users = await list_users()
return {"users": users}
@app.post("/api/admin/users")
async def admin_create_user(request: Request, admin = Depends(require_admin)):
"""Create a new user. Admin only."""
body = await request.json()
username = body.get("username", "").strip()
password = body.get("password", "")
role = body.get("role", "user")
display_name = body.get("display_name", "").strip() or username
if not username or not password:
raise HTTPException(status_code=400, detail="Username and password required")
if len(password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
if role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'")
user = await create_user(username, password, role=role, display_name=display_name)
if not user:
raise HTTPException(status_code=400, detail="Username already taken")
return {"user": {
"id": user["id"],
"username": user["username"],
"display_name": user["display_name"],
"role": user["role"],
}}
@app.post("/api/admin/invites")
async def admin_create_invite(request: Request, admin = Depends(require_admin)):
"""Generate an invite code. Admin only."""
body = await request.json()
max_uses = body.get("max_uses", 1)
expires_hours = body.get("expires_hours")
code = await create_invite_code(admin["id"], max_uses=max_uses, expires_hours=expires_hours)
return {"code": code, "max_uses": max_uses, "expires_hours": expires_hours}
@app.get("/api/admin/invites")
async def admin_list_invites(admin = Depends(require_admin)):
"""List all invite codes. Admin only."""
invites = await list_invite_codes()
return {"invites": invites}
@app.delete("/api/admin/users/{user_id}")
async def admin_delete_user(user_id: str, admin = Depends(require_admin)):
"""Delete a user. Admin only."""
if user_id == admin["id"]:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
deleted = await delete_user(user_id)
if not deleted:
raise HTTPException(status_code=404, detail="User not found")
return {"status": "ok"}
@app.put("/api/admin/users/{user_id}/role")
async def admin_set_role(user_id: str, request: Request, admin = Depends(require_admin)):
"""Set a user's role. Admin only."""
body = await request.json()
role = body.get("role")
if role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'")
updated = await set_user_role(user_id, role)
if not updated:
raise HTTPException(status_code=404, detail="User not found")
return {"status": "ok"}
@app.get("/api/models")
async def list_models():