diff --git a/app.py b/app.py index 3753d92..c0af29c 100644 --- a/app.py +++ b/app.py @@ -8,6 +8,7 @@ import calendar as cal_module from storage import load_json, save_json from settings import load_settings, save_settings, SECRET_KEYS import txns_db +import auth from version import __version__ as APP_VERSION, CHANGELOG from categories import CATEGORIES, CATEGORY_COLORS from bills import load_bills, save_bills, bills_for_month, calendar_grid, get_week_num @@ -35,6 +36,10 @@ app.secret_key = os.environ.get("SECRET_KEY", "fullmoonbakehouse") from suggestions.routes_flask import suggestions_bp app.register_blueprint(suggestions_bp) +# Passkey (WebAuthn) auth — blueprint + login gate. No-op until AUTH_ENABLED=1. +from auth_routes import init_auth +init_auth(app) + from flask import send_from_directory @app.route("/uploads/") @@ -2881,7 +2886,9 @@ def settings_view(): secrets_status = {k: bool(current.get(k)) for k in SECRET_KEYS} return render_template("settings.html", settings=safe, secrets_status=secrets_status, - saved=request.args.get("saved")) + saved=request.args.get("saved"), + passkeys=auth.list_credentials(), + auth_enabled=auth.auth_enabled()) @app.route("/changelog") diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..68f03e1 --- /dev/null +++ b/auth.py @@ -0,0 +1,94 @@ +"""Passkey (WebAuthn) credential storage + config. + +One "household" identity, many enrolled devices (each device = one credential). +Credentials live in passkeys.json on the volume (public keys only — there is no +shared password to leak). RP config + the AUTH_ENABLED gate come from env so the +hostname can change (mkcert host, Tailscale ts.net name, etc.) without code edits. +""" +import os +import secrets + +from storage import DATA_DIR, load_json, save_json + +PASSKEYS_FILE = os.path.join(DATA_DIR, "passkeys.json") + + +# ---- config (env-driven) ---- +def rp_id(): + return os.environ.get("WEBAUTHN_RP_ID", "budget.nastynas.xyz") + + +def rp_name(): + return os.environ.get("WEBAUTHN_RP_NAME", "Moon Household Budget") + + +def origin(): + return os.environ.get("WEBAUTHN_ORIGIN", f"https://{rp_id()}") + + +def auth_enabled(): + return os.environ.get("AUTH_ENABLED", "0").lower() in ("1", "true", "yes", "on") + + +def enrollment_code(): + return os.environ.get("PASSKEY_ENROLLMENT_CODE", "") + + +# ---- credential store ---- +def _data(): + d = load_json(PASSKEYS_FILE, {}) or {} + changed = False + if "credentials" not in d: + d["credentials"] = [] + changed = True + if not d.get("user_handle"): + d["user_handle"] = secrets.token_urlsafe(16) + changed = True + if changed: + save_json(PASSKEYS_FILE, d) + return d + + +def user_handle_bytes(): + return _data()["user_handle"].encode() + + +def list_credentials(): + return _data()["credentials"] + + +def has_credentials(): + return len(_data()["credentials"]) > 0 + + +def get_credential(cred_id_b64): + for c in _data()["credentials"]: + if c["id"] == cred_id_b64: + return c + return None + + +def add_credential(cred_id_b64, public_key_b64, sign_count, label, transports=None): + d = _data() + d["credentials"].append({ + "id": cred_id_b64, + "public_key": public_key_b64, + "sign_count": sign_count, + "label": label or "Device", + "transports": transports or [], + }) + save_json(PASSKEYS_FILE, d) + + +def update_sign_count(cred_id_b64, new_count): + d = _data() + for c in d["credentials"]: + if c["id"] == cred_id_b64: + c["sign_count"] = new_count + save_json(PASSKEYS_FILE, d) + + +def remove_credential(cred_id_b64): + d = _data() + d["credentials"] = [c for c in d["credentials"] if c["id"] != cred_id_b64] + save_json(PASSKEYS_FILE, d) diff --git a/auth_routes.py b/auth_routes.py new file mode 100644 index 0000000..649e27b --- /dev/null +++ b/auth_routes.py @@ -0,0 +1,198 @@ +"""WebAuthn passkey routes + the login gate. + +Pages: /login (passkey sign-in), /enroll (register a device), /logout. +JSON ceremony endpoints under /auth/*. init_auth(app) wires the blueprint and a +before_request gate that protects everything when AUTH_ENABLED is on. +""" +import json +from datetime import timedelta + +from flask import (Blueprint, abort, jsonify, redirect, render_template, + request, session, url_for) +from webauthn import (generate_authentication_options, + generate_registration_options, options_to_json, + verify_authentication_response, + verify_registration_response) +from webauthn.helpers import base64url_to_bytes, bytes_to_base64url +from webauthn.helpers.structs import (AuthenticatorSelectionCriteria, + PublicKeyCredentialDescriptor, + ResidentKeyRequirement, + UserVerificationRequirement) + +import auth + +auth_bp = Blueprint("auth", __name__) + +# Endpoints reachable WITHOUT a session (the login/enroll flow itself). +OPEN_ENDPOINTS = { + "auth.login", "auth.enroll", "auth.logout", + "auth.register_begin", "auth.register_complete", + "auth.login_begin", "auth.login_complete", + "static", +} + + +def _json_body(): + return request.get_json(silent=True) or {} + + +def _enroll_authorized(): + """May the caller register a new device right now?""" + if session.get("authed"): + return True + code = auth.enrollment_code() + if code: + supplied = (_json_body().get("code") + or request.args.get("code") + or request.headers.get("X-Enroll-Code")) + return supplied == code + # No code configured: allow only to bootstrap the very first device. + return not auth.has_credentials() + + +# ---- pages ---- +@auth_bp.route("/login") +def login(): + if session.get("authed"): + return redirect(url_for("index")) + return render_template("login.html", rp_id=auth.rp_id(), + has_credentials=auth.has_credentials()) + + +@auth_bp.route("/enroll") +def enroll(): + return render_template("enroll.html", rp_id=auth.rp_id(), + needs_code=bool(auth.enrollment_code()) and not session.get("authed"), + logged_in=bool(session.get("authed"))) + + +@auth_bp.route("/logout", methods=["GET", "POST"]) +def logout(): + session.clear() + return redirect(url_for("auth.login")) + + +# ---- registration ceremony ---- +@auth_bp.route("/auth/register/begin", methods=["POST"]) +def register_begin(): + if not _enroll_authorized(): + return jsonify({"error": "not_authorized"}), 403 + creds = auth.list_credentials() + opts = generate_registration_options( + rp_id=auth.rp_id(), + rp_name=auth.rp_name(), + user_name="household", + user_id=auth.user_handle_bytes(), + user_display_name="Moon Household", + exclude_credentials=[ + PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["id"])) for c in creds + ], + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + user_verification=UserVerificationRequirement.PREFERRED, + ), + ) + session["reg_challenge"] = bytes_to_base64url(opts.challenge) + session["reg_label"] = (_json_body().get("label") or "Device")[:60] + session["enroll_ok"] = True + return options_to_json(opts), 200, {"Content-Type": "application/json"} + + +@auth_bp.route("/auth/register/complete", methods=["POST"]) +def register_complete(): + if not session.pop("enroll_ok", False): + return jsonify({"error": "not_authorized"}), 403 + challenge = session.pop("reg_challenge", None) + if not challenge: + return jsonify({"error": "no_challenge"}), 400 + try: + v = verify_registration_response( + credential=request.get_data(as_text=True), + expected_challenge=base64url_to_bytes(challenge), + expected_rp_id=auth.rp_id(), + expected_origin=auth.origin(), + require_user_verification=False, + ) + except Exception as e: + return jsonify({"error": f"verify_failed: {e}"}), 400 + auth.add_credential( + bytes_to_base64url(v.credential_id), + bytes_to_base64url(v.credential_public_key), + v.sign_count, + session.pop("reg_label", "Device"), + ) + session["authed"] = True + session.permanent = True + return jsonify({"ok": True}) + + +# ---- authentication ceremony ---- +@auth_bp.route("/auth/login/begin", methods=["POST"]) +def login_begin(): + creds = auth.list_credentials() + opts = generate_authentication_options( + rp_id=auth.rp_id(), + allow_credentials=[ + PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["id"])) for c in creds + ], + user_verification=UserVerificationRequirement.PREFERRED, + ) + session["auth_challenge"] = bytes_to_base64url(opts.challenge) + return options_to_json(opts), 200, {"Content-Type": "application/json"} + + +@auth_bp.route("/auth/login/complete", methods=["POST"]) +def login_complete(): + challenge = session.pop("auth_challenge", None) + if not challenge: + return jsonify({"error": "no_challenge"}), 400 + body = request.get_data(as_text=True) + cred = auth.get_credential(json.loads(body).get("id")) + if not cred: + return jsonify({"error": "unknown_credential"}), 400 + try: + v = verify_authentication_response( + credential=body, + expected_challenge=base64url_to_bytes(challenge), + expected_rp_id=auth.rp_id(), + expected_origin=auth.origin(), + credential_public_key=base64url_to_bytes(cred["public_key"]), + credential_current_sign_count=cred["sign_count"], + require_user_verification=False, + ) + except Exception as e: + return jsonify({"error": f"verify_failed: {e}"}), 400 + auth.update_sign_count(cred["id"], v.new_sign_count) + session["authed"] = True + session.permanent = True + return jsonify({"ok": True}) + + +@auth_bp.route("/auth/device/remove", methods=["POST"]) +def device_remove(): + if auth.auth_enabled() and not session.get("authed"): + abort(403) + cid = request.form.get("id") + if cid: + auth.remove_credential(cid) + return redirect(url_for("settings_view")) + + +def init_auth(app): + app.permanent_session_lifetime = timedelta(days=30) + app.register_blueprint(auth_bp) + + @app.before_request + def _gate(): + if not auth.auth_enabled(): + return + ep = request.endpoint or "" + if ep in OPEN_ENDPOINTS or ep.startswith("auth."): + return + if request.path.startswith(("/static", "/suggestions-static", "/auth/")): + return + if session.get("authed"): + return + if request.path.startswith("/api/") or request.is_json: + return jsonify({"error": "unauthorized"}), 401 + return redirect(url_for("auth.login", next=request.path)) diff --git a/requirements.txt b/requirements.txt index 16986f7..c99dc38 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ pdfplumber==0.11.8 python-dateutil requests gunicorn +webauthn==2.7.1 diff --git a/templates/enroll.html b/templates/enroll.html new file mode 100644 index 0000000..9a36e65 --- /dev/null +++ b/templates/enroll.html @@ -0,0 +1,65 @@ + + + + + + Add a device · Moon Household Budget + + + + +
+

🌙 Add this device

+

Create a passkey (Touch ID / Face ID / security key) for this device.

+ + {% if needs_code %}{% endif %} + +
+

Back to sign in

+
+ + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..413bf51 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,57 @@ + + + + + + Sign in · Moon Household Budget + + + + +
+

🌙 Moon Household Budget

+

Sign in with your passkey

+ +
+

Set up a new device

+
+ + + diff --git a/templates/settings.html b/templates/settings.html index e59b73a..5c2c505 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -55,5 +55,29 @@ + +
+ 🔑 Passkeys (sign-in) +

+ Login is currently {{ 'ON' if auth_enabled else 'OFF' }}. + Devices enrolled with a passkey (Touch ID / Face ID / security key): +

+ {% if passkeys %} + + {% else %} +

No devices enrolled yet.

+ {% endif %} + + Add a device +
{% endblock %}