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:
|
environment:
|
||||||
- REDIS_URL=redis://redis:6379/5
|
- REDIS_URL=redis://redis:6379/5
|
||||||
- CACHE_DIR=/data/cache
|
- CACHE_DIR=/data/cache
|
||||||
|
- L2_SERVER=http://activitypub_l2-server:8200
|
||||||
volumes:
|
volumes:
|
||||||
- l1_cache:/data/cache
|
- l1_cache:/data/cache
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
270
server.py
270
server.py
@@ -16,16 +16,21 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, UploadFile, File
|
from fastapi import FastAPI, HTTPException, UploadFile, File, Depends, Form, Request
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
|
import requests as http_requests
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from celery_app import app as celery_app
|
from celery_app import app as celery_app
|
||||||
from tasks import render_effect
|
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 directory (use /data/cache in Docker, ~/.artdag/cache locally)
|
||||||
CACHE_DIR = Path(os.environ.get("CACHE_DIR", str(Path.home() / ".artdag" / "cache")))
|
CACHE_DIR = Path(os.environ.get("CACHE_DIR", str(Path.home() / ".artdag" / "cache")))
|
||||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -90,6 +95,48 @@ class RunStatus(BaseModel):
|
|||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
celery_task_id: Optional[str] = None
|
celery_task_id: Optional[str] = None
|
||||||
effects_commit: 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:
|
def file_hash(path: Path) -> str:
|
||||||
@@ -219,8 +266,8 @@ async def root():
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/runs", response_model=RunStatus)
|
@app.post("/runs", response_model=RunStatus)
|
||||||
async def create_run(request: RunRequest):
|
async def create_run(request: RunRequest, username: str = Depends(get_required_user)):
|
||||||
"""Start a new rendering run."""
|
"""Start a new rendering run. Requires authentication."""
|
||||||
run_id = str(uuid.uuid4())
|
run_id = str(uuid.uuid4())
|
||||||
|
|
||||||
# Generate output name if not provided
|
# Generate output name if not provided
|
||||||
@@ -233,7 +280,8 @@ async def create_run(request: RunRequest):
|
|||||||
recipe=request.recipe,
|
recipe=request.recipe,
|
||||||
inputs=request.inputs,
|
inputs=request.inputs,
|
||||||
output_name=output_name,
|
output_name=output_name,
|
||||||
created_at=datetime.now(timezone.utc).isoformat()
|
created_at=datetime.now(timezone.utc).isoformat(),
|
||||||
|
username=username
|
||||||
)
|
)
|
||||||
|
|
||||||
# Submit to Celery
|
# Submit to Celery
|
||||||
@@ -468,6 +516,14 @@ def detect_media_type(cache_path: Path) -> str:
|
|||||||
return "unknown"
|
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 = """
|
UI_CSS = """
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
@@ -526,15 +582,33 @@ UI_CSS = """
|
|||||||
code { background: #222; padding: 2px 6px; border-radius: 4px; }
|
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>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Art DAG L1 Server</title>
|
<title>Art DAG L1 Server</title>
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
<style>""" + UI_CSS + """</style>
|
<style>{UI_CSS}</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
{user_info}
|
||||||
<h1><a href="/ui" style="color: inherit;">Art DAG L1 Server</a></h1>
|
<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">
|
<button class="refresh-btn" hx-get="/ui/runs" hx-target="#runs" hx-swap="innerHTML">
|
||||||
Refresh
|
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)
|
@app.get("/ui", response_class=HTMLResponse)
|
||||||
async def ui_index():
|
async def ui_index(request: Request):
|
||||||
"""Web UI for viewing runs."""
|
"""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)
|
@app.get("/ui/runs", response_class=HTMLResponse)
|
||||||
async def ui_runs():
|
async def ui_runs(request: Request):
|
||||||
"""HTMX partial: list of runs."""
|
"""HTMX partial: list of runs."""
|
||||||
|
current_user = get_user_from_cookie(request)
|
||||||
runs = list_all_runs()
|
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:
|
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">']
|
html_parts = ['<div class="runs">']
|
||||||
|
|
||||||
for run in runs[:20]: # Limit to 20 most recent
|
for run in runs[:20]: # Limit to 20 most recent
|
||||||
status_class = run.status
|
status_class = run.status
|
||||||
effect_url = f"https://git.rose-ash.com/art-dag/effects/src/branch/main/{run.recipe}"
|
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'''
|
html_parts.append(f'''
|
||||||
<a href="/ui/detail/{run.run_id}" class="run-link">
|
<a href="/ui/detail/{run.run_id}" class="run-link">
|
||||||
@@ -573,7 +819,7 @@ async def ui_runs():
|
|||||||
<div class="run-header">
|
<div class="run-header">
|
||||||
<div>
|
<div>
|
||||||
<span class="run-recipe">{run.recipe}</span>
|
<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>
|
</div>
|
||||||
<span class="status {status_class}">{run.status}</span>
|
<span class="status {status_class}">{run.status}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user