feat: session mode parsing, memory-only cookies, single-user mode
- COOKIE_SECURE env now accepts: 'true' (secure), 'false' (memory-only), 'single_user' (no cookie) - config.parse_cookie_mode() returns (COOKIE_SECURE, SINGLE_USER) tuple for routing - single_user_owner() returns oldest Owner for test/local deployments - session cookie respects mode: Max-Age only in secure mode, dropped for memory-only - base.html renders ephemeral-session and single-user banners per mode - Tests: comprehensive coverage for all three modes with monkeypatch configs - CLAUDE.md + README docs updated Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -115,7 +115,7 @@ on narrow screens or in the add-printer sidebar (`.form-aside`).
|
||||
|-----|---------|---------|
|
||||
| `DATA_DIR` | `/data` | Storage root (DB + drivers + icons) |
|
||||
| `PORT` | `8000` | Server port |
|
||||
| `COOKIE_SECURE` | `true` | Owner-session cookie `Secure` flag. Set `false` for plain-HTTP serving or the browser drops the cookie and a new Owner is created on every request. `false` also makes the cookie **memory-only** (no `Max-Age`) — see below. |
|
||||
| `COOKIE_SECURE` | `true` | Three-way session mode, parsed by `config.parse_cookie_mode()` into `(COOKIE_SECURE, SINGLE_USER)`: `true` = Secure + 10-year cookie; `false` = plain-HTTP serving (browser drops a Secure cookie → new Owner per request), cookie becomes **memory-only** (no `Max-Age`); `single_user` (or `single-user`/`single`) = no cookie at all, one shared Owner — see below. |
|
||||
|
||||
`COOKIE_SECURE=false` degrades the session instead of weakening the credential:
|
||||
`services/session.cookie_kwargs()` drops `max_age`, so the browser holds the
|
||||
@@ -125,4 +125,18 @@ cookie-setting call sites (the middleware and `POST /session/restore`) must go
|
||||
through `cookie_kwargs()`. `request.state.ephemeral_session` mirrors the flag,
|
||||
and `base.html` renders the `#ephemeral-session-warning` banner plus an extra
|
||||
paragraph in the onboarding modal off it. Changing this touches
|
||||
`tests/test_session.py::test_insecure_mode_*` / `test_secure_mode_*`.
|
||||
`tests/test_session.py::test_insecure_mode_*` / `test_secure_mode_*`.
|
||||
|
||||
`COOKIE_SECURE=single_user` (`cfg.SINGLE_USER`) removes sessions for test boxes
|
||||
and single-person local prod: the middleware never reads or sets a cookie and
|
||||
returns `services/session.single_user_owner()` — the **oldest** `Owner` row,
|
||||
created on demand — so a deployment switched over from cookie mode keeps the
|
||||
printers it already had and no second row is ever minted. `request.state.owner`
|
||||
is still what every route filters on, so per-owner query code is unchanged.
|
||||
`request.state.single_user` gates the sidebar "This session" menu and the
|
||||
`#single-user-notice` banner in `base.html`; `is_new_owner`/`ephemeral_session`
|
||||
are forced `False` (no onboarding modal, no memory-only warning). All three
|
||||
`/session/*` routes 404 via `api/session._require_cookie_sessions()` — a key
|
||||
can't re-point a cookie that isn't read, and downloading one would leak the
|
||||
shared owner's bearer credential for a later switch back to cookie mode. Tests:
|
||||
`test_session.py::test_single_user_*` + `test_parse_cookie_mode_*`.
|
||||
@@ -52,7 +52,7 @@ Set these under `environment:` in `docker-compose.yml`.
|
||||
|------------------|---------|--------------------------------------------------|
|
||||
| `DATA_DIR` | `/data` | Storage root for the SQLite DB, drivers and icons. Should map to the `imptune_data` volume. |
|
||||
| `PORT` | `8000` | Port the server listens on inside the container. |
|
||||
| `COOKIE_SECURE` | `true` | `Secure` flag on the session cookie. Set to `false` when the app is reached over plain HTTP — see below. |
|
||||
| `COOKIE_SECURE` | `true` | Session mode: `true` (Secure cookie), `false` (plain-HTTP cookie, memory-only) or `single_user` (no cookie, one shared store) — see below. |
|
||||
|
||||
### `COOKIE_SECURE` and HTTPS
|
||||
|
||||
@@ -91,3 +91,34 @@ export DATA_DIR=/tmp/imptune_data
|
||||
export COOKIE_SECURE=false
|
||||
uvicorn imptune.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
### `COOKIE_SECURE=single_user` — no sessions at all
|
||||
|
||||
For a test box or a local deployment used by one person, sessions are pure
|
||||
friction. `COOKIE_SECURE=single_user` (also accepted: `single-user`, `single`)
|
||||
drops them:
|
||||
|
||||
- No cookie is read or set. Every request — every browser, every device, curl —
|
||||
resolves to **one shared owner**, so all printers, print defaults and clients
|
||||
are simply "the server's".
|
||||
- The onboarding modal, the memory-only warning, the "This session" sidebar menu
|
||||
and both `/session/*` key routes disappear (the routes return `404`). There is
|
||||
no backup key to lose, and none to hand out.
|
||||
- Every page shows a banner stating that whoever reaches the app sees the same
|
||||
data.
|
||||
|
||||
**There is no isolation left in this mode**, so put it only where reaching the
|
||||
app is already the permission — localhost, or a network you trust. Switching an
|
||||
existing deployment over adopts the oldest existing owner, so printers saved
|
||||
under a cookie stay visible; switching back re-enables cookie scoping and hands
|
||||
new browsers a fresh empty session (that same data is then reachable only with
|
||||
its key, which single-user mode never printed — download a backup key *before*
|
||||
switching if you may switch back).
|
||||
|
||||
```yaml
|
||||
services:
|
||||
imptune:
|
||||
environment:
|
||||
- DATA_DIR=/data
|
||||
- COOKIE_SECURE=single_user
|
||||
```
|
||||
|
||||
@@ -17,6 +17,9 @@ services:
|
||||
# drops it and every request mints a new empty Owner. With `false` the
|
||||
# session works but lives only until the browser closes, and the UI warns.
|
||||
# - COOKIE_SECURE=false
|
||||
# Or drop sessions entirely — no cookie, one shared store for everyone who
|
||||
# can reach the app. Test boxes / single-person local prod only.
|
||||
# - COOKIE_SECURE=single_user
|
||||
|
||||
volumes:
|
||||
imptune_data:
|
||||
+16
-1
@@ -3,10 +3,11 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi import APIRouter, Form, HTTPException, 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_NAME, cookie_kwargs, is_same_origin
|
||||
|
||||
@@ -17,9 +18,21 @@ templates = Jinja2Templates(
|
||||
)
|
||||
|
||||
|
||||
def _require_cookie_sessions() -> None:
|
||||
"""404 these routes in single-user mode — keys have no meaning without a cookie.
|
||||
|
||||
Restoring one could not re-point anything, and downloading one would hand
|
||||
out the shared owner's bearer key, which turns into a live credential the
|
||||
moment the deployment is switched back to a cookie-scoped mode.
|
||||
"""
|
||||
if cfg.SINGLE_USER:
|
||||
raise HTTPException(status_code=404, detail="Session keys are disabled in single-user mode")
|
||||
|
||||
|
||||
@router.get("/key/download")
|
||||
def download_key(request: Request) -> PlainTextResponse:
|
||||
"""Mark the current owner permanent and hand back its key as a backup file."""
|
||||
_require_cookie_sessions()
|
||||
owner: Owner = request.state.owner
|
||||
if not owner.is_permanent:
|
||||
owner.is_permanent = True
|
||||
@@ -33,6 +46,7 @@ def download_key(request: Request) -> PlainTextResponse:
|
||||
|
||||
@router.get("/restore", response_class=HTMLResponse)
|
||||
def restore_page(request: Request, error: str = "") -> HTMLResponse:
|
||||
_require_cookie_sessions()
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="session_restore.html",
|
||||
@@ -43,6 +57,7 @@ def restore_page(request: Request, error: str = "") -> HTMLResponse:
|
||||
@router.post("/restore")
|
||||
def restore_session(request: Request, key: str = Form(...)):
|
||||
"""Re-associate this browser with a previously downloaded backup key."""
|
||||
_require_cookie_sessions()
|
||||
if not is_same_origin(request):
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
|
||||
+22
-2
@@ -7,8 +7,28 @@ 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"
|
||||
|
||||
_SINGLE_USER_VALUES = frozenset({"single_user", "single-user", "singleuser", "single"})
|
||||
|
||||
|
||||
def parse_cookie_mode(raw: str) -> tuple[bool, bool]:
|
||||
"""Read `COOKIE_SECURE` as a three-way mode → `(cookie_secure, single_user)`.
|
||||
|
||||
- `true` (default): cookie-scoped Owners, `Secure` + persistent cookie.
|
||||
- `false`: cookie-scoped Owners over plain HTTP, memory-only cookie —
|
||||
browsers drop `Secure` cookies on HTTP, so the flag has to come off.
|
||||
- `single_user`: no cookie at all. Every request shares one Owner, so a
|
||||
test box or a local single-person deployment needs no session plumbing.
|
||||
Anyone who can reach the app gets that data — there is no separation
|
||||
left to enforce, which is the point.
|
||||
"""
|
||||
value = raw.strip().lower()
|
||||
if value in _SINGLE_USER_VALUES:
|
||||
return False, True
|
||||
return value != "false", False
|
||||
|
||||
|
||||
COOKIE_SECURE, SINGLE_USER = parse_cookie_mode(os.environ.get("COOKIE_SECURE", "true"))
|
||||
|
||||
DB_PATH = str(Path(DATA_DIR) / "imptune.db")
|
||||
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
|
||||
|
||||
@@ -45,6 +45,21 @@ def cookie_kwargs() -> dict:
|
||||
return kwargs
|
||||
|
||||
|
||||
def single_user_owner() -> Owner:
|
||||
"""The one Owner every request shares under `COOKIE_SECURE=single_user`.
|
||||
|
||||
No cookie is read, so the owner has to be findable from the DB alone: the
|
||||
oldest row wins. That way a deployment that used to run cookie-scoped keeps
|
||||
the printers it already had instead of waking up empty, and repeat requests
|
||||
never mint a second row. It is marked permanent so nothing treats it as a
|
||||
throwaway first-visit session.
|
||||
"""
|
||||
owner = Owner.select().order_by(Owner.id).first()
|
||||
if owner is None:
|
||||
owner = Owner.create(key=generate_key(), is_permanent=True)
|
||||
return owner
|
||||
|
||||
|
||||
def _request_hosts(request: Request) -> set[str]:
|
||||
"""Every host spelling that legitimately identifies this deployment."""
|
||||
hosts = {request.url.netloc}
|
||||
@@ -98,6 +113,16 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware):
|
||||
if path in _UNSCOPED_PATHS or path.startswith(_UNSCOPED_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
if cfg.SINGLE_USER:
|
||||
# No cookie is read and none is set: identity comes from the DB, so
|
||||
# onboarding ("keep your printers?") and the ephemeral-cookie
|
||||
# warning are both meaningless here.
|
||||
request.state.owner = single_user_owner()
|
||||
request.state.is_new_owner = False
|
||||
request.state.ephemeral_session = False
|
||||
request.state.single_user = True
|
||||
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
|
||||
@@ -106,6 +131,7 @@ class OwnerSessionMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
request.state.owner = owner
|
||||
request.state.is_new_owner = is_new
|
||||
request.state.single_user = False
|
||||
# Templates warn about it; see cookie_kwargs().
|
||||
request.state.ephemeral_session = not cfg.COOKIE_SECURE
|
||||
|
||||
|
||||
+11
-6
@@ -1203,16 +1203,20 @@ div.error > p {
|
||||
|
||||
.error p, .notice p { margin: 0; }
|
||||
|
||||
/* Memory-only-session banner (COOKIE_SECURE=false): icon, message, and the
|
||||
escape hatch on one line, stacking on narrow screens. */
|
||||
.notice.session-ephemeral {
|
||||
/* Session-mode banners — memory-only (COOKIE_SECURE=false) and single-user
|
||||
(COOKIE_SECURE=single_user): icon, message, and the escape hatch on one
|
||||
line, stacking on narrow screens. */
|
||||
.notice.session-ephemeral,
|
||||
.notice.session-single-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .6rem;
|
||||
}
|
||||
|
||||
.notice.session-ephemeral p { flex: 1; min-width: 12rem; }
|
||||
.notice.session-ephemeral .ico { flex: none; }
|
||||
.notice.session-ephemeral p,
|
||||
.notice.session-single-user p { flex: 1; min-width: 12rem; }
|
||||
.notice.session-ephemeral .ico,
|
||||
.notice.session-single-user .ico { flex: none; }
|
||||
|
||||
.notice.session-ephemeral .btn {
|
||||
flex: none;
|
||||
@@ -1226,7 +1230,8 @@ div.error > p {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.notice.session-ephemeral { flex-wrap: wrap; }
|
||||
.notice.session-ephemeral,
|
||||
.notice.session-single-user { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
.notice details { margin: .4rem 0 0; }
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
session_restore_key: 'Restaurer depuis une clé',
|
||||
session_note: 'Vos imprimantes sont liées à ce navigateur. La clé de secours les récupère ailleurs.',
|
||||
ephemeral_warning: 'Cette session ne dure que tant que ce navigateur reste ouvert : vos imprimantes et réglages sont bien enregistrés, mais le lien vers eux est perdu à la fermeture. Téléchargez votre clé de secours pour les conserver.',
|
||||
single_user_notice: 'Mode mono-utilisateur : tout est enregistré sur le serveur sans cookie de session, et toute personne qui accède à cette application voit les mêmes imprimantes.',
|
||||
open_menu: 'Ouvrir le menu',
|
||||
// Dashboard
|
||||
dashboard_intro: 'Trois étapes du fichier ZIP au paquet déployable.',
|
||||
@@ -277,6 +278,7 @@
|
||||
session_restore_key: 'Restore from a key',
|
||||
session_note: 'Your printers live in this browser. The backup key brings them back elsewhere.',
|
||||
ephemeral_warning: 'This session lasts only while this browser stays open — your printers and configs are saved, but the link to them is lost when you close it. Download your backup key to keep them.',
|
||||
single_user_notice: 'Single-user mode: everything is stored on the server without a session cookie, and anyone who can reach this app sees the same printers.',
|
||||
open_menu: 'Open menu',
|
||||
// Dashboard
|
||||
dashboard_intro: 'Three steps from driver ZIP to deployable package.',
|
||||
@@ -459,6 +461,8 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{# Single-user mode has no cookie and no keys — the whole menu would 404. #}
|
||||
{% if not request.state.single_user %}
|
||||
<div class="sidebar-foot">
|
||||
<details {% if request.url.path == "/session/restore" %}open{% endif %}>
|
||||
<summary>{{ ico.i('key') }}<span x-data x-text="$store.i18n.t('session_menu')">This session</span></summary>
|
||||
@@ -469,6 +473,7 @@
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<div class="main-wrapper">
|
||||
@@ -497,6 +502,13 @@
|
||||
</header>
|
||||
|
||||
<main class="main-content">
|
||||
{% if request.state.single_user %}
|
||||
{# COOKIE_SECURE=single_user: one shared store, no cookie, no isolation. #}
|
||||
<div class="notice session-single-user" id="single-user-notice">
|
||||
{{ ico.i('key', 16) }}
|
||||
<p x-data x-text="$store.i18n.t('single_user_notice')">Single-user mode: everything is stored on the server without a session cookie, and anyone who can reach this app sees the same printers.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if request.state.ephemeral_session %}
|
||||
{# COOKIE_SECURE=false: the owner key is memory-only, so the session
|
||||
dies with the browser window. Say it before work is lost. #}
|
||||
|
||||
@@ -268,3 +268,96 @@ def test_restore_also_issues_a_session_cookie_when_insecure(tmp_data_dir, monkey
|
||||
assert resp.status_code == 303
|
||||
header = _set_cookie_header(resp)
|
||||
assert "Max-Age" not in header and "Expires" not in header
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# COOKIE_SECURE=single_user — no cookie at all, one shared Owner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_cookie_mode_reads_three_modes():
|
||||
from imptune.config import parse_cookie_mode
|
||||
|
||||
assert parse_cookie_mode("true") == (True, False)
|
||||
assert parse_cookie_mode("") == (True, False) # unset-ish → secure default
|
||||
assert parse_cookie_mode("False") == (False, False)
|
||||
for spelling in ("single_user", "single-user", "SingleUser", " single "):
|
||||
assert parse_cookie_mode(spelling) == (False, True)
|
||||
|
||||
|
||||
def _single_user(monkeypatch):
|
||||
import imptune.config as cfg
|
||||
|
||||
monkeypatch.setattr(cfg, "SINGLE_USER", True)
|
||||
monkeypatch.setattr(cfg, "COOKIE_SECURE", False)
|
||||
|
||||
|
||||
def test_single_user_mode_sets_no_cookie_and_skips_onboarding(tmp_data_dir, monkeypatch):
|
||||
from imptune.main import app
|
||||
|
||||
_single_user(monkeypatch)
|
||||
with TestClient(app) as fresh:
|
||||
resp = fresh.get("/")
|
||||
assert resp.status_code == 200
|
||||
assert COOKIE_NAME not in fresh.cookies
|
||||
assert not [v for k, v in resp.headers.multi_items() if k.lower() == "set-cookie"]
|
||||
assert "session-choice-modal" not in resp.text
|
||||
assert "ephemeral-session-warning" not in resp.text
|
||||
assert "single-user-notice" in resp.text
|
||||
|
||||
|
||||
def test_single_user_mode_shares_data_across_browsers(tmp_data_dir, monkeypatch):
|
||||
"""The point of the mode: a cookie-less client sees the same printers."""
|
||||
from imptune.main import app
|
||||
|
||||
_single_user(monkeypatch)
|
||||
with TestClient(app) as client_a, TestClient(app) as client_b:
|
||||
client_a.post(
|
||||
"/printers",
|
||||
data={"name": "Shared Printer", "ip_address": "10.0.7.1", "port_name": "IP_S"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert "Shared Printer" in client_b.get("/printers").text
|
||||
|
||||
|
||||
def test_single_user_mode_creates_exactly_one_owner(tmp_data_dir, monkeypatch):
|
||||
from imptune.db.models import Owner
|
||||
from imptune.main import app
|
||||
|
||||
_single_user(monkeypatch)
|
||||
with TestClient(app) as fresh:
|
||||
for _ in range(4):
|
||||
assert fresh.get("/printers").status_code == 200
|
||||
assert Owner.select().count() == 1
|
||||
|
||||
|
||||
def test_single_user_mode_adopts_the_existing_owner(tmp_data_dir, monkeypatch):
|
||||
"""Switching a cookie-scoped deployment over must not hide its printers."""
|
||||
from imptune.main import app
|
||||
|
||||
with TestClient(app) as cookie_client:
|
||||
cookie_client.post(
|
||||
"/printers",
|
||||
data={"name": "Pre-switch Printer", "ip_address": "10.0.7.2", "port_name": "IP_T"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
_single_user(monkeypatch)
|
||||
with TestClient(app) as after_switch:
|
||||
assert "Pre-switch Printer" in after_switch.get("/printers").text
|
||||
|
||||
|
||||
def test_single_user_mode_disables_session_key_routes(tmp_data_dir, monkeypatch):
|
||||
"""No cookie to re-point, and the shared owner's key must not leak."""
|
||||
from imptune.main import app
|
||||
|
||||
_single_user(monkeypatch)
|
||||
with TestClient(app) as fresh:
|
||||
assert fresh.get("/session/key/download").status_code == 404
|
||||
assert fresh.get("/session/restore").status_code == 404
|
||||
assert fresh.post(
|
||||
"/session/restore",
|
||||
data={"key": "anything"},
|
||||
headers={"origin": "http://testserver"},
|
||||
).status_code == 404
|
||||
assert "/session/key/download" not in fresh.get("/printers").text
|
||||
|
||||
Reference in New Issue
Block a user