diff --git a/config.yaml b/config.yaml index 6255896..8890113 100644 --- a/config.yaml +++ b/config.yaml @@ -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 diff --git a/pipekit/web/app.py b/pipekit/web/app.py index 2132c65..c2fe9fe 100644 --- a/pipekit/web/app.py +++ b/pipekit/web/app.py @@ -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 # --------------------------------------------------------------------------- diff --git a/pipekit/web/auth.py b/pipekit/web/auth.py new file mode 100644 index 0000000..868a0d5 --- /dev/null +++ b/pipekit/web/auth.py @@ -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 +""" + +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 diff --git a/pipekit/web/templates/base.html b/pipekit/web/templates/base.html index 9a3c6ea..9fbb272 100644 --- a/pipekit/web/templates/base.html +++ b/pipekit/web/templates/base.html @@ -16,7 +16,17 @@ Groups Runs - v{{ version }} · API docs + + {% if request.state.current_user %} + {{ request.state.current_user }} + · +
+ +
+ · + {% endif %} + v{{ version }} · API docs +
{% if flash %} diff --git a/pipekit/web/templates/login.html b/pipekit/web/templates/login.html new file mode 100644 index 0000000..5f736d1 --- /dev/null +++ b/pipekit/web/templates/login.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Login — Pipekit{% endblock %} + +{% block content %} +
+
+
Login
+
+ {% if error %} +
{{ error }}
+ {% endif %} +
+ + +
+ +
+
+
+
+
+{% endblock %} diff --git a/requirements.txt b/requirements.txt index b997495..d40d415 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ fastapi>=0.115 +itsdangerous>=2.1 uvicorn[standard]>=0.30 python-multipart>=0.0.20 jinja2>=3.1