From 470823b3477c46f9e5587db15f349bdecf54fefb Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Tue, 21 Apr 2026 13:01:17 +0200 Subject: [PATCH] feat(auth): login-guard the Furtka UI with a cookie session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-admin, one-password model — all of /apps, /api/*, /, and /settings/ now require a signed-in session. Passwords are werkzeug PBKDF2-hashed in /var/lib/furtka/users.json (mode 0600, atomic write via the same .tmp+chmod+rename dance installer.write_env uses). Sessions are secrets.token_urlsafe(32) tokens held in a module-level SessionStore dict (thread-safe lock included for when we swap to ThreadingHTTPServer). Cookies are HttpOnly, SameSite=Strict, and Path=/, with Secure set when X-Forwarded-Proto from Caddy says HTTPS. Two bootstrap paths: * Fresh install — webinstaller step-1 collects Linux user + password, the chroot post-install step hashes the password and writes users.json on the target partition. First browser visit lands on /login with the account already present. * Upgrade from 26.10-alpha — no users.json yet, so /login detects setup_needed() and renders a first-run setup form. POST creates the admin and immediately logs in. POST /logout revokes the server session and clears the cookie. Unauthenticated HTML requests 302 to /login; unauthenticated API requests 401 JSON so fetch() callers see a clean error. A sleep(0.5) on failed logins is the brute-force speed bump on top of werkzeug's ~600k-iter PBKDF2. Caddyfile gains /login* and /logout* handle blocks in the shared furtka_routes snippet so both :80 and the HTTPS hostname block forward the auth endpoints to localhost:7000. Without this Caddy would 404 from the static file server. Test surface: * tests/test_auth.py (new, 19 cases): hash roundtrip, users.json I/O, session create/lookup/expire/revoke. * tests/test_api.py: new admin_session fixture; existing HTTP tests updated to send the cookie; new tests cover login setup, login success, wrong-password 401, logout revocation, and the guard's 302/401 split. * tests/test_webinstaller_assets.py: new case that unpacks the users.json _write_file_cmd body and verifies the werkzeug hash round-trips against the step-1 password. Bumped version to 26.11-alpha and rolled CHANGELOG. Also folded in the ruff-format fix that was pending from 26.10-alpha's lint red. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 43 +++- assets/Caddyfile | 6 + assets/www/style.css | 13 +- furtka/api.py | 270 +++++++++++++++++++++++- furtka/auth.py | 175 ++++++++++++++++ furtka/paths.py | 9 + pyproject.toml | 2 +- tests/test_api.py | 328 +++++++++++++++++++++++++++--- tests/test_auth.py | 152 ++++++++++++++ tests/test_webinstaller_assets.py | 27 ++- webinstaller/app.py | 39 +++- 11 files changed, 1020 insertions(+), 44 deletions(-) create mode 100644 furtka/auth.py create mode 100644 tests/test_auth.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 309f744..172ba7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r ## [Unreleased] +## [26.11-alpha] - 2026-04-21 + +### Added + +- **Login-auth for the Furtka web UI.** Every `/apps`, `/api/*`, `/`, + and `/settings/` route now requires a signed-in session. New + `/login` page serves a username/password form; `POST /login` + validates against `/var/lib/furtka/users.json` (werkzeug PBKDF2- + hashed), sets a `furtka_session` cookie (`HttpOnly`, `SameSite= + Strict`, 7-day TTL), and redirects to `/apps`. `POST /logout` + revokes the server-side session and clears the cookie. + Unauthenticated HTML requests get a 302 to `/login`; unauthenticated + API requests get 401 JSON. The old "No authentication on this UI + yet" banner is gone; the `/apps` header picks up a `Logout` link + instead. +- **First-run setup fallback for upgrade-path boxes.** Boxes + upgrading from 26.10-alpha have no `users.json` yet — on the first + visit `/login` renders a setup form (username + password + + password-confirm) that creates the admin record on submit. Fresh + installs skip this: the webinstaller writes `users.json` during + the chroot post-install step using the step-1 password, so the + first browser visit after boot goes straight to the login form. +- **Caddy proxy routes `/login` and `/logout`.** `assets/Caddyfile` + gets two new `handle` blocks in the shared `(furtka_routes)` + snippet so both the `:80` block and the `hostname.local, hostname` + HTTPS block forward the auth endpoints to the stdlib server on + `127.0.0.1:7000`. Without this Caddy would serve a 404 from the + static file server. + +### Fixed + +- `tests/test_installer.py` ruff-format nit — the 26.10-alpha + release commit had a misformatted list literal that failed + `ruff format --check`. Caught when the Release page on Forgejo + showed a red CI badge for the tag. +- `pyproject.toml` version string bumped from the stale 26.8-alpha + to 26.11-alpha. Release pipeline uses `GITHUB_REF_NAME` as source + of truth for the artefact name, but having the two agree matters + for local dev runs that read `pyproject.toml`. + ## [26.10-alpha] - 2026-04-21 ### Added @@ -182,7 +222,8 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de - **Containers:** Docker + Compose - **License:** AGPL-3.0 -[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.10-alpha...HEAD +[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka/compare/26.11-alpha...HEAD +[26.11-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.11-alpha [26.10-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.10-alpha [26.9-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.9-alpha [26.8-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.8-alpha diff --git a/assets/Caddyfile b/assets/Caddyfile index 71ea0ca..36cc8ea 100644 --- a/assets/Caddyfile +++ b/assets/Caddyfile @@ -35,6 +35,12 @@ handle /apps* { reverse_proxy localhost:7000 } + handle /login* { + reverse_proxy localhost:7000 + } + handle /logout* { + reverse_proxy localhost:7000 + } # Runtime JSON lives under /var/lib/furtka/ so it survives self-updates # (which only swap /opt/furtka/current). handle /status.json { diff --git a/assets/www/style.css b/assets/www/style.css index dde1293..90a8cf9 100644 --- a/assets/www/style.css +++ b/assets/www/style.css @@ -310,7 +310,8 @@ details.log-details[open] > summary { color: var(--fg); } } .field input:focus { outline: 2px solid var(--accent); outline-offset: -1px; } .field .req { color: var(--danger); margin-left: 0.25rem; } -.modal .error { +.modal .error, +.login-wrap .error { background: var(--warn); color: var(--warn-fg); padding: 0.5rem 0.75rem; @@ -319,7 +320,15 @@ details.log-details[open] > summary { color: var(--fg); } font-size: 0.9rem; display: none; } -.modal .error.show { display: block; } +.modal .error.show, +.login-wrap .error.show { display: block; } + +/* Login + first-run setup page. Shares .wrap's max-width so the form + sits in the same column the rest of the app uses, just without the + Home/Apps/Settings nav. A bit of top padding so the H1 isn't glued + to the viewport edge. */ +.login-wrap { padding-top: 3rem; } +.login-wrap .actions { margin-top: 0.5rem; } .modal-actions { display: flex; justify-content: flex-end; diff --git a/furtka/api.py b/furtka/api.py index 851cbda..d3c84e3 100644 --- a/furtka/api.py +++ b/furtka/api.py @@ -2,20 +2,26 @@ # its lines hurts readability and the rendered output is what matters here. """Tiny HTTP API + management UI for the Furtka resource manager. -Single stdlib http.server process, no Flask/no third-party deps so we don't -have to pip-install anything on the target. Caddy reverse-proxies /apps and -/api from :80 to here. +Single stdlib http.server process, served behind Caddy (reverse-proxies +/apps, /api, /login and /logout from :80 to here). -Security: NO AUTH. Bound to 127.0.0.1 by default; the Caddy proxy makes it -LAN-reachable. Anyone on the LAN can install/remove apps. The UI shouts this -out at the top. Auth lands when Authentik does. +Security: single-admin password login, cookie-session, werkzeug-hashed +password stored at /var/lib/furtka/users.json (0600). Sessions live in +memory — `systemctl restart furtka-api` invalidates everyone. Fresh +installs pre-populate users.json from the webinstaller step-1 password; +upgrades from pre-auth releases fall into a first-run setup form at +/login where the admin password is created from the browser. Authentik +integration remains the long-term plan; this is the pragmatic alpha +stopgap. """ import json import re +import time +from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler, HTTPServer -from furtka import dockerops, installer, reconciler, sources +from furtka import auth, dockerops, installer, reconciler, sources from furtka.manifest import ManifestError, load_manifest from furtka.paths import apps_dir from furtka.scanner import scan @@ -77,12 +83,12 @@ _HTML = """ Home Apps Settings + Logout

Furtka Apps

Install or remove resource-manager apps on this Furtka box.

-
No authentication on this UI yet. Anyone on your LAN can install or remove apps. Don't expose this to the wider internet.

Installed

@@ -120,6 +126,15 @@ function esc(s) { return d.innerHTML; } +async function doLogout(ev) { + ev.preventDefault(); + try { + await fetch('/logout', { method: 'POST', credentials: 'same-origin' }); + } catch (e) { /* best-effort — server may already be down */ } + window.location.href = '/login'; + return false; +} + // Fallback when an app doesn't ship a parseable icon.svg. Simple // stroked folder — currentColor so the tile's accent tint applies. const FALLBACK_ICON = ''; @@ -387,6 +402,120 @@ refreshCatalog(); """ +# Login / first-run setup page. Rendered standalone (no main-UI chrome) so +# an unauthenticated visitor never gets a glimpse of the app list. Reuses +# /style.css for the look — the page is just a form + optional error line. +# The template has a {{ SETUP }} marker the server flips on/off depending +# on whether users.json exists yet (first-run vs. normal login). +_HTML_LOGIN = """ + + + +Furtka · {{ TITLE }} + + + + +
+

{{ HEADING }}

+

{{ LEDE }}

+
+
+ + +
+
+ + +
+ {{ PASSWORD2_FIELD }} +
+
+ +
+
+
+ + + +""" + + +def _render_login_html(setup: bool, default_username: str = "") -> str: + if setup: + password2_field = ( + '
' + '
' + ) + subs = { + "TITLE": "First-run setup", + "HEADING": "Set admin password", + "LEDE": "No admin account exists yet on this box. Pick a username and password — you'll use them to sign in to the Furtka UI.", + "PWD_AUTOCOMPLETE": "new-password", + "PASSWORD2_FIELD": password2_field, + "SUBMIT_LABEL": "Create admin", + "DEFAULT_USERNAME": "admin", + "SETUP_JSON": "true", + } + else: + subs = { + "TITLE": "Login", + "HEADING": "Furtka login", + "LEDE": "Sign in with the admin credentials you set during install.", + "PWD_AUTOCOMPLETE": "current-password", + "PASSWORD2_FIELD": "", + "SUBMIT_LABEL": "Log in", + "DEFAULT_USERNAME": default_username, + "SETUP_JSON": "false", + } + out = _HTML_LOGIN + for key, val in subs.items(): + out = out.replace("{{ " + key + " }}", val) + return out + + +# Minimum password length enforced server-side (browser also enforces it +# via the input's minlength, but don't rely on client-side only). +_MIN_PASSWORD_LEN = 8 + + def _manifest_summary(m, app_dir=None): return { "name": m.name, @@ -826,23 +955,134 @@ def _parse_settings_body(payload): class _Handler(BaseHTTPRequestHandler): - def _json(self, status, payload): + def _json(self, status, payload, extra_headers=None): body = json.dumps(payload).encode() self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) + for name, value in extra_headers or []: + self.send_header(name, value) self.end_headers() self.wfile.write(body) - def _html(self, status, body): + def _html(self, status, body, extra_headers=None): b = body.encode() self.send_response(status) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(b))) + for name, value in extra_headers or []: + self.send_header(name, value) self.end_headers() self.wfile.write(b) + def _redirect(self, location, extra_headers=None): + self.send_response(302) + self.send_header("Location", location) + self.send_header("Content-Length", "0") + for name, value in extra_headers or []: + self.send_header(name, value) + self.end_headers() + + # ---- Auth helpers ------------------------------------------------- + + def _request_cookies(self) -> SimpleCookie: + cookies = SimpleCookie() + header = self.headers.get("Cookie") + if header: + try: + cookies.load(header) + except Exception: + # Malformed Cookie header — treat as no cookies rather + # than 500ing. Same posture as browsers. + return SimpleCookie() + return cookies + + def _current_session(self): + cookies = self._request_cookies() + morsel = cookies.get(auth.COOKIE_NAME) + if morsel is None: + return None + return auth.SESSIONS.lookup(morsel.value) + + def _session_cookie_header(self, token: str, max_age: int) -> tuple[str, str]: + secure = self.headers.get("X-Forwarded-Proto", "").lower() == "https" + parts = [ + f"{auth.COOKIE_NAME}={token}", + "HttpOnly", + "SameSite=Strict", + "Path=/", + f"Max-Age={max_age}", + ] + if secure: + parts.append("Secure") + return ("Set-Cookie", "; ".join(parts)) + + def _clear_cookie_header(self) -> tuple[str, str]: + # Max-Age=0 with an empty value tells the browser to drop it. + return ( + "Set-Cookie", + f"{auth.COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0", + ) + + def _handle_login(self, payload): + username = payload.get("username") if isinstance(payload, dict) else None + password = payload.get("password") if isinstance(payload, dict) else None + if not isinstance(username, str) or not username.strip(): + return self._json(400, {"error": "username is required"}) + if not isinstance(password, str) or not password: + return self._json(400, {"error": "password is required"}) + username = username.strip() + + if auth.setup_needed(): + # First-run setup path — create the admin account, then log + # in. Require password2 so a typo doesn't lock the user out + # of their own box. + password2 = payload.get("password2") + if password2 != password: + return self._json(400, {"error": "passwords do not match"}) + if len(password) < _MIN_PASSWORD_LEN: + return self._json( + 400, + {"error": f"password must be at least {_MIN_PASSWORD_LEN} characters"}, + ) + auth.create_admin(username, password) + else: + if not auth.authenticate(username, password): + # Cheap brute-force speed bump. werkzeug's PBKDF2 is + # already slow per attempt, but a fixed sleep makes + # "try 1000 passwords over the LAN" even less fun. + time.sleep(0.5) + return self._json(401, {"error": "invalid username or password"}) + + session = auth.SESSIONS.create(username) + cookie = self._session_cookie_header(session.token, auth.COOKIE_TTL_SECONDS) + return self._json(200, {"ok": True, "username": username}, extra_headers=[cookie]) + + def _handle_logout(self): + cookies = self._request_cookies() + morsel = cookies.get(auth.COOKIE_NAME) + if morsel is not None: + auth.SESSIONS.revoke(morsel.value) + return self._json(200, {"ok": True}, extra_headers=[self._clear_cookie_header()]) + def do_GET(self): # noqa: N802 — http.server convention + # --- Public routes: login page + its assets ------------------ + if self.path in ("/login", "/login/"): + # Already authed? Skip straight to the app list. + if self._current_session() is not None: + return self._redirect("/apps") + return self._html(200, _render_login_html(auth.setup_needed())) + + # --- Auth guard for everything below ------------------------- + session = self._current_session() + if session is None: + # API paths get a 401 JSON so fetch() callers see a clean + # error. HTML paths get a redirect to /login so the browser + # naturally ends up on the login form. + if self.path.startswith("/api/"): + return self._json(401, {"error": "not authenticated"}) + return self._redirect("/login") + if self.path in ("/", "/apps", "/apps/"): return self._html(200, _HTML) if self.path == "/api/apps": @@ -879,6 +1119,16 @@ class _Handler(BaseHTTPRequestHandler): if not isinstance(payload, dict): return self._json(400, {"error": "body must be a JSON object"}) + # --- Public routes: login + logout ---------------------------- + if self.path in ("/login", "/login/"): + return self._handle_login(payload) + if self.path in ("/logout", "/logout/"): + return self._handle_logout() + + # --- Auth guard for every other POST -------------------------- + if self._current_session() is None: + return self._json(401, {"error": "not authenticated"}) + # Per-app settings update: /api/apps//settings if self.path.startswith("/api/apps/") and self.path.endswith("/settings"): name = self.path[len("/api/apps/") : -len("/settings")] diff --git a/furtka/auth.py b/furtka/auth.py new file mode 100644 index 0000000..526760a --- /dev/null +++ b/furtka/auth.py @@ -0,0 +1,175 @@ +"""Login-guard primitives for the Furtka UI. + +One admin, one password. Passwords are PBKDF2-hashed via werkzeug (already +pulled in by the flask runtime dep), stored in /var/lib/furtka/users.json +with mode 0600. Sessions live in memory — a systemctl restart logs +everyone out again, which is fine for an alpha single-user box. + +On upgrade from 26.10-alpha the users.json file does not exist yet; the +api's GET /login detects this via `setup_needed()` and renders a first- +run form that POSTs to /login as if it were a setup submit. Fresh installs +get the file pre-populated by the webinstaller so the setup step is +skipped. +""" + +from __future__ import annotations + +import json +import secrets +import threading +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta + +from werkzeug.security import check_password_hash, generate_password_hash + +from furtka.paths import users_file + +COOKIE_NAME = "furtka_session" +COOKIE_TTL_SECONDS = 7 * 24 * 3600 # one week + + +def hash_password(plain: str) -> str: + """PBKDF2-SHA256 via werkzeug. Cost default (~600k iterations).""" + return generate_password_hash(plain) + + +def verify_password(plain: str, hashed: str) -> bool: + # werkzeug's check_password_hash is constant-time. + return check_password_hash(hashed, plain) + + +def load_users() -> dict: + """Return the users dict, or {} if the file is missing or empty. + + Missing-file is the expected state on first boot and on upgrades from + pre-auth versions — callers treat empty-dict as "setup required". + """ + path = users_file() + if not path.exists(): + return {} + try: + raw = path.read_text() + except OSError: + return {} + if not raw.strip(): + return {} + try: + data = json.loads(raw) + except json.JSONDecodeError: + return {} + if not isinstance(data, dict): + return {} + return data + + +def save_users(users: dict) -> None: + """Atomically write users.json with mode 0600. + + Same pattern as installer.write_env — write to .tmp, chmod, rename — + so a crash between open() and close() can't leave a world-readable + partial file. + """ + path = users_file() + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps(users, indent=2) + "\n") + tmp.chmod(0o600) + tmp.replace(path) + + +def setup_needed() -> bool: + """True when no admin is registered yet — initial setup is required.""" + users = load_users() + return not users or "admin" not in users + + +def create_admin(username: str, password: str) -> None: + """Overwrite users.json with a single admin account. + + The webinstaller calls this post-install (with the step-1 password) so + the installed system is login-guarded from first boot. The /login + route calls it on first setup for upgrade-path boxes that don't + already have a users.json. + """ + users = { + "admin": { + "username": username, + "hash": hash_password(password), + "created_at": datetime.now(UTC).isoformat(timespec="seconds"), + } + } + save_users(users) + + +def authenticate(username: str, password: str) -> bool: + """Return True iff the supplied credentials match the admin record.""" + users = load_users() + admin = users.get("admin") + if not admin: + return False + if admin.get("username") != username: + return False + hashed = admin.get("hash") + if not isinstance(hashed, str) or not hashed: + return False + return verify_password(password, hashed) + + +@dataclass(frozen=True) +class Session: + token: str + username: str + expires_at: datetime + + +class SessionStore: + """In-memory session table. Thread-safe (api.py uses the stdlib + + HTTPServer which handles one request per thread — though the default + variant is single-threaded, we keep the lock so swapping to + ThreadingHTTPServer later doesn't require revisiting this). + """ + + def __init__(self, ttl_seconds: int = COOKIE_TTL_SECONDS) -> None: + self._ttl = timedelta(seconds=ttl_seconds) + self._by_token: dict[str, Session] = {} + self._lock = threading.Lock() + + def create(self, username: str) -> Session: + token = secrets.token_urlsafe(32) + session = Session( + token=token, + username=username, + expires_at=datetime.now(UTC) + self._ttl, + ) + with self._lock: + self._by_token[token] = session + return session + + def lookup(self, token: str | None) -> Session | None: + if not token: + return None + with self._lock: + session = self._by_token.get(token) + if session is None: + return None + if datetime.now(UTC) >= session.expires_at: + # Expired — drop it on the floor so repeat lookups stay fast. + self._by_token.pop(token, None) + return None + return session + + def revoke(self, token: str | None) -> None: + if not token: + return + with self._lock: + self._by_token.pop(token, None) + + def clear(self) -> None: + """Test helper — wipe all sessions.""" + with self._lock: + self._by_token.clear() + + +# Module-level singleton used by the HTTP handler. +SESSIONS = SessionStore() diff --git a/furtka/paths.py b/furtka/paths.py index db741c3..36e78a2 100644 --- a/furtka/paths.py +++ b/furtka/paths.py @@ -11,6 +11,11 @@ DEFAULT_BUNDLED_APPS_DIR = Path("/opt/furtka/current/apps") # release tarball. Lives under /var/lib/furtka/ so it survives core self- # updates — the resolver (furtka.sources) prefers it over the bundled seed. DEFAULT_CATALOG_DIR = Path("/var/lib/furtka/catalog") +# Users / auth state. One JSON file keyed by role — today only "admin" exists. +# Lives under /var/lib/furtka/ so self-updates don't stomp it. Mode 0600 is +# enforced by furtka.auth.save_users (same atomic-write pattern as the app +# .env files). +DEFAULT_USERS_FILE = Path("/var/lib/furtka/users.json") def apps_dir() -> Path: @@ -27,3 +32,7 @@ def catalog_dir() -> Path: def catalog_apps_dir() -> Path: return catalog_dir() / "apps" + + +def users_file() -> Path: + return Path(os.environ.get("FURTKA_USERS_FILE", DEFAULT_USERS_FILE)) diff --git a/pyproject.toml b/pyproject.toml index 76cfc69..d9123fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "furtka" -version = "26.8-alpha" +version = "26.11-alpha" description = "Open-source home server OS — simple enough for everyone." requires-python = ">=3.11" readme = "README.md" diff --git a/tests/test_api.py b/tests/test_api.py index 159cedb..fca872b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -5,7 +5,7 @@ import urllib.request import pytest -from furtka import api, dockerops +from furtka import api, auth, dockerops VALID_MANIFEST = { "name": "fileshare", @@ -23,14 +23,28 @@ def fake_dirs(tmp_path, monkeypatch): apps = tmp_path / "apps" bundled = tmp_path / "bundled" catalog = tmp_path / "catalog" + users_file = tmp_path / "users.json" apps.mkdir() bundled.mkdir() monkeypatch.setenv("FURTKA_APPS_DIR", str(apps)) monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled)) monkeypatch.setenv("FURTKA_CATALOG_DIR", str(catalog)) + monkeypatch.setenv("FURTKA_USERS_FILE", str(users_file)) + # Scrub any sessions that leaked from a prior test — the SESSIONS + # store is module-level. + auth.SESSIONS.clear() return apps, bundled +@pytest.fixture +def admin_session(fake_dirs): + """Pre-create an admin account + live session. Returns a Cookie header + value ready to drop into urllib.request.Request(headers=...).""" + auth.create_admin("daniel", "hunter2-pw") + session = auth.SESSIONS.create("daniel") + return f"{auth.COOKIE_NAME}={session.token}" + + @pytest.fixture def no_docker(monkeypatch): """Stub docker calls so install/remove can run without a daemon.""" @@ -199,23 +213,39 @@ def test_remove_endpoint_happy_path(fake_dirs, no_docker): assert not (apps / "fileshare").exists() -def test_http_get_apps_route(fake_dirs, no_docker): +def _request(port, path, cookie=None, method="GET", body=None): + headers = {} + if cookie is not None: + headers["Cookie"] = cookie + data = None + if body is not None: + headers["Content-Type"] = "application/json" + data = json.dumps(body).encode() + return urllib.request.Request( + f"http://127.0.0.1:{port}{path}", + data=data, + headers=headers, + method=method, + ) + + +def test_http_get_apps_route(fake_dirs, no_docker, admin_session): """Smoke test the actual HTTP server with a real socket, urllib client.""" server = api.HTTPServer(("127.0.0.1", 0), api._Handler) # port 0 → ephemeral port = server.server_address[1] t = threading.Thread(target=server.serve_forever, daemon=True) t.start() try: - with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/apps") as r: + with urllib.request.urlopen(_request(port, "/api/apps", cookie=admin_session)) as r: assert r.status == 200 data = json.loads(r.read()) assert data == [] - with urllib.request.urlopen(f"http://127.0.0.1:{port}/") as r: + with urllib.request.urlopen(_request(port, "/", cookie=admin_session)) as r: assert r.status == 200 assert b"Furtka Apps" in r.read() # Unknown route → 404 JSON. try: - urllib.request.urlopen(f"http://127.0.0.1:{port}/api/nope") + urllib.request.urlopen(_request(port, "/api/nope", cookie=admin_session)) raise AssertionError("expected 404") except urllib.error.HTTPError as e: assert e.code == 404 @@ -224,17 +254,18 @@ def test_http_get_apps_route(fake_dirs, no_docker): server.server_close() -def test_http_post_install_unknown_app(fake_dirs): +def test_http_post_install_unknown_app(fake_dirs, admin_session): server = api.HTTPServer(("127.0.0.1", 0), api._Handler) port = server.server_address[1] t = threading.Thread(target=server.serve_forever, daemon=True) t.start() try: - req = urllib.request.Request( - f"http://127.0.0.1:{port}/api/apps/install", - data=json.dumps({"name": "ghost"}).encode(), - headers={"Content-Type": "application/json"}, + req = _request( + port, + "/api/apps/install", + cookie=admin_session, method="POST", + body={"name": "ghost"}, ) try: urllib.request.urlopen(req) @@ -248,6 +279,247 @@ def test_http_post_install_unknown_app(fake_dirs): server.server_close() +# --- Auth guard + login flow ------------------------------------------------ + + +def _start_server(): + server = api.HTTPServer(("127.0.0.1", 0), api._Handler) + port = server.server_address[1] + t = threading.Thread(target=server.serve_forever, daemon=True) + t.start() + return server, port + + +def test_unauthenticated_api_returns_401(fake_dirs): + # No admin_session fixture → no cookie on the request. + server, port = _start_server() + try: + try: + urllib.request.urlopen(_request(port, "/api/apps")) + raise AssertionError("expected 401") + except urllib.error.HTTPError as e: + assert e.code == 401 + body = json.loads(e.read()) + assert body["error"] == "not authenticated" + finally: + server.shutdown() + server.server_close() + + +def test_unauthenticated_html_redirects_to_login(fake_dirs): + server, port = _start_server() + try: + # Disable redirect following so we can inspect the 302. + opener = urllib.request.build_opener(_NoRedirectHandler()) + try: + opener.open(_request(port, "/apps")) + raise AssertionError("expected 302") + except urllib.error.HTTPError as e: + assert e.code == 302 + assert e.headers["Location"] == "/login" + finally: + server.shutdown() + server.server_close() + + +class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +def test_get_login_renders_login_form_when_admin_exists(fake_dirs): + auth.create_admin("daniel", "hunter2-pw") + server, port = _start_server() + try: + with urllib.request.urlopen(_request(port, "/login")) as r: + html = r.read().decode() + assert r.status == 200 + assert "Furtka login" in html + # No setup confirm-password field rendered in login mode. + assert 'id="password2"' not in html + assert "Repeat password" not in html + finally: + server.shutdown() + server.server_close() + + +def test_get_login_renders_setup_form_when_no_admin(fake_dirs): + server, port = _start_server() + try: + with urllib.request.urlopen(_request(port, "/login")) as r: + html = r.read().decode() + assert r.status == 200 + assert "Set admin password" in html + assert "password2" in html # setup confirm field rendered + finally: + server.shutdown() + server.server_close() + + +def test_get_login_redirects_when_already_authed(fake_dirs, admin_session): + server, port = _start_server() + try: + opener = urllib.request.build_opener(_NoRedirectHandler()) + try: + opener.open(_request(port, "/login", cookie=admin_session)) + raise AssertionError("expected 302") + except urllib.error.HTTPError as e: + assert e.code == 302 + assert e.headers["Location"] == "/apps" + finally: + server.shutdown() + server.server_close() + + +def test_post_login_setup_creates_admin(fake_dirs): + server, port = _start_server() + try: + req = _request( + port, + "/login", + method="POST", + body={ + "username": "daniel", + "password": "a-real-password", + "password2": "a-real-password", + }, + ) + with urllib.request.urlopen(req) as r: + assert r.status == 200 + set_cookie = r.headers["Set-Cookie"] + assert auth.COOKIE_NAME in set_cookie + assert "HttpOnly" in set_cookie + assert "SameSite=Strict" in set_cookie + # users.json got written. + assert auth.load_users()["admin"]["username"] == "daniel" + # And the password really works. + assert auth.authenticate("daniel", "a-real-password") is True + finally: + server.shutdown() + server.server_close() + + +def test_post_login_setup_rejects_password_mismatch(fake_dirs): + server, port = _start_server() + try: + req = _request( + port, + "/login", + method="POST", + body={"username": "x", "password": "abcdefgh", "password2": "different"}, + ) + try: + urllib.request.urlopen(req) + raise AssertionError("expected 400") + except urllib.error.HTTPError as e: + assert e.code == 400 + body = json.loads(e.read()) + assert "match" in body["error"].lower() + # No admin created. + assert auth.setup_needed() is True + finally: + server.shutdown() + server.server_close() + + +def test_post_login_setup_rejects_short_password(fake_dirs): + server, port = _start_server() + try: + req = _request( + port, + "/login", + method="POST", + body={"username": "x", "password": "short", "password2": "short"}, + ) + try: + urllib.request.urlopen(req) + raise AssertionError("expected 400") + except urllib.error.HTTPError as e: + assert e.code == 400 + finally: + server.shutdown() + server.server_close() + + +def test_post_login_success_with_correct_credentials(fake_dirs): + auth.create_admin("daniel", "hunter2-pw") + server, port = _start_server() + try: + req = _request( + port, + "/login", + method="POST", + body={"username": "daniel", "password": "hunter2-pw"}, + ) + with urllib.request.urlopen(req) as r: + assert r.status == 200 + set_cookie = r.headers["Set-Cookie"] + assert auth.COOKIE_NAME in set_cookie + finally: + server.shutdown() + server.server_close() + + +def test_post_login_rejects_wrong_password(fake_dirs): + auth.create_admin("daniel", "hunter2-pw") + server, port = _start_server() + try: + req = _request( + port, + "/login", + method="POST", + body={"username": "daniel", "password": "nope"}, + ) + try: + urllib.request.urlopen(req) + raise AssertionError("expected 401") + except urllib.error.HTTPError as e: + assert e.code == 401 + finally: + server.shutdown() + server.server_close() + + +def test_post_logout_revokes_session(fake_dirs, admin_session): + server, port = _start_server() + try: + # Logout returns 200 and clears the cookie. + with urllib.request.urlopen( + _request(port, "/logout", cookie=admin_session, method="POST", body={}) + ) as r: + assert r.status == 200 + set_cookie = r.headers["Set-Cookie"] + assert "Max-Age=0" in set_cookie + # Subsequent API call with same cookie → 401 (session revoked). + try: + urllib.request.urlopen(_request(port, "/api/apps", cookie=admin_session)) + raise AssertionError("expected 401") + except urllib.error.HTTPError as e: + assert e.code == 401 + finally: + server.shutdown() + server.server_close() + + +def test_post_to_protected_route_without_auth_is_401(fake_dirs): + server, port = _start_server() + try: + req = _request( + port, + "/api/apps/install", + method="POST", + body={"name": "whatever"}, + ) + try: + urllib.request.urlopen(req) + raise AssertionError("expected 401") + except urllib.error.HTTPError as e: + assert e.code == 401 + finally: + server.shutdown() + server.server_close() + + # --- Settings endpoints ------------------------------------------------------ SETTINGS_MANIFEST = dict( @@ -329,7 +601,7 @@ def test_update_settings_unknown_app(fake_dirs): assert status == 404 -def test_http_get_settings_route(fake_dirs, no_docker): +def test_http_get_settings_route(fake_dirs, no_docker, admin_session): _, bundled = fake_dirs _write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST) server = api.HTTPServer(("127.0.0.1", 0), api._Handler) @@ -337,7 +609,9 @@ def test_http_get_settings_route(fake_dirs, no_docker): t = threading.Thread(target=server.serve_forever, daemon=True) t.start() try: - with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/apps/fileshare/settings") as r: + with urllib.request.urlopen( + _request(port, "/api/apps/fileshare/settings", cookie=admin_session) + ) as r: assert r.status == 200 data = json.loads(r.read()) assert data["name"] == "fileshare" @@ -549,7 +823,7 @@ def test_furtka_update_status_endpoint(stub_furtka_updater): assert stub_furtka_updater["status_called"] == 1 -def test_http_post_update_route(fake_dirs, no_docker, update_docker_stubs): +def test_http_post_update_route(fake_dirs, no_docker, update_docker_stubs, admin_session): _, bundled = fake_dirs _write_bundled(bundled, "fileshare", env_example="A=real") api._do_install("fileshare") @@ -560,11 +834,12 @@ def test_http_post_update_route(fake_dirs, no_docker, update_docker_stubs): t = threading.Thread(target=server.serve_forever, daemon=True) t.start() try: - req = urllib.request.Request( - f"http://127.0.0.1:{port}/api/apps/fileshare/update", - data=b"{}", - headers={"Content-Type": "application/json"}, + req = _request( + port, + "/api/apps/fileshare/update", + cookie=admin_session, method="POST", + body={}, ) with urllib.request.urlopen(req) as r: assert r.status == 200 @@ -576,7 +851,7 @@ def test_http_post_update_route(fake_dirs, no_docker, update_docker_stubs): server.server_close() -def test_http_post_install_with_settings(fake_dirs, no_docker): +def test_http_post_install_with_settings(fake_dirs, no_docker, admin_session): _, bundled = fake_dirs _write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST) server = api.HTTPServer(("127.0.0.1", 0), api._Handler) @@ -584,16 +859,15 @@ def test_http_post_install_with_settings(fake_dirs, no_docker): t = threading.Thread(target=server.serve_forever, daemon=True) t.start() try: - req = urllib.request.Request( - f"http://127.0.0.1:{port}/api/apps/install", - data=json.dumps( - { - "name": "fileshare", - "settings": {"SMB_USER": "alice", "SMB_PASSWORD": "s3cret"}, - } - ).encode(), - headers={"Content-Type": "application/json"}, + req = _request( + port, + "/api/apps/install", + cookie=admin_session, method="POST", + body={ + "name": "fileshare", + "settings": {"SMB_USER": "alice", "SMB_PASSWORD": "s3cret"}, + }, ) with urllib.request.urlopen(req) as r: assert r.status == 200 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..21bea43 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,152 @@ +import json +from datetime import UTC, datetime, timedelta + +import pytest + +from furtka import auth + + +@pytest.fixture +def tmp_users_file(tmp_path, monkeypatch): + path = tmp_path / "users.json" + monkeypatch.setenv("FURTKA_USERS_FILE", str(path)) + # Sessions are module-level; wipe between tests so one doesn't leak a + # valid token into the next. + auth.SESSIONS.clear() + return path + + +def test_hash_password_roundtrip(): + h = auth.hash_password("hunter2") + assert h != "hunter2" # Not plain text. + assert auth.verify_password("hunter2", h) is True + assert auth.verify_password("hunter3", h) is False + + +def test_hash_password_is_salted(): + # Two calls with the same password must produce different hashes. + a = auth.hash_password("same") + b = auth.hash_password("same") + assert a != b + # But both verify against the original. + assert auth.verify_password("same", a) + assert auth.verify_password("same", b) + + +def test_load_users_returns_empty_when_missing(tmp_users_file): + assert not tmp_users_file.exists() + assert auth.load_users() == {} + + +def test_load_users_returns_empty_on_junk(tmp_users_file): + tmp_users_file.write_text("{not json") + assert auth.load_users() == {} + + +def test_load_users_returns_empty_on_non_dict(tmp_users_file): + tmp_users_file.write_text("[]") + assert auth.load_users() == {} + + +def test_save_users_atomic_and_0600(tmp_users_file): + auth.save_users({"admin": {"hash": "x", "username": "daniel"}}) + assert tmp_users_file.exists() + mode = tmp_users_file.stat().st_mode & 0o777 + assert mode == 0o600, f"expected 0o600, got {oct(mode)}" + loaded = json.loads(tmp_users_file.read_text()) + assert loaded["admin"]["username"] == "daniel" + + +def test_setup_needed_true_on_missing_file(tmp_users_file): + assert auth.setup_needed() is True + + +def test_setup_needed_true_on_empty_dict(tmp_users_file): + tmp_users_file.write_text("{}") + assert auth.setup_needed() is True + + +def test_setup_needed_false_when_admin_exists(tmp_users_file): + auth.create_admin("daniel", "secret-pw") + assert auth.setup_needed() is False + + +def test_create_admin_overwrites_file(tmp_users_file): + auth.create_admin("daniel", "secret-pw") + auth.create_admin("robert", "new-pw") + users = auth.load_users() + assert users["admin"]["username"] == "robert" + + +def test_authenticate_happy(tmp_users_file): + auth.create_admin("daniel", "secret-pw") + assert auth.authenticate("daniel", "secret-pw") is True + + +def test_authenticate_wrong_username(tmp_users_file): + auth.create_admin("daniel", "secret-pw") + assert auth.authenticate("robert", "secret-pw") is False + + +def test_authenticate_wrong_password(tmp_users_file): + auth.create_admin("daniel", "secret-pw") + assert auth.authenticate("daniel", "wrong") is False + + +def test_authenticate_no_admin(tmp_users_file): + assert auth.authenticate("daniel", "anything") is False + + +# ---- Session store --------------------------------------------------------- + + +def test_session_create_and_lookup(tmp_users_file): + s = auth.SESSIONS.create("daniel") + assert s.username == "daniel" + assert s.token + looked_up = auth.SESSIONS.lookup(s.token) + assert looked_up is not None + assert looked_up.username == "daniel" + + +def test_session_lookup_unknown_token(tmp_users_file): + assert auth.SESSIONS.lookup("not-a-real-token") is None + + +def test_session_lookup_none_token(tmp_users_file): + assert auth.SESSIONS.lookup(None) is None + assert auth.SESSIONS.lookup("") is None + + +def test_session_revoke(tmp_users_file): + s = auth.SESSIONS.create("daniel") + auth.SESSIONS.revoke(s.token) + assert auth.SESSIONS.lookup(s.token) is None + + +def test_session_expires(tmp_users_file, monkeypatch): + # Build a session store with a 0-second TTL so lookup immediately + # treats new sessions as expired. + store = auth.SessionStore(ttl_seconds=0) + s = store.create("daniel") + # Force the clock forward a hair so the > check fires. + monkeypatch.setattr( + auth, + "datetime", + _FakeDatetime(datetime.now(UTC) + timedelta(seconds=1)), + ) + # The module-local datetime reference inside SessionStore.lookup + # resolves at call time. Verify that an expired session is dropped. + assert store.lookup(s.token) is None + + +class _FakeDatetime: + """Tiny shim — only `.now(tz)` is used from SessionStore.""" + + def __init__(self, fixed_utc): + self._fixed = fixed_utc + + def now(self, tz=None): + if tz is None: + return self._fixed.replace(tzinfo=None) + return self._fixed.astimezone(tz) diff --git a/tests/test_webinstaller_assets.py b/tests/test_webinstaller_assets.py index b4b022e..4d3783c 100644 --- a/tests/test_webinstaller_assets.py +++ b/tests/test_webinstaller_assets.py @@ -54,7 +54,7 @@ def install_cmds(tmp_path, monkeypatch): fake = tmp_path / "payload.tar.gz" fake.write_bytes(b"not a real tarball") monkeypatch.setattr(app, "RESOURCE_MANAGER_PAYLOAD", fake) - return app._post_install_commands("testhost") + return app._post_install_commands("testhost", "daniel", "test-admin-pw") @pytest.mark.parametrize("target,asset_relpath", ASSET_TARGETS) @@ -202,3 +202,28 @@ def test_read_asset_raises_for_missing_file(): def test_assets_dir_resolves_to_repo_tree(): assert app._ASSETS_DIR == ASSETS + + +def test_post_install_writes_users_json_with_hashed_password(install_cmds): + """The Furtka-admin users.json is created during the chroot post-install. + + Without this, a fresh-install box lands at /login in first-run setup + mode and the user has to go through the browser to set a password — + which defeats the "step-1 password works for everything" design. Also + check that the file is chmod 0600 (the PBKDF2 hash is a secret even + if it's slow to crack). + """ + import json as _json + + from werkzeug.security import check_password_hash + + users_cmd = next((c for c in install_cmds if " > /var/lib/furtka/users.json" in c), None) + assert users_cmd is not None, "no command writes /var/lib/furtka/users.json" + assert "chmod 600" in users_cmd, "users.json must be chmod 0600" + body = _extract_written_content(users_cmd, "/var/lib/furtka/users.json") + parsed = _json.loads(body) + assert "admin" in parsed + assert parsed["admin"]["username"] == "daniel" # matches fixture + # Hash is a real werkzeug hash, not the plaintext password. + assert parsed["admin"]["hash"] != "test-admin-pw" + assert check_password_hash(parsed["admin"]["hash"], "test-admin-pw") diff --git a/webinstaller/app.py b/webinstaller/app.py index 96adf01..6156441 100644 --- a/webinstaller/app.py +++ b/webinstaller/app.py @@ -8,6 +8,7 @@ import os import re import subprocess import sys +from datetime import UTC from pathlib import Path from drives import list_scored_devices @@ -348,7 +349,35 @@ def _furtka_json_cmd(hostname): ) -def _post_install_commands(hostname): +def _users_json_cmd(username, password): + """Write /var/lib/furtka/users.json with the admin account hashed. + + The core furtka-api reads this file on every login attempt; the + auth.py module treats `admin.username` + `admin.hash` as the only + credential. Hashing happens here in the webinstaller (werkzeug is a + flask transitive dep so it's already installed in this environment) + — the chroot doesn't need pip. Mode 0600 so nobody but root on the + installed box can read the PBKDF2 hash. + """ + from datetime import datetime + + from werkzeug.security import generate_password_hash + + users = { + "admin": { + "username": username, + "hash": generate_password_hash(password), + "created_at": datetime.now(UTC).isoformat(timespec="seconds"), + } + } + return _write_file_cmd( + "/var/lib/furtka/users.json", + json.dumps(users, indent=2) + "\n", + mode="600", + ) + + +def _post_install_commands(hostname, admin_username, admin_password): # nss-mdns: splice `mdns_minimal [NOTFOUND=return]` before `resolve` on # the hosts line so `*.local` works from the installed system too. Guarded # so a re-run (or a future Arch default that already ships mdns) is a @@ -389,6 +418,12 @@ def _post_install_commands(hostname): # furtka.json depends on /opt/furtka/current/VERSION, so it has to # run after the resource-manager commands. _furtka_json_cmd(hostname), + # Admin account for the Furtka web UI. Hashed here (werkzeug is + # already in scope for the Flask webinstaller) and materialised + # into /var/lib/furtka/users.json at mode 0600 on the target + # partition — the installed core's auth.py picks it up on first + # login. + _users_json_cmd(admin_username, admin_password), ] @@ -447,7 +482,7 @@ def build_archinstall_config(s): # page, status timer, and welcome banner into place. "custom_commands": [ f"gpasswd -a {s['username']} docker", - *_post_install_commands(s["hostname"]), + *_post_install_commands(s["hostname"], s["username"], s["password"]), ], "network_config": {"type": "iso"}, "ssh": True,