web: add session-cookie login for web UI

- New pipekit/web/auth.py: itsdangerous-signed cookie, 8hr expiry,
  auto-generates signing secret in settings table on first use
- GET/POST /login and POST /logout routes (public, no auth dependency)
- All other web routes protected via router-level require_web_auth dep
- Starlette middleware injects request.state.current_user for templates
- Topbar shows logged-in username + logout button when session active
- Reuses existing api_user/api_pass credentials and api_auth_enabled flag
- Add itsdangerous>=2.1 to requirements.txt
- Enable api_auth_enabled in config.yaml

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-06-04 13:21:50 -04:00
parent 583dc16c9b
commit 31135cf5be
6 changed files with 178 additions and 3 deletions

View File

@ -1,4 +1,5 @@
database: /opt/pipekit/pipekit.db
api_auth_enabled: true
jrunner_path: /usr/local/bin/jrunner
driver_dir: /opt/pipekit/drivers/
api_host: 0.0.0.0

View File

@ -15,13 +15,19 @@ from __future__ import annotations
from pathlib import Path
from urllib.parse import urlencode
from fastapi import APIRouter, BackgroundTasks, FastAPI, HTTPException, Query, Request
import secrets as _secrets
from urllib.parse import quote as _quote
from fastapi import APIRouter, BackgroundTasks, Depends, FastAPI, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.base import BaseHTTPMiddleware
from .. import __version__, drivers, engine, jrunner, repo
from ..config import get_config
from .auth import (NotAuthenticated, auth_enabled, clear_session_cookie,
get_session_user, require_web_auth, set_session_cookie)
from ..engine import watermark
from ..engine.merge import MergeError, build_merge_sql
@ -54,19 +60,74 @@ _templates.env.filters["duration"] = _fmt_duration
_templates.env.filters["localtime"] = _fmt_localtime
class _SessionMiddleware(BaseHTTPMiddleware):
"""Injects request.state.current_user so templates can read it."""
async def dispatch(self, request, call_next):
request.state.current_user = get_session_user(request)
return await call_next(request)
def mount_web(app: FastAPI) -> None:
"""Attach HTML pages + /static onto a FastAPI app."""
app.mount("/static", StaticFiles(directory=_WEB_DIR / "static"), name="static")
app.add_middleware(_SessionMiddleware)
app.include_router(_public_router)
app.include_router(_router)
@app.exception_handler(NotAuthenticated)
async def _not_auth_handler(request: Request, exc: NotAuthenticated):
return RedirectResponse(f"/login?next={_quote(exc.next_url, safe='')}", status_code=302)
_router = APIRouter(include_in_schema=False)
# Public routes — no auth required.
_public_router = APIRouter(include_in_schema=False)
# Protected routes — require_web_auth no-ops when auth is disabled.
_router = APIRouter(include_in_schema=False, dependencies=[Depends(require_web_auth)])
def _ctx(**extra) -> dict:
return {"version": __version__, "flash": None, **extra}
# ---------------------------------------------------------------------------
# Auth — login / logout
# ---------------------------------------------------------------------------
@_public_router.get("/login", response_class=HTMLResponse)
def login_page(request: Request, next: str = "/"):
if get_session_user(request) or not auth_enabled():
return RedirectResponse(next or "/", status_code=302)
return _templates.TemplateResponse(request, "login.html", _ctx(next=next, error=None))
@_public_router.post("/login")
async def login_submit(request: Request, next: str = "/"):
form = await request.form()
username = str(form.get("username") or "")
password = str(form.get("password") or "")
expected_user = repo.get_setting("api_user") or ""
expected_pass = repo.get_setting("api_pass") or ""
user_ok = _secrets.compare_digest(username, expected_user)
pass_ok = _secrets.compare_digest(password, expected_pass)
if user_ok and pass_ok and expected_user:
resp = RedirectResponse(next or "/", status_code=303)
set_session_cookie(resp, username)
return resp
return _templates.TemplateResponse(
request, "login.html",
_ctx(next=next, error="Invalid credentials"),
status_code=401,
)
@_public_router.post("/logout")
def logout():
resp = RedirectResponse("/login", status_code=303)
clear_session_cookie(resp)
return resp
# ---------------------------------------------------------------------------
# Modules — home page
# ---------------------------------------------------------------------------

74
pipekit/web/auth.py Normal file
View File

@ -0,0 +1,74 @@
"""Session-cookie auth for the web UI.
Uses itsdangerous to sign a short-lived cookie storing the username.
Reuses the same api_user / api_pass credentials and api_auth_enabled
flag as the JSON API no extra config needed.
Enable with:
api_auth_enabled: true # in config.yaml
pipekit set-password <username>
"""
from __future__ import annotations
import secrets
from fastapi import Request, Response
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from .. import repo
from ..config import get_config
_COOKIE = "pk_session"
_MAX_AGE = 8 * 3600 # 8 hours
def auth_enabled() -> bool:
return bool(get_config().get("api_auth_enabled", False))
def _signer() -> URLSafeTimedSerializer:
secret = repo.get_setting("web_session_secret")
if not secret:
secret = secrets.token_hex(32)
repo.set_setting("web_session_secret", secret)
return URLSafeTimedSerializer(secret, salt="pipekit-web")
def get_session_user(request: Request) -> str | None:
token = request.cookies.get(_COOKIE)
if not token:
return None
try:
return _signer().loads(token, max_age=_MAX_AGE)
except (BadSignature, SignatureExpired):
return None
def set_session_cookie(response: Response, username: str) -> None:
token = _signer().dumps(username)
response.set_cookie(_COOKIE, token, httponly=True, samesite="lax", max_age=_MAX_AGE)
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(_COOKIE, httponly=True, samesite="lax")
class NotAuthenticated(Exception):
"""Raised by require_web_auth; caught by app-level handler → redirect to /login."""
def __init__(self, next_url: str = "/"):
self.next_url = next_url
def require_web_auth(request: Request) -> str | None:
"""FastAPI dependency for protected web routes.
No-ops when api_auth_enabled is false. Otherwise redirects to /login
via NotAuthenticated if there is no valid session cookie.
"""
if not auth_enabled():
return None
user = get_session_user(request)
if not user:
raise NotAuthenticated(next_url=str(request.url.path))
return user

View File

@ -16,7 +16,17 @@
<a href="/groups" class="{% if section == 'groups' %}active{% endif %}">Groups</a>
<a href="/runs" class="{% if section == 'runs' %}active{% endif %}">Runs</a>
</nav>
<span class="right">v{{ version }} &middot; <a href="/docs">API docs</a></span>
<span class="right">
{% if request.state.current_user %}
{{ request.state.current_user }}
&middot;
<form method="post" action="/logout" style="display:inline;margin:0">
<button type="submit" class="ghost" style="font-size:12px;padding:0.1rem 0.3rem;border:none;color:var(--text-muted)">logout</button>
</form>
&middot;
{% endif %}
v{{ version }} &middot; <a href="/docs">API docs</a>
</span>
</header>
<main>
{% if flash %}

View File

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block title %}Login — Pipekit{% endblock %}
{% block content %}
<div style="max-width:22rem;margin:4rem auto">
<div class="panel">
<header>Login</header>
<div class="body">
{% if error %}
<div class="flash err" style="margin-bottom:0.8rem">{{ error }}</div>
{% endif %}
<form method="post" action="/login?next={{ next | urlencode }}">
<label class="field" style="grid-template-columns:1fr">
<span>username</span>
<input type="text" name="username" required autofocus>
</label>
<label class="field" style="grid-template-columns:1fr;margin-top:0.5rem">
<span>password</span>
<input type="password" name="password" required>
</label>
<div class="actions" style="justify-content:flex-end;margin-top:1rem">
<button type="submit" class="primary">Sign in</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,4 +1,5 @@
fastapi>=0.115
itsdangerous>=2.1
uvicorn[standard]>=0.30
python-multipart>=0.0.20
jinja2>=3.1