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:
@@ -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
270
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'''
|
||||
<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">← 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">← 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>
|
||||
|
||||
Reference in New Issue
Block a user