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:
parent
583dc16c9b
commit
31135cf5be
@ -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
|
||||
|
||||
@ -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
74
pipekit/web/auth.py
Normal 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
|
||||
@ -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 }} · <a href="/docs">API docs</a></span>
|
||||
<span class="right">
|
||||
{% if request.state.current_user %}
|
||||
{{ request.state.current_user }}
|
||||
·
|
||||
<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>
|
||||
·
|
||||
{% endif %}
|
||||
v{{ version }} · <a href="/docs">API docs</a>
|
||||
</span>
|
||||
</header>
|
||||
<main>
|
||||
{% if flash %}
|
||||
|
||||
28
pipekit/web/templates/login.html
Normal file
28
pipekit/web/templates/login.html
Normal 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 %}
|
||||
@ -1,4 +1,5 @@
|
||||
fastapi>=0.115
|
||||
itsdangerous>=2.1
|
||||
uvicorn[standard]>=0.30
|
||||
python-multipart>=0.0.20
|
||||
jinja2>=3.1
|
||||
|
||||
Loading…
Reference in New Issue
Block a user