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]
|
## [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
|
## [26.10-alpha] - 2026-04-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
@ -182,7 +222,8 @@ First tagged snapshot. Pre-alpha — the installer does not yet boot, but the de
|
||||||
- **Containers:** Docker + Compose
|
- **Containers:** Docker + Compose
|
||||||
- **License:** AGPL-3.0
|
- **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.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.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
|
[26.8-alpha]: https://forgejo.sourcegate.online/daniel/furtka/releases/tag/26.8-alpha
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,12 @@
|
||||||
handle /apps* {
|
handle /apps* {
|
||||||
reverse_proxy localhost:7000
|
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
|
# Runtime JSON lives under /var/lib/furtka/ so it survives self-updates
|
||||||
# (which only swap /opt/furtka/current).
|
# (which only swap /opt/furtka/current).
|
||||||
handle /status.json {
|
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 input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
||||||
.field .req { color: var(--danger); margin-left: 0.25rem; }
|
.field .req { color: var(--danger); margin-left: 0.25rem; }
|
||||||
.modal .error {
|
.modal .error,
|
||||||
|
.login-wrap .error {
|
||||||
background: var(--warn);
|
background: var(--warn);
|
||||||
color: var(--warn-fg);
|
color: var(--warn-fg);
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
|
|
@ -319,7 +320,15 @@ details.log-details[open] > summary { color: var(--fg); }
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
display: none;
|
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 {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
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.
|
# its lines hurts readability and the rendered output is what matters here.
|
||||||
"""Tiny HTTP API + management UI for the Furtka resource manager.
|
"""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
|
Single stdlib http.server process, served behind Caddy (reverse-proxies
|
||||||
have to pip-install anything on the target. Caddy reverse-proxies /apps and
|
/apps, /api, /login and /logout from :80 to here).
|
||||||
/api from :80 to here.
|
|
||||||
|
|
||||||
Security: NO AUTH. Bound to 127.0.0.1 by default; the Caddy proxy makes it
|
Security: single-admin password login, cookie-session, werkzeug-hashed
|
||||||
LAN-reachable. Anyone on the LAN can install/remove apps. The UI shouts this
|
password stored at /var/lib/furtka/users.json (0600). Sessions live in
|
||||||
out at the top. Auth lands when Authentik does.
|
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 json
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
|
from http.cookies import SimpleCookie
|
||||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
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.manifest import ManifestError, load_manifest
|
||||||
from furtka.paths import apps_dir
|
from furtka.paths import apps_dir
|
||||||
from furtka.scanner import scan
|
from furtka.scanner import scan
|
||||||
|
|
@ -77,12 +83,12 @@ _HTML = """<!DOCTYPE html>
|
||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
<a href="/apps" aria-current="page">Apps</a>
|
<a href="/apps" aria-current="page">Apps</a>
|
||||||
<a href="/settings/">Settings</a>
|
<a href="/settings/">Settings</a>
|
||||||
|
<a href="#" id="logout-link" onclick="return doLogout(event)">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<h1>Furtka Apps</h1>
|
<h1>Furtka Apps</h1>
|
||||||
<p class="lede">Install or remove resource-manager apps on this Furtka box.</p>
|
<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>
|
<h2>Installed</h2>
|
||||||
<div id="installed"></div>
|
<div id="installed"></div>
|
||||||
|
|
@ -120,6 +126,15 @@ function esc(s) {
|
||||||
return d.innerHTML;
|
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
|
// Fallback when an app doesn't ship a parseable icon.svg. Simple
|
||||||
// stroked folder — currentColor so the tile's accent tint applies.
|
// 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>';
|
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):
|
def _manifest_summary(m, app_dir=None):
|
||||||
return {
|
return {
|
||||||
"name": m.name,
|
"name": m.name,
|
||||||
|
|
@ -826,23 +955,134 @@ def _parse_settings_body(payload):
|
||||||
|
|
||||||
|
|
||||||
class _Handler(BaseHTTPRequestHandler):
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
def _json(self, status, payload):
|
def _json(self, status, payload, extra_headers=None):
|
||||||
body = json.dumps(payload).encode()
|
body = json.dumps(payload).encode()
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
self.send_header("Content-Length", str(len(body)))
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
for name, value in extra_headers or []:
|
||||||
|
self.send_header(name, value)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(body)
|
self.wfile.write(body)
|
||||||
|
|
||||||
def _html(self, status, body):
|
def _html(self, status, body, extra_headers=None):
|
||||||
b = body.encode()
|
b = body.encode()
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
self.send_header("Content-Length", str(len(b)))
|
self.send_header("Content-Length", str(len(b)))
|
||||||
|
for name, value in extra_headers or []:
|
||||||
|
self.send_header(name, value)
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(b)
|
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
|
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/"):
|
if self.path in ("/", "/apps", "/apps/"):
|
||||||
return self._html(200, _HTML)
|
return self._html(200, _HTML)
|
||||||
if self.path == "/api/apps":
|
if self.path == "/api/apps":
|
||||||
|
|
@ -879,6 +1119,16 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
return self._json(400, {"error": "body must be a JSON object"})
|
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
|
# Per-app settings update: /api/apps/<name>/settings
|
||||||
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
|
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
|
||||||
name = self.path[len("/api/apps/") : -len("/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-
|
# release tarball. Lives under /var/lib/furtka/ so it survives core self-
|
||||||
# updates — the resolver (furtka.sources) prefers it over the bundled seed.
|
# updates — the resolver (furtka.sources) prefers it over the bundled seed.
|
||||||
DEFAULT_CATALOG_DIR = Path("/var/lib/furtka/catalog")
|
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:
|
def apps_dir() -> Path:
|
||||||
|
|
@ -27,3 +32,7 @@ def catalog_dir() -> Path:
|
||||||
|
|
||||||
def catalog_apps_dir() -> Path:
|
def catalog_apps_dir() -> Path:
|
||||||
return catalog_dir() / "apps"
|
return catalog_dir() / "apps"
|
||||||
|
|
||||||
|
|
||||||
|
def users_file() -> Path:
|
||||||
|
return Path(os.environ.get("FURTKA_USERS_FILE", DEFAULT_USERS_FILE))
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "furtka"
|
name = "furtka"
|
||||||
version = "26.8-alpha"
|
version = "26.11-alpha"
|
||||||
description = "Open-source home server OS — simple enough for everyone."
|
description = "Open-source home server OS — simple enough for everyone."
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import urllib.request
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from furtka import api, dockerops
|
from furtka import api, auth, dockerops
|
||||||
|
|
||||||
VALID_MANIFEST = {
|
VALID_MANIFEST = {
|
||||||
"name": "fileshare",
|
"name": "fileshare",
|
||||||
|
|
@ -23,14 +23,28 @@ def fake_dirs(tmp_path, monkeypatch):
|
||||||
apps = tmp_path / "apps"
|
apps = tmp_path / "apps"
|
||||||
bundled = tmp_path / "bundled"
|
bundled = tmp_path / "bundled"
|
||||||
catalog = tmp_path / "catalog"
|
catalog = tmp_path / "catalog"
|
||||||
|
users_file = tmp_path / "users.json"
|
||||||
apps.mkdir()
|
apps.mkdir()
|
||||||
bundled.mkdir()
|
bundled.mkdir()
|
||||||
monkeypatch.setenv("FURTKA_APPS_DIR", str(apps))
|
monkeypatch.setenv("FURTKA_APPS_DIR", str(apps))
|
||||||
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
|
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
|
||||||
monkeypatch.setenv("FURTKA_CATALOG_DIR", str(catalog))
|
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
|
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
|
@pytest.fixture
|
||||||
def no_docker(monkeypatch):
|
def no_docker(monkeypatch):
|
||||||
"""Stub docker calls so install/remove can run without a daemon."""
|
"""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()
|
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."""
|
"""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
|
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) # port 0 → ephemeral
|
||||||
port = server.server_address[1]
|
port = server.server_address[1]
|
||||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
try:
|
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
|
assert r.status == 200
|
||||||
data = json.loads(r.read())
|
data = json.loads(r.read())
|
||||||
assert data == []
|
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 r.status == 200
|
||||||
assert b"Furtka Apps" in r.read()
|
assert b"Furtka Apps" in r.read()
|
||||||
# Unknown route → 404 JSON.
|
# Unknown route → 404 JSON.
|
||||||
try:
|
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")
|
raise AssertionError("expected 404")
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
assert e.code == 404
|
assert e.code == 404
|
||||||
|
|
@ -224,17 +254,18 @@ def test_http_get_apps_route(fake_dirs, no_docker):
|
||||||
server.server_close()
|
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)
|
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
|
||||||
port = server.server_address[1]
|
port = server.server_address[1]
|
||||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(
|
req = _request(
|
||||||
f"http://127.0.0.1:{port}/api/apps/install",
|
port,
|
||||||
data=json.dumps({"name": "ghost"}).encode(),
|
"/api/apps/install",
|
||||||
headers={"Content-Type": "application/json"},
|
cookie=admin_session,
|
||||||
method="POST",
|
method="POST",
|
||||||
|
body={"name": "ghost"},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
urllib.request.urlopen(req)
|
urllib.request.urlopen(req)
|
||||||
|
|
@ -248,6 +279,247 @@ def test_http_post_install_unknown_app(fake_dirs):
|
||||||
server.server_close()
|
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 endpoints ------------------------------------------------------
|
||||||
|
|
||||||
SETTINGS_MANIFEST = dict(
|
SETTINGS_MANIFEST = dict(
|
||||||
|
|
@ -329,7 +601,7 @@ def test_update_settings_unknown_app(fake_dirs):
|
||||||
assert status == 404
|
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
|
_, bundled = fake_dirs
|
||||||
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
|
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
|
||||||
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
|
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 = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
try:
|
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
|
assert r.status == 200
|
||||||
data = json.loads(r.read())
|
data = json.loads(r.read())
|
||||||
assert data["name"] == "fileshare"
|
assert data["name"] == "fileshare"
|
||||||
|
|
@ -549,7 +823,7 @@ def test_furtka_update_status_endpoint(stub_furtka_updater):
|
||||||
assert stub_furtka_updater["status_called"] == 1
|
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
|
_, bundled = fake_dirs
|
||||||
_write_bundled(bundled, "fileshare", env_example="A=real")
|
_write_bundled(bundled, "fileshare", env_example="A=real")
|
||||||
api._do_install("fileshare")
|
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 = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(
|
req = _request(
|
||||||
f"http://127.0.0.1:{port}/api/apps/fileshare/update",
|
port,
|
||||||
data=b"{}",
|
"/api/apps/fileshare/update",
|
||||||
headers={"Content-Type": "application/json"},
|
cookie=admin_session,
|
||||||
method="POST",
|
method="POST",
|
||||||
|
body={},
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req) as r:
|
with urllib.request.urlopen(req) as r:
|
||||||
assert r.status == 200
|
assert r.status == 200
|
||||||
|
|
@ -576,7 +851,7 @@ def test_http_post_update_route(fake_dirs, no_docker, update_docker_stubs):
|
||||||
server.server_close()
|
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
|
_, bundled = fake_dirs
|
||||||
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
|
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
|
||||||
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
|
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 = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(
|
req = _request(
|
||||||
f"http://127.0.0.1:{port}/api/apps/install",
|
port,
|
||||||
data=json.dumps(
|
"/api/apps/install",
|
||||||
{
|
cookie=admin_session,
|
||||||
|
method="POST",
|
||||||
|
body={
|
||||||
"name": "fileshare",
|
"name": "fileshare",
|
||||||
"settings": {"SMB_USER": "alice", "SMB_PASSWORD": "s3cret"},
|
"settings": {"SMB_USER": "alice", "SMB_PASSWORD": "s3cret"},
|
||||||
}
|
},
|
||||||
).encode(),
|
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
method="POST",
|
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req) as r:
|
with urllib.request.urlopen(req) as r:
|
||||||
assert r.status == 200
|
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 = tmp_path / "payload.tar.gz"
|
||||||
fake.write_bytes(b"not a real tarball")
|
fake.write_bytes(b"not a real tarball")
|
||||||
monkeypatch.setattr(app, "RESOURCE_MANAGER_PAYLOAD", fake)
|
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)
|
@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():
|
def test_assets_dir_resolves_to_repo_tree():
|
||||||
assert app._ASSETS_DIR == ASSETS
|
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 re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import UTC
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from drives import list_scored_devices
|
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
|
# nss-mdns: splice `mdns_minimal [NOTFOUND=return]` before `resolve` on
|
||||||
# the hosts line so `*.local` works from the installed system too. Guarded
|
# 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
|
# 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
|
# furtka.json depends on /opt/furtka/current/VERSION, so it has to
|
||||||
# run after the resource-manager commands.
|
# run after the resource-manager commands.
|
||||||
_furtka_json_cmd(hostname),
|
_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.
|
# page, status timer, and welcome banner into place.
|
||||||
"custom_commands": [
|
"custom_commands": [
|
||||||
f"gpasswd -a {s['username']} docker",
|
f"gpasswd -a {s['username']} docker",
|
||||||
*_post_install_commands(s["hostname"]),
|
*_post_install_commands(s["hostname"], s["username"], s["password"]),
|
||||||
],
|
],
|
||||||
"network_config": {"type": "iso"},
|
"network_config": {"type": "iso"},
|
||||||
"ssh": True,
|
"ssh": True,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue