feat(session): per-owner printer/config storage via cookie-scoped bearer key
Printers and groups (Client) are now scoped to an Owner identified by an opaque
bearer key (secrets.token_urlsafe(32)) stored in an httponly cookie, defaulting
to temporary. First-visit modal offers backup-key download (marks permanent) or
temporary-only choice. /session/restore re-attaches a fresh browser to a saved
key. Every printer-facing route enforces ownership (404 on mismatch, not just
filtering) since printer IDs are sequential ints. Drivers stay global/shared.
On upgrade, pre-existing printer/client rows backfill to a synthetic legacy Owner;
its key is written to {DATA_DIR}/legacy_owner_key.txt for manual restore.
SECURITY: Added Origin/Referer same-origin check on POST /session/restore to
block login-CSRF/session-fixation attacks (cross-site form POST can't re-point
victim's cookie at attacker's Owner without hitting that check first).
Tests: 140 pass (2 deselected: pre-existing locale-flaky, unrelated to this change).
Verified live: modal on first visit, isolation between browsers, backup-key
download and restore flow work end-to-end.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,7 @@ def _error_response(message: str, status_code: int = 400) -> HTMLResponse:
|
||||
|
||||
def _render_client_list(request: Request) -> HTMLResponse:
|
||||
"""Render the client list partial for HTMX swap."""
|
||||
clients = list(Client.select().order_by(Client.name))
|
||||
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/client_list.html",
|
||||
@@ -47,7 +47,7 @@ def create_client(request: Request, name: str = Form(...)) -> HTMLResponse:
|
||||
return _error_response("Client name is required.")
|
||||
|
||||
try:
|
||||
Client.create(name=name)
|
||||
Client.create(name=name, owner=request.state.owner)
|
||||
except IntegrityError:
|
||||
return _error_response(f"Client '{name}' already exists.", status_code=409)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import hashlib
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, UploadFile
|
||||
from fastapi import APIRouter, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse
|
||||
from PIL import Image
|
||||
|
||||
@@ -18,7 +18,7 @@ MAX_ICON_BYTES = 750 * 1024 # 750 KB
|
||||
|
||||
|
||||
@router.post("/{printer_id}/icon", response_class=HTMLResponse)
|
||||
def upload_icon(printer_id: int, file: UploadFile) -> HTMLResponse:
|
||||
def upload_icon(request: Request, printer_id: int, file: UploadFile) -> HTMLResponse:
|
||||
"""Accept a printer icon PNG, validate it, store it, and update the Icon record.
|
||||
|
||||
Validation rules:
|
||||
@@ -29,8 +29,10 @@ def upload_icon(printer_id: int, file: UploadFile) -> HTMLResponse:
|
||||
Replaces any previously uploaded icon for this printer.
|
||||
Returns an HTMX-friendly HTML fragment.
|
||||
"""
|
||||
# Check printer exists
|
||||
printer = Printer.get_or_none(Printer.id == printer_id)
|
||||
# Check printer exists and belongs to this owner
|
||||
printer = Printer.get_or_none(
|
||||
(Printer.id == printer_id) & (Printer.owner == request.state.owner)
|
||||
)
|
||||
if printer is None:
|
||||
return HTMLResponse(
|
||||
content="<p>Printer not found.</p>",
|
||||
|
||||
@@ -6,11 +6,11 @@ import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import PlainTextResponse, Response
|
||||
|
||||
import imptune.config as cfg
|
||||
from imptune.db.models import Icon, Printer
|
||||
from imptune.db.models import Icon, Owner, Printer
|
||||
from imptune.generators.intunewin_builder import build_intunewin
|
||||
from imptune.generators.script_generator import render_detect, render_install, render_uninstall
|
||||
from imptune.storage.driver_store import DriverStore
|
||||
@@ -18,9 +18,9 @@ from imptune.storage.driver_store import DriverStore
|
||||
router = APIRouter(prefix="/printers")
|
||||
|
||||
|
||||
def _get_printer_and_driver(printer_id: int):
|
||||
"""Fetch printer and validate driver — returns (printer, driver, driver_name) or PlainTextResponse error."""
|
||||
printer = Printer.get_or_none(Printer.id == printer_id)
|
||||
def _get_printer_and_driver(printer_id: int, owner: Owner):
|
||||
"""Fetch printer (scoped to owner) and validate driver — returns (printer, driver, driver_name) or PlainTextResponse error."""
|
||||
printer = Printer.get_or_none((Printer.id == printer_id) & (Printer.owner == owner))
|
||||
if printer is None:
|
||||
return None, PlainTextResponse("Printer not found", status_code=404)
|
||||
|
||||
@@ -48,9 +48,9 @@ def _get_driver_zip_path(driver) -> str:
|
||||
|
||||
|
||||
@router.get("/{printer_id}/packages/ninja")
|
||||
def get_ninja_package(printer_id: int):
|
||||
def get_ninja_package(request: Request, printer_id: int):
|
||||
"""Download a NinjaRMM-ready ZIP containing install.ps1 and the driver files."""
|
||||
result, error = _get_printer_and_driver(printer_id)
|
||||
result, error = _get_printer_and_driver(printer_id, request.state.owner)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
@@ -96,9 +96,9 @@ def get_ninja_package(printer_id: int):
|
||||
|
||||
|
||||
@router.get("/{printer_id}/packages/intunewin")
|
||||
def get_intunewin_package(printer_id: int):
|
||||
def get_intunewin_package(request: Request, printer_id: int):
|
||||
"""Download a Microsoft Intune .intunewin deployment package."""
|
||||
result, error = _get_printer_and_driver(printer_id)
|
||||
result, error = _get_printer_and_driver(printer_id, request.state.owner)
|
||||
if error is not None:
|
||||
return error
|
||||
|
||||
|
||||
+17
-10
@@ -17,12 +17,16 @@ templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templa
|
||||
def dashboard(request: Request):
|
||||
from imptune.db.models import Printer
|
||||
|
||||
owner = request.state.owner
|
||||
recent_printers = list(
|
||||
Printer.select().order_by(Printer.created_at.desc()).limit(5)
|
||||
Printer.select()
|
||||
.where(Printer.owner == owner)
|
||||
.order_by(Printer.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
recent_packages = list(
|
||||
Printer.select()
|
||||
.where(Printer.driver.is_null(False))
|
||||
.where((Printer.owner == owner) & Printer.driver.is_null(False))
|
||||
.order_by(Printer.created_at.desc())
|
||||
.limit(5)
|
||||
)
|
||||
@@ -56,9 +60,11 @@ def drivers_page(request: Request):
|
||||
def printers_page(request: Request):
|
||||
from imptune.db.models import Client, Driver, Printer
|
||||
|
||||
owner = request.state.owner
|
||||
query = (
|
||||
Printer.select(Printer, Client)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.where(Printer.owner == owner)
|
||||
.order_by(Client.name, Printer.name)
|
||||
)
|
||||
grouped: dict[str, list] = defaultdict(list)
|
||||
@@ -66,7 +72,7 @@ def printers_page(request: Request):
|
||||
client_name = p.client.name if p.client_id else "Unassigned"
|
||||
grouped[client_name].append(p)
|
||||
|
||||
clients = list(Client.select().order_by(Client.name))
|
||||
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
|
||||
|
||||
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
|
||||
driver_data = []
|
||||
@@ -89,7 +95,7 @@ def printers_page(request: Request):
|
||||
def printers_new_page(request: Request):
|
||||
from imptune.db.models import Client, Driver
|
||||
|
||||
clients = list(Client.select().order_by(Client.name))
|
||||
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
|
||||
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
|
||||
driver_data = [
|
||||
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
|
||||
@@ -111,7 +117,7 @@ def printer_detail(request: Request, printer_id: int):
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.switch(Printer)
|
||||
.join(Driver, JOIN.LEFT_OUTER)
|
||||
.where(Printer.id == printer_id)
|
||||
.where((Printer.id == printer_id) & (Printer.owner == request.state.owner))
|
||||
.first()
|
||||
)
|
||||
if printer is None:
|
||||
@@ -148,7 +154,7 @@ def printer_detail(request: Request, printer_id: int):
|
||||
def clients_page(request: Request):
|
||||
from imptune.db.models import Client
|
||||
|
||||
clients = list(Client.select().order_by(Client.name))
|
||||
clients = list(Client.select().where(Client.owner == request.state.owner).order_by(Client.name))
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="clients.html",
|
||||
@@ -160,7 +166,8 @@ def clients_page(request: Request):
|
||||
def client_detail(request: Request, client_id: int):
|
||||
from imptune.db.models import Client, Driver, Printer
|
||||
|
||||
client = Client.get_or_none(Client.id == client_id)
|
||||
owner = request.state.owner
|
||||
client = Client.get_or_none((Client.id == client_id) & (Client.owner == owner))
|
||||
if client is None:
|
||||
return HTMLResponse(
|
||||
content="<h1>404 Not Found</h1><p>Client not found.</p>",
|
||||
@@ -170,12 +177,12 @@ def client_detail(request: Request, client_id: int):
|
||||
query = (
|
||||
Printer.select(Printer, Client)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.where(Printer.client == client_id)
|
||||
.where((Printer.client == client_id) & (Printer.owner == owner))
|
||||
.order_by(Printer.name)
|
||||
)
|
||||
grouped = {client.name: list(query)}
|
||||
|
||||
clients = list(Client.select().order_by(Client.name))
|
||||
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
|
||||
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
|
||||
driver_data = [
|
||||
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
|
||||
@@ -203,7 +210,7 @@ def packages_page(request: Request):
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.switch(Printer)
|
||||
.join(Driver, JOIN.LEFT_OUTER)
|
||||
.where(Printer.driver.is_null(False))
|
||||
.where((Printer.owner == request.state.owner) & Printer.driver.is_null(False))
|
||||
.order_by(Printer.name)
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
|
||||
+23
-5
@@ -33,9 +33,11 @@ def _render_printer_list(request: Request) -> HTMLResponse:
|
||||
"""Query printers with LEFT JOIN on client and render grouped partial."""
|
||||
import json
|
||||
|
||||
owner = request.state.owner
|
||||
query = (
|
||||
Printer.select(Printer, Client)
|
||||
.join(Client, JOIN.LEFT_OUTER)
|
||||
.where(Printer.owner == owner)
|
||||
.order_by(Client.name, Printer.name)
|
||||
)
|
||||
grouped: dict[str, list[Printer]] = defaultdict(list)
|
||||
@@ -43,7 +45,7 @@ def _render_printer_list(request: Request) -> HTMLResponse:
|
||||
client_name = p.client.name if p.client_id else "Unassigned"
|
||||
grouped[client_name].append(p)
|
||||
|
||||
clients = list(Client.select().order_by(Client.name))
|
||||
clients = list(Client.select().where(Client.owner == owner).order_by(Client.name))
|
||||
all_drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
|
||||
driver_data = [
|
||||
{"driver": d, "names": json.loads(d.driver_desc) if d.driver_desc else []}
|
||||
@@ -94,8 +96,13 @@ def create_printer(
|
||||
color_mode_bool = color_mode == "on"
|
||||
collate_bool = collate == "on"
|
||||
|
||||
# Resolve optional FK IDs
|
||||
owner = request.state.owner
|
||||
|
||||
# Resolve optional FK IDs — client must belong to this owner
|
||||
client_fk = int(client_id) if client_id.strip() else None
|
||||
if client_fk is not None:
|
||||
if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None:
|
||||
return _error_response(f"Client {client_fk} not found.", status_code=404)
|
||||
driver_fk = int(driver_id) if driver_id.strip() else None
|
||||
|
||||
Printer.create(
|
||||
@@ -106,6 +113,7 @@ def create_printer(
|
||||
color_mode=color_mode_bool,
|
||||
paper_size=paper_size,
|
||||
collate=collate_bool,
|
||||
owner=owner,
|
||||
client=client_fk,
|
||||
driver=driver_fk,
|
||||
)
|
||||
@@ -116,7 +124,11 @@ def create_printer(
|
||||
@router.delete("/{printer_id}", response_class=HTMLResponse)
|
||||
def delete_printer(request: Request, printer_id: int) -> HTMLResponse:
|
||||
"""Delete a printer by ID. Returns updated printer list partial."""
|
||||
deleted = Printer.delete().where(Printer.id == printer_id).execute()
|
||||
deleted = (
|
||||
Printer.delete()
|
||||
.where((Printer.id == printer_id) & (Printer.owner == request.state.owner))
|
||||
.execute()
|
||||
)
|
||||
if not deleted:
|
||||
return _error_response(f"Printer {printer_id} not found.", status_code=404)
|
||||
|
||||
@@ -140,7 +152,8 @@ def update_printer(
|
||||
"""Update an existing printer configuration in-place."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
printer = Printer.get_or_none(Printer.id == printer_id)
|
||||
owner = request.state.owner
|
||||
printer = Printer.get_or_none((Printer.id == printer_id) & (Printer.owner == owner))
|
||||
if printer is None:
|
||||
return _error_response(f"Printer {printer_id} not found.", status_code=404)
|
||||
|
||||
@@ -159,6 +172,11 @@ def update_printer(
|
||||
if paper_size not in _VALID_PAPER:
|
||||
return _error_response(f"Invalid paper size: {paper_size}.")
|
||||
|
||||
client_fk = int(client_id) if client_id.strip() else None
|
||||
if client_fk is not None:
|
||||
if Client.get_or_none((Client.id == client_fk) & (Client.owner == owner)) is None:
|
||||
return _error_response(f"Client {client_fk} not found.", status_code=404)
|
||||
|
||||
printer.name = name
|
||||
printer.ip_address = ip_address
|
||||
printer.port_name = port_name
|
||||
@@ -166,7 +184,7 @@ def update_printer(
|
||||
printer.color_mode = color_mode == "on"
|
||||
printer.paper_size = paper_size
|
||||
printer.collate = collate == "on"
|
||||
printer.client = int(client_id) if client_id.strip() else None
|
||||
printer.client = client_fk
|
||||
printer.driver = int(driver_id) if driver_id.strip() else None
|
||||
printer.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
printer.save()
|
||||
|
||||
+23
-23
@@ -1,18 +1,18 @@
|
||||
"""Script download endpoints — generates and serves PowerShell scripts for a printer."""
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from imptune.db.models import Printer
|
||||
from imptune.db.models import Owner, Printer
|
||||
from imptune.generators.script_generator import render_detect, render_install, render_uninstall
|
||||
|
||||
router = APIRouter(prefix="/printers")
|
||||
|
||||
|
||||
def _get_printer_and_driver(printer_id: int):
|
||||
"""Fetch printer and validate driver — returns (printer, driver_name) or PlainTextResponse error."""
|
||||
printer = Printer.get_or_none(Printer.id == printer_id)
|
||||
def _get_printer_and_driver(printer_id: int, owner: Owner):
|
||||
"""Fetch printer (scoped to owner) and validate driver — returns (printer, driver_name) or PlainTextResponse error."""
|
||||
printer = Printer.get_or_none((Printer.id == printer_id) & (Printer.owner == owner))
|
||||
if printer is None:
|
||||
return None, PlainTextResponse("Printer not found", status_code=404)
|
||||
|
||||
@@ -35,8 +35,8 @@ def _get_printer_and_driver(printer_id: int):
|
||||
return (printer, driver, driver_name), None
|
||||
|
||||
|
||||
def _install_response(printer_id: int):
|
||||
result, error = _get_printer_and_driver(printer_id)
|
||||
def _install_response(printer_id: int, owner: Owner):
|
||||
result, error = _get_printer_and_driver(printer_id, owner)
|
||||
if error is not None:
|
||||
return error
|
||||
printer, driver, driver_name = result
|
||||
@@ -57,8 +57,8 @@ def _install_response(printer_id: int):
|
||||
)
|
||||
|
||||
|
||||
def _uninstall_response(printer_id: int):
|
||||
result, error = _get_printer_and_driver(printer_id)
|
||||
def _uninstall_response(printer_id: int, owner: Owner):
|
||||
result, error = _get_printer_and_driver(printer_id, owner)
|
||||
if error is not None:
|
||||
return error
|
||||
printer, driver, driver_name = result
|
||||
@@ -73,8 +73,8 @@ def _uninstall_response(printer_id: int):
|
||||
)
|
||||
|
||||
|
||||
def _detect_response(printer_id: int):
|
||||
result, error = _get_printer_and_driver(printer_id)
|
||||
def _detect_response(printer_id: int, owner: Owner):
|
||||
result, error = _get_printer_and_driver(printer_id, owner)
|
||||
if error is not None:
|
||||
return error
|
||||
printer, driver, driver_name = result
|
||||
@@ -86,36 +86,36 @@ def _detect_response(printer_id: int):
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/install")
|
||||
def get_install_script(printer_id: int):
|
||||
def get_install_script(request: Request, printer_id: int):
|
||||
"""Download the PowerShell install script for a printer."""
|
||||
return _install_response(printer_id)
|
||||
return _install_response(printer_id, request.state.owner)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/install.ps1")
|
||||
def get_install_script_ps1(printer_id: int):
|
||||
def get_install_script_ps1(request: Request, printer_id: int):
|
||||
"""Download the PowerShell install script for a printer (.ps1 alias)."""
|
||||
return _install_response(printer_id)
|
||||
return _install_response(printer_id, request.state.owner)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/uninstall")
|
||||
def get_uninstall_script(printer_id: int):
|
||||
def get_uninstall_script(request: Request, printer_id: int):
|
||||
"""Download the PowerShell uninstall script for a printer."""
|
||||
return _uninstall_response(printer_id)
|
||||
return _uninstall_response(printer_id, request.state.owner)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/uninstall.ps1")
|
||||
def get_uninstall_script_ps1(printer_id: int):
|
||||
def get_uninstall_script_ps1(request: Request, printer_id: int):
|
||||
"""Download the PowerShell uninstall script for a printer (.ps1 alias)."""
|
||||
return _uninstall_response(printer_id)
|
||||
return _uninstall_response(printer_id, request.state.owner)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/detect")
|
||||
def get_detect_script(printer_id: int):
|
||||
def get_detect_script(request: Request, printer_id: int):
|
||||
"""Download the PowerShell detection script for a printer."""
|
||||
return _detect_response(printer_id)
|
||||
return _detect_response(printer_id, request.state.owner)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/scripts/detect.ps1")
|
||||
def get_detect_script_ps1(printer_id: int):
|
||||
def get_detect_script_ps1(request: Request, printer_id: int):
|
||||
"""Download the PowerShell detection script for a printer (.ps1 alias)."""
|
||||
return _detect_response(printer_id)
|
||||
return _detect_response(printer_id, request.state.owner)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Owner session routes — backup-key download and restore-on-new-browser."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
import imptune.config as cfg
|
||||
from imptune.db.models import Owner
|
||||
from imptune.services.session import COOKIE_MAX_AGE, COOKIE_NAME, is_same_origin
|
||||
|
||||
router = APIRouter(prefix="/session")
|
||||
|
||||
templates = Jinja2Templates(
|
||||
directory=str(Path(__file__).parent.parent / "templates")
|
||||
)
|
||||
|
||||
|
||||
@router.get("/key/download")
|
||||
def download_key(request: Request) -> PlainTextResponse:
|
||||
"""Mark the current owner permanent and hand back its key as a backup file."""
|
||||
owner: Owner = request.state.owner
|
||||
if not owner.is_permanent:
|
||||
owner.is_permanent = True
|
||||
owner.save()
|
||||
|
||||
return PlainTextResponse(
|
||||
content=owner.key,
|
||||
headers={"Content-Disposition": 'attachment; filename="imptune-backup-key.txt"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/restore", response_class=HTMLResponse)
|
||||
def restore_page(request: Request, error: str = "") -> HTMLResponse:
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="session_restore.html",
|
||||
context={"error": error},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/restore")
|
||||
def restore_session(request: Request, key: str = Form(...)):
|
||||
"""Re-associate this browser with a previously downloaded backup key."""
|
||||
if not is_same_origin(request):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="session_restore.html",
|
||||
context={"error": "Request rejected — please submit this form directly from this site."},
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
owner = Owner.get_or_none(Owner.key == key.strip())
|
||||
if owner is None:
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="session_restore.html",
|
||||
context={"error": "Key not found."},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
response = RedirectResponse(url="/", status_code=303)
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
owner.key,
|
||||
max_age=COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=cfg.COOKIE_SECURE,
|
||||
)
|
||||
return response
|
||||
@@ -7,6 +7,8 @@ load_dotenv()
|
||||
|
||||
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
||||
PORT = int(os.environ.get("PORT", "8000"))
|
||||
# Set to "false" for local plain-HTTP dev — browsers drop Secure cookies over HTTP.
|
||||
COOKIE_SECURE = os.environ.get("COOKIE_SECURE", "true").lower() != "false"
|
||||
|
||||
DB_PATH = str(Path(DATA_DIR) / "imptune.db")
|
||||
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
|
||||
|
||||
+37
-2
@@ -16,7 +16,7 @@ def init_db() -> None:
|
||||
Closes any existing connection before re-initializing so that test
|
||||
fixtures can monkeypatch DB_PATH between test runs.
|
||||
"""
|
||||
from imptune.db.models import Client, Driver, Printer, Icon
|
||||
from imptune.db.models import Client, Driver, Owner, Printer, Icon
|
||||
|
||||
# Re-read DB_PATH each time so tests can patch imptune.config.DB_PATH
|
||||
import imptune.config as cfg
|
||||
@@ -33,4 +33,39 @@ def init_db() -> None:
|
||||
},
|
||||
)
|
||||
db.connect(reuse_if_open=True)
|
||||
db.create_tables([Client, Driver, Printer, Icon], safe=True)
|
||||
db.create_tables([Owner, Client, Driver, Printer, Icon], safe=True)
|
||||
_migrate_owner_column(cfg.DATA_DIR)
|
||||
|
||||
|
||||
def _migrate_owner_column(data_dir: str) -> None:
|
||||
"""Add owner_id to printer/client if missing, backfilling pre-existing rows.
|
||||
|
||||
Runs against a DB created before per-owner scoping existed. Idempotent:
|
||||
a no-op once the column exists and no rows are left with a NULL owner_id.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from imptune.db.models import Client, Owner, Printer
|
||||
from imptune.services.session import generate_key
|
||||
|
||||
for table in ("printer", "client"):
|
||||
columns = {row[1] for row in db.execute_sql(f"PRAGMA table_info({table})")}
|
||||
if "owner_id" not in columns:
|
||||
db.execute_sql(
|
||||
f"ALTER TABLE {table} ADD COLUMN owner_id INTEGER REFERENCES owner (id)"
|
||||
)
|
||||
|
||||
orphaned = Printer.select().where(Printer.owner.is_null()).count() or Client.select().where(
|
||||
Client.owner.is_null()
|
||||
).count()
|
||||
if not orphaned:
|
||||
return
|
||||
|
||||
legacy_owner = Owner.create(key=generate_key(), is_permanent=True)
|
||||
Printer.update(owner=legacy_owner.id).where(Printer.owner.is_null()).execute()
|
||||
Client.update(owner=legacy_owner.id).where(Client.owner.is_null()).execute()
|
||||
|
||||
key_path = Path(data_dir) / "legacy_owner_key.txt"
|
||||
key_path.write_text(legacy_owner.key, encoding="utf-8")
|
||||
print(f"[imptune] Pre-existing printers/clients migrated to a legacy owner. "
|
||||
f"Restore key written to {key_path} — paste it into /session/restore to reclaim them.")
|
||||
|
||||
+17
-3
@@ -24,14 +24,27 @@ class BaseModel(Model):
|
||||
database = db
|
||||
|
||||
|
||||
class Client(BaseModel):
|
||||
"""Represents a deployment target (AD client / OU)."""
|
||||
class Owner(BaseModel):
|
||||
"""Cookie-scoped identity — no accounts, just an opaque bearer key."""
|
||||
|
||||
name = CharField(unique=True)
|
||||
key = CharField(unique=True, index=True)
|
||||
is_permanent = BooleanField(default=False)
|
||||
created_at = DateTimeField(default=_utcnow)
|
||||
|
||||
class Meta:
|
||||
table_name = "owner"
|
||||
|
||||
|
||||
class Client(BaseModel):
|
||||
"""Represents a deployment target (AD client / OU), scoped to an Owner."""
|
||||
|
||||
name = CharField()
|
||||
owner = ForeignKeyField(Owner, backref="clients")
|
||||
created_at = DateTimeField(default=_utcnow)
|
||||
|
||||
class Meta:
|
||||
table_name = "client"
|
||||
indexes = ((("owner", "name"), True),)
|
||||
|
||||
|
||||
class Driver(BaseModel):
|
||||
@@ -56,6 +69,7 @@ class Printer(BaseModel):
|
||||
name = CharField()
|
||||
ip_address = CharField()
|
||||
port_name = CharField()
|
||||
owner = ForeignKeyField(Owner, backref="printers")
|
||||
client = ForeignKeyField(Client, null=True, backref="printers")
|
||||
driver = ForeignKeyField(Driver, null=True, backref="printers")
|
||||
duplex_mode = CharField(default="OneSided")
|
||||
|
||||
+4
-1
@@ -5,9 +5,10 @@ from pathlib import Path
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from imptune.api import clients, drivers, health, icons, pages, packages, printers, scripts
|
||||
from imptune.api import clients, drivers, health, icons, pages, packages, printers, scripts, session
|
||||
from imptune.config import DATA_DIR, DRIVERS_DIR, ICONS_DIR
|
||||
from imptune.db.database import db, init_db
|
||||
from imptune.services.session import OwnerSessionMiddleware
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -23,6 +24,7 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
|
||||
app = FastAPI(title="ImpTune", lifespan=lifespan)
|
||||
app.add_middleware(OwnerSessionMiddleware)
|
||||
|
||||
# Serve baked-in static assets (pico.min.css, htmx.min.js, alpine.min.js)
|
||||
_static_dir = Path(__file__).parent / "static"
|
||||
@@ -37,3 +39,4 @@ app.include_router(clients.router)
|
||||
app.include_router(scripts.router)
|
||||
app.include_router(packages.router)
|
||||
app.include_router(icons.router)
|
||||
app.include_router(session.router)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Cookie-scoped Owner session — opaque bearer key, no accounts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
|
||||
import imptune.config as cfg
|
||||
from imptune.db.models import Owner
|
||||
|
||||
COOKIE_NAME = "imptune_owner_key"
|
||||
COOKIE_MAX_AGE = 10 * 365 * 24 * 60 * 60 # 10 years
|
||||
|
||||
|
||||
def generate_key() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def is_same_origin(request: Request) -> bool:
|
||||
"""Origin/Referer check — the CSRF guard for /session/restore.
|
||||
|
||||
Restoring a key re-points the cookie at a *different* Owner, so unlike
|
||||
the rest of the app's unprotected POSTs (which only ever mutate the
|
||||
caller's own data), a forged cross-site POST here is a login-CSRF /
|
||||
session-fixation vector: an attacker who knows their own key can force
|
||||
a victim's browser onto the attacker's Owner. Browsers always send
|
||||
Origin (and usually Referer) on form POSTs, same-site or not, so
|
||||
requiring a match — and rejecting when both are absent — blocks a plain
|
||||
auto-submitting HTML form without needing a token.
|
||||
"""
|
||||
expected = f"{request.url.scheme}://{request.url.netloc}"
|
||||
|
||||
origin = request.headers.get("origin")
|
||||
if origin is not None:
|
||||
return origin == expected
|
||||
|
||||
referer = request.headers.get("referer")
|
||||
if referer:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(referer)
|
||||
return f"{parsed.scheme}://{parsed.netloc}" == expected
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class OwnerSessionMiddleware(BaseHTTPMiddleware):
|
||||
"""Resolves request.state.owner from a cookie, creating one on first visit."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.url.path == "/health":
|
||||
return await call_next(request)
|
||||
|
||||
key = request.cookies.get(COOKIE_NAME)
|
||||
owner = Owner.get_or_none(Owner.key == key) if key else None
|
||||
is_new = owner is None
|
||||
if owner is None:
|
||||
owner = Owner.create(key=generate_key(), is_permanent=False)
|
||||
|
||||
request.state.owner = owner
|
||||
request.state.is_new_owner = is_new
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
# Routes like /session/restore intentionally set this cookie themselves
|
||||
# (to a *different* owner than the one this middleware just minted) —
|
||||
# don't clobber that with the auto-provisioned one.
|
||||
route_already_set_cookie = any(
|
||||
header.lower() == b"set-cookie" and value.startswith(f"{COOKIE_NAME}=".encode())
|
||||
for header, value in response.raw_headers
|
||||
)
|
||||
|
||||
if is_new and not route_already_set_cookie:
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
owner.key,
|
||||
max_age=COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=cfg.COOKIE_SECURE,
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -315,6 +315,10 @@
|
||||
<li><a href="/packages" {% if request.url.path == "/packages" %}class="active"{% endif %}
|
||||
x-data x-text="$store.i18n.t('packages')">Packages</a></li>
|
||||
</ul>
|
||||
<ul class="sidebar-nav sidebar-nav-footer">
|
||||
<li><a href="/session/key/download">Download backup key</a></li>
|
||||
<li><a href="/session/restore" {% if request.url.path == "/session/restore" %}class="active"{% endif %}>Restore from backup key</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="main-wrapper">
|
||||
<header class="topbar">
|
||||
@@ -333,6 +337,20 @@
|
||||
</div>
|
||||
</header>
|
||||
<main class="main-content">
|
||||
{% if request.state.is_new_owner %}
|
||||
<dialog open id="session-choice-modal">
|
||||
<article>
|
||||
<h3>Keep your printers?</h3>
|
||||
<p>Your printers, configs, and groups are private to this browser
|
||||
(drivers stay shared with everyone). If you clear your cookies
|
||||
without a backup key, they're gone for good.</p>
|
||||
<footer>
|
||||
<a role="button" href="/session/key/download">Store permanently (download backup key)</a>
|
||||
<button class="secondary" onclick="this.closest('dialog').close()">Keep temporary, this browser only</button>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Restore backup key</h1>
|
||||
|
||||
<p>Paste the key from your <code>imptune-backup-key.txt</code> backup file to
|
||||
recover your printers, configs, and groups on this browser.</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="error"><p>{{ error }}</p></div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/session/restore">
|
||||
<label>
|
||||
Backup key
|
||||
<input type="text" name="key" placeholder="paste your key here" required autofocus>
|
||||
</label>
|
||||
<button type="submit">Restore</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user