furtka/tests/test_auth.py

153 lines
4.5 KiB
Python
Raw Normal View History

feat(auth): login-guard the Furtka UI with a cookie session 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>
2026-04-21 13:01:17 +02:00
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)