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
|
database: /opt/pipekit/pipekit.db
|
||||||
|
api_auth_enabled: true
|
||||||
jrunner_path: /usr/local/bin/jrunner
|
jrunner_path: /usr/local/bin/jrunner
|
||||||
driver_dir: /opt/pipekit/drivers/
|
driver_dir: /opt/pipekit/drivers/
|
||||||
api_host: 0.0.0.0
|
api_host: 0.0.0.0
|
||||||
|
|||||||
@ -15,13 +15,19 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlencode
|
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.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
|
||||||
from .. import __version__, drivers, engine, jrunner, repo
|
from .. import __version__, drivers, engine, jrunner, repo
|
||||||
from ..config import get_config
|
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 import watermark
|
||||||
from ..engine.merge import MergeError, build_merge_sql
|
from ..engine.merge import MergeError, build_merge_sql
|
||||||
|
|
||||||
@ -54,19 +60,74 @@ _templates.env.filters["duration"] = _fmt_duration
|
|||||||
_templates.env.filters["localtime"] = _fmt_localtime
|
_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:
|
def mount_web(app: FastAPI) -> None:
|
||||||
"""Attach HTML pages + /static onto a FastAPI app."""
|
"""Attach HTML pages + /static onto a FastAPI app."""
|
||||||
app.mount("/static", StaticFiles(directory=_WEB_DIR / "static"), name="static")
|
app.mount("/static", StaticFiles(directory=_WEB_DIR / "static"), name="static")
|
||||||
|
app.add_middleware(_SessionMiddleware)
|
||||||
|
app.include_router(_public_router)
|
||||||
app.include_router(_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:
|
def _ctx(**extra) -> dict:
|
||||||
return {"version": __version__, "flash": None, **extra}
|
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
|
# 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="/groups" class="{% if section == 'groups' %}active{% endif %}">Groups</a>
|
||||||
<a href="/runs" class="{% if section == 'runs' %}active{% endif %}">Runs</a>
|
<a href="/runs" class="{% if section == 'runs' %}active{% endif %}">Runs</a>
|
||||||
</nav>
|
</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>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
{% if flash %}
|
{% 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
|
fastapi>=0.115
|
||||||
|
itsdangerous>=2.1
|
||||||
uvicorn[standard]>=0.30
|
uvicorn[standard]>=0.30
|
||||||
python-multipart>=0.0.20
|
python-multipart>=0.0.20
|
||||||
jinja2>=3.1
|
jinja2>=3.1
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user