From 631571ed886edc97ce0296a918205b1475a9a044 Mon Sep 17 00:00:00 2001 From: gilesb Date: Wed, 7 Jan 2026 14:43:56 +0000 Subject: [PATCH] feat: add authentication to L1 server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Runs require auth token (verified with L2) - Store username with each run - UI login/register/logout via L2 - Filter runs by logged-in user - Cookie-based auth for UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docker-compose.yml | 1 + server.py | 270 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 259 insertions(+), 12 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ff16cf0..3448e90 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,7 @@ services: environment: - REDIS_URL=redis://redis:6379/5 - CACHE_DIR=/data/cache + - L2_SERVER=http://activitypub_l2-server:8200 volumes: - l1_cache:/data/cache depends_on: diff --git a/server.py b/server.py index d263c39..a5af277 100644 --- a/server.py +++ b/server.py @@ -16,16 +16,21 @@ from datetime import datetime, timezone from pathlib import Path from typing import Optional -from fastapi import FastAPI, HTTPException, UploadFile, File -from fastapi.responses import FileResponse, HTMLResponse +from fastapi import FastAPI, HTTPException, UploadFile, File, Depends, Form, Request +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pydantic import BaseModel import redis +import requests as http_requests from urllib.parse import urlparse from celery_app import app as celery_app from tasks import render_effect +# L2 server for auth verification +L2_SERVER = os.environ.get("L2_SERVER", "http://localhost:8200") + # Cache directory (use /data/cache in Docker, ~/.artdag/cache locally) CACHE_DIR = Path(os.environ.get("CACHE_DIR", str(Path.home() / ".artdag" / "cache"))) CACHE_DIR.mkdir(parents=True, exist_ok=True) @@ -90,6 +95,48 @@ class RunStatus(BaseModel): error: Optional[str] = None celery_task_id: Optional[str] = None effects_commit: Optional[str] = None + username: Optional[str] = None # Owner of the run + + +# ============ Auth ============ + +security = HTTPBearer(auto_error=False) + + +def verify_token_with_l2(token: str) -> Optional[str]: + """Verify token with L2 server, return username if valid.""" + try: + resp = http_requests.post( + f"{L2_SERVER}/auth/verify", + headers={"Authorization": f"Bearer {token}"}, + timeout=5 + ) + if resp.status_code == 200: + return resp.json().get("username") + except Exception: + pass + return None + + +async def get_optional_user( + credentials: HTTPAuthorizationCredentials = Depends(security) +) -> Optional[str]: + """Get username if authenticated, None otherwise.""" + if not credentials: + return None + return verify_token_with_l2(credentials.credentials) + + +async def get_required_user( + credentials: HTTPAuthorizationCredentials = Depends(security) +) -> str: + """Get username, raise 401 if not authenticated.""" + if not credentials: + raise HTTPException(401, "Not authenticated") + username = verify_token_with_l2(credentials.credentials) + if not username: + raise HTTPException(401, "Invalid token") + return username def file_hash(path: Path) -> str: @@ -219,8 +266,8 @@ async def root(): @app.post("/runs", response_model=RunStatus) -async def create_run(request: RunRequest): - """Start a new rendering run.""" +async def create_run(request: RunRequest, username: str = Depends(get_required_user)): + """Start a new rendering run. Requires authentication.""" run_id = str(uuid.uuid4()) # Generate output name if not provided @@ -233,7 +280,8 @@ async def create_run(request: RunRequest): recipe=request.recipe, inputs=request.inputs, output_name=output_name, - created_at=datetime.now(timezone.utc).isoformat() + created_at=datetime.now(timezone.utc).isoformat(), + username=username ) # Submit to Celery @@ -468,6 +516,14 @@ def detect_media_type(cache_path: Path) -> str: return "unknown" +def get_user_from_cookie(request) -> Optional[str]: + """Get username from auth cookie.""" + token = request.cookies.get("auth_token") + if not token: + return None + return verify_token_with_l2(token) + + UI_CSS = """ * { box-sizing: border-box; } body { @@ -526,15 +582,33 @@ UI_CSS = """ code { background: #222; padding: 2px 6px; border-radius: 4px; } """ -UI_HTML = """ +def render_ui_html(username: Optional[str] = None) -> str: + """Render main UI HTML with optional user context.""" + user_info = "" + if username: + user_info = f''' +
+ Logged in as {username} + Logout +
+ ''' + else: + user_info = ''' +
+ Login +
+ ''' + + return f""" Art DAG L1 Server - + + {user_info}

Art DAG L1 Server

+ + + + +""" + + +UI_REGISTER_HTML = """ + + + + Register | Art DAG L1 Server + + + +

Art DAG L1 Server

+ ← Back + + + + +""" + + @app.get("/ui", response_class=HTMLResponse) -async def ui_index(): +async def ui_index(request: Request): """Web UI for viewing runs.""" - return UI_HTML + username = get_user_from_cookie(request) + return render_ui_html(username) + + +@app.get("/ui/login", response_class=HTMLResponse) +async def ui_login_page(): + """Login page.""" + return UI_LOGIN_HTML + + +@app.post("/ui/login") +async def ui_login(username: str = Form(...), password: str = Form(...)): + """Process login form.""" + try: + resp = http_requests.post( + f"{L2_SERVER}/auth/login", + json={"username": username, "password": password}, + timeout=5 + ) + if resp.status_code == 200: + token = resp.json().get("access_token") + response = RedirectResponse(url="/ui", status_code=303) + response.set_cookie("auth_token", token, httponly=True, max_age=30*24*60*60) + return response + except Exception: + pass + + return HTMLResponse(UI_LOGIN_HTML.replace( + '', + '

Invalid username or password

' + )) + + +@app.get("/ui/register", response_class=HTMLResponse) +async def ui_register_page(): + """Register page.""" + return UI_REGISTER_HTML + + +@app.post("/ui/register") +async def ui_register( + username: str = Form(...), + password: str = Form(...), + email: str = Form(None) +): + """Process registration form.""" + try: + resp = http_requests.post( + f"{L2_SERVER}/auth/register", + json={"username": username, "password": password, "email": email}, + timeout=5 + ) + if resp.status_code == 200: + token = resp.json().get("access_token") + response = RedirectResponse(url="/ui", status_code=303) + response.set_cookie("auth_token", token, httponly=True, max_age=30*24*60*60) + return response + elif resp.status_code == 400: + error = resp.json().get("detail", "Registration failed") + return HTMLResponse(UI_REGISTER_HTML.replace( + '', + f'

{error}

' + )) + except Exception as e: + return HTMLResponse(UI_REGISTER_HTML.replace( + '', + f'

Registration failed: {e}

' + )) + + +@app.get("/ui/logout") +async def ui_logout(): + """Logout - clear cookie.""" + response = RedirectResponse(url="/ui", status_code=303) + response.delete_cookie("auth_token") + return response @app.get("/ui/runs", response_class=HTMLResponse) -async def ui_runs(): +async def ui_runs(request: Request): """HTMX partial: list of runs.""" + current_user = get_user_from_cookie(request) runs = list_all_runs() + # Filter runs by user if logged in + if current_user: + runs = [r for r in runs if r.username == current_user] + if not runs: - return '

No runs yet.

' + if current_user: + return '

You have no runs yet. Use the CLI to start a run.

' + return '

No runs yet. Login to see your runs.

' html_parts = ['
'] for run in runs[:20]: # Limit to 20 most recent status_class = run.status effect_url = f"https://git.rose-ash.com/art-dag/effects/src/branch/main/{run.recipe}" + owner_badge = f'by {run.username or "anonymous"}' if not current_user else '' html_parts.append(f''' @@ -573,7 +819,7 @@ async def ui_runs():
{run.recipe} - {run.run_id} + {run.run_id}{owner_badge}
{run.status}