feat(auth): login-guard the Furtka UI with a cookie session
All checks were successful
Build ISO / build-iso (push) Successful in 17m30s
CI / lint (push) Successful in 27s
CI / test (push) Successful in 43s
CI / validate-json (push) Successful in 31s
CI / markdown-links (push) Successful in 15s
Release / release (push) Successful in 11m38s
All checks were successful
Build ISO / build-iso (push) Successful in 17m30s
CI / lint (push) Successful in 27s
CI / test (push) Successful in 43s
CI / validate-json (push) Successful in 31s
CI / markdown-links (push) Successful in 15s
Release / release (push) Successful in 11m38s
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) <noreply@anthropic.com>
This commit is contained in:
parent
577c2469f7
commit
470823b347
11 changed files with 1020 additions and 44 deletions
43
CHANGELOG.md
43
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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
270
furtka/api.py
270
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 = """<!DOCTYPE html>
|
|||
<a href="/">Home</a>
|
||||
<a href="/apps" aria-current="page">Apps</a>
|
||||
<a href="/settings/">Settings</a>
|
||||
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<h1>Furtka Apps</h1>
|
||||
<p class="lede">Install or remove resource-manager apps on this Furtka box.</p>
|
||||
<div class="warn">No authentication on this UI yet. Anyone on your LAN can install or remove apps. Don't expose this to the wider internet.</div>
|
||||
|
||||
<h2>Installed</h2>
|
||||
<div id="installed"></div>
|
||||
|
|
@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-7l-2-2H5a2 2 0 0 0-2 2z"/></svg>';
|
||||
|
|
@ -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 = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Furtka · {{ TITLE }}</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap login-wrap">
|
||||
<h1>{{ HEADING }}</h1>
|
||||
<p class="lede">{{ LEDE }}</p>
|
||||
<form id="login-form" onsubmit="return doLogin(event)">
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username" required value="{{ DEFAULT_USERNAME }}" autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="{{ PWD_AUTOCOMPLETE }}" required minlength="8">
|
||||
</div>
|
||||
{{ PASSWORD2_FIELD }}
|
||||
<div id="login-error" class="error"></div>
|
||||
<div class="actions">
|
||||
<button type="submit" id="login-submit">{{ SUBMIT_LABEL }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
<script>
|
||||
const SETUP = {{ SETUP_JSON }};
|
||||
const errBox = document.getElementById('login-error');
|
||||
async function doLogin(ev) {
|
||||
ev.preventDefault();
|
||||
errBox.classList.remove('show');
|
||||
errBox.textContent = '';
|
||||
const btn = document.getElementById('login-submit');
|
||||
btn.disabled = true;
|
||||
const body = {
|
||||
username: document.getElementById('username').value,
|
||||
password: document.getElementById('password').value,
|
||||
};
|
||||
if (SETUP) body.password2 = document.getElementById('password2').value;
|
||||
try {
|
||||
const r = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (r.ok) {
|
||||
window.location.href = '/apps';
|
||||
return false;
|
||||
}
|
||||
const data = await r.json().catch(() => ({error: 'HTTP ' + r.status}));
|
||||
errBox.textContent = data.error || 'Login failed';
|
||||
errBox.classList.add('show');
|
||||
} catch (e) {
|
||||
errBox.textContent = 'Network error — is the box reachable?';
|
||||
errBox.classList.add('show');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def _render_login_html(setup: bool, default_username: str = "") -> str:
|
||||
if setup:
|
||||
password2_field = (
|
||||
'<div class="field"><label for="password2">Repeat password</label>'
|
||||
'<input id="password2" name="password2" type="password" '
|
||||
'autocomplete="new-password" required minlength="8"></div>'
|
||||
)
|
||||
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/<name>/settings
|
||||
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
|
||||
name = self.path[len("/api/apps/") : -len("/settings")]
|
||||
|
|
|
|||
175
furtka/auth.py
Normal file
175
furtka/auth.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
req = _request(
|
||||
port,
|
||||
"/api/apps/install",
|
||||
cookie=admin_session,
|
||||
method="POST",
|
||||
body={
|
||||
"name": "fileshare",
|
||||
"settings": {"SMB_USER": "alice", "SMB_PASSWORD": "s3cret"},
|
||||
}
|
||||
).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req) as r:
|
||||
assert r.status == 200
|
||||
|
|
|
|||
152
tests/test_auth.py
Normal file
152
tests/test_auth.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue