feat: add authentication to L1 server

- 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 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-07 14:43:56 +00:00
parent 0c7e43e069
commit 631571ed88
2 changed files with 259 additions and 12 deletions

View File

@@ -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:

270
server.py
View File

@@ -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'''
<div style="float:right;font-size:14px;color:#888;">
Logged in as <strong>{username}</strong>
<a href="/ui/logout" style="margin-left:12px;">Logout</a>
</div>
'''
else:
user_info = '''
<div style="float:right;font-size:14px;">
<a href="/ui/login">Login</a>
</div>
'''
return f"""
<!DOCTYPE html>
<html>
<head>
<title>Art DAG L1 Server</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>""" + UI_CSS + """</style>
<style>{UI_CSS}</style>
</head>
<body>
{user_info}
<h1><a href="/ui" style="color: inherit;">Art DAG L1 Server</a></h1>
<button class="refresh-btn" hx-get="/ui/runs" hx-target="#runs" hx-swap="innerHTML">
Refresh
@@ -547,25 +621,197 @@ UI_HTML = """
"""
UI_LOGIN_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>Login | Art DAG L1 Server</title>
<style>""" + UI_CSS + """
.login-form { max-width: 400px; }
.login-form input {
width: 100%; padding: 12px; margin: 8px 0;
background: #222; border: 1px solid #333; border-radius: 4px;
color: #eee; font-size: 15px;
}
.login-form button {
width: 100%; padding: 12px; margin-top: 12px;
background: #2563eb; color: #fff; border: none; border-radius: 4px;
font-size: 15px; cursor: pointer;
}
.login-form button:hover { background: #1d4ed8; }
.error { color: #f87171; margin-top: 12px; }
.tabs { margin-bottom: 20px; }
.tabs a { padding: 8px 16px; margin-right: 8px; background: #333; border-radius: 4px; }
.tabs a.active { background: #2563eb; }
</style>
</head>
<body>
<h1><a href="/ui" style="color: inherit;">Art DAG L1 Server</a></h1>
<a href="/ui" class="back-btn">&larr; Back</a>
<div class="run login-form">
<div class="tabs">
<a href="/ui/login" class="active">Login</a>
<a href="/ui/register">Register</a>
</div>
<form method="POST" action="/ui/login">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>
"""
UI_REGISTER_HTML = """
<!DOCTYPE html>
<html>
<head>
<title>Register | Art DAG L1 Server</title>
<style>""" + UI_CSS + """
.login-form { max-width: 400px; }
.login-form input {
width: 100%; padding: 12px; margin: 8px 0;
background: #222; border: 1px solid #333; border-radius: 4px;
color: #eee; font-size: 15px;
}
.login-form button {
width: 100%; padding: 12px; margin-top: 12px;
background: #2563eb; color: #fff; border: none; border-radius: 4px;
font-size: 15px; cursor: pointer;
}
.login-form button:hover { background: #1d4ed8; }
.error { color: #f87171; margin-top: 12px; }
.tabs { margin-bottom: 20px; }
.tabs a { padding: 8px 16px; margin-right: 8px; background: #333; border-radius: 4px; }
.tabs a.active { background: #2563eb; }
</style>
</head>
<body>
<h1><a href="/ui" style="color: inherit;">Art DAG L1 Server</a></h1>
<a href="/ui" class="back-btn">&larr; Back</a>
<div class="run login-form">
<div class="tabs">
<a href="/ui/login">Login</a>
<a href="/ui/register" class="active">Register</a>
</div>
<form method="POST" action="/ui/register">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
<input type="email" name="email" placeholder="Email (optional)">
<button type="submit">Register</button>
</form>
</div>
</body>
</html>
"""
@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(
'</form>',
'<p class="error">Invalid username or password</p></form>'
))
@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(
'</form>',
f'<p class="error">{error}</p></form>'
))
except Exception as e:
return HTMLResponse(UI_REGISTER_HTML.replace(
'</form>',
f'<p class="error">Registration failed: {e}</p></form>'
))
@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 '<p class="no-runs">No runs yet.</p>'
if current_user:
return '<p class="no-runs">You have no runs yet. Use the CLI to start a run.</p>'
return '<p class="no-runs">No runs yet. <a href="/ui/login">Login</a> to see your runs.</p>'
html_parts = ['<div class="runs">']
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'<span style="font-size:11px;color:#666;margin-left:8px;">by {run.username or "anonymous"}</span>' if not current_user else ''
html_parts.append(f'''
<a href="/ui/detail/{run.run_id}" class="run-link">
@@ -573,7 +819,7 @@ async def ui_runs():
<div class="run-header">
<div>
<span class="run-recipe">{run.recipe}</span>
<span class="run-id">{run.run_id}</span>
<span class="run-id">{run.run_id}</span>{owner_badge}
</div>
<span class="status {status_class}">{run.status}</span>
</div>