Add WebAuthn passkey auth (dormant behind AUTH_ENABLED)
- auth.py: passkey credential store (public keys only) + env-driven RP config. - auth_routes.py: /login, /enroll, /logout + /auth/* ceremony endpoints + before_request gate (no-op unless AUTH_ENABLED=1). - login/enroll pages (SimpleWebAuthn browser); settings page lists + removes enrolled devices. requirements: webauthn==2.7.1. - Deploys OFF: no behavior change until enabled after the hostname/cert exist. Closes groundwork for #5. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
9
app.py
9
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/<path:p>")
|
||||
@@ -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")
|
||||
|
||||
94
auth.py
Normal file
94
auth.py
Normal file
@@ -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)
|
||||
198
auth_routes.py
Normal file
198
auth_routes.py
Normal file
@@ -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))
|
||||
@@ -8,3 +8,4 @@ pdfplumber==0.11.8
|
||||
python-dateutil
|
||||
requests
|
||||
gunicorn
|
||||
webauthn==2.7.1
|
||||
|
||||
65
templates/enroll.html
Normal file
65
templates/enroll.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Add a device · Moon Household Budget</title>
|
||||
<script src="https://unpkg.com/@simplewebauthn/browser@9.0.1/dist/bundle/index.umd.min.js"></script>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background:#FAF7F2;
|
||||
display:flex; min-height:100vh; align-items:center; justify-content:center; margin:0; color:#3A3330; }
|
||||
.card { background:#fff; border:1px solid #EDE8E2; border-radius:16px; padding:2rem; max-width:380px;
|
||||
width:90%; text-align:center; box-shadow:0 4px 16px rgba(80,60,50,0.08); }
|
||||
h1 { font-size:1.25rem; margin:0 0 .25rem; }
|
||||
p.sub { color:#7A6E68; margin:0 0 1.25rem; font-size:.95rem; }
|
||||
input { width:100%; box-sizing:border-box; padding:.7rem; border:1px solid #cfcfd4; border-radius:10px;
|
||||
font:inherit; margin-bottom:.6rem; }
|
||||
button { background:#4338ca; color:#fff; border:0; border-radius:10px; padding:.8rem 1.2rem;
|
||||
font:inherit; font-weight:600; cursor:pointer; width:100%; }
|
||||
button:disabled { opacity:.5; }
|
||||
.msg { min-height:1.2rem; margin-top:.9rem; font-size:.9rem; }
|
||||
.err { color:#C57A6E; } .ok { color:#5A9E68; }
|
||||
a { color:#4338ca; font-size:.85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>🌙 Add this device</h1>
|
||||
<p class="sub">Create a passkey (Touch ID / Face ID / security key) for this device.</p>
|
||||
<input id="label" placeholder="Device name — e.g. Tony's iPhone" autocomplete="off">
|
||||
{% if needs_code %}<input id="code" type="password" placeholder="Enrollment code" autocomplete="off">{% endif %}
|
||||
<button id="enroll">🔑 Create passkey</button>
|
||||
<div class="msg" id="msg"></div>
|
||||
<p style="margin-top:1.25rem;"><a href="/login">Back to sign in</a></p>
|
||||
</div>
|
||||
<script>
|
||||
const { startRegistration } = SimpleWebAuthnBrowser;
|
||||
const msg = document.getElementById('msg');
|
||||
const btn = document.getElementById('enroll');
|
||||
btn.onclick = async () => {
|
||||
btn.disabled = true; msg.className = 'msg'; msg.textContent = 'Follow your device prompt…';
|
||||
const label = document.getElementById('label').value || 'Device';
|
||||
const codeEl = document.getElementById('code');
|
||||
const code = codeEl ? codeEl.value : undefined;
|
||||
try {
|
||||
const begin = await fetch('/auth/register/begin', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label, code }),
|
||||
});
|
||||
if (!begin.ok) { msg.className = 'msg err'; msg.textContent = 'Not allowed to enroll (check the code).'; btn.disabled = false; return; }
|
||||
const opts = await begin.json();
|
||||
const attestation = await startRegistration(opts);
|
||||
const res = await fetch('/auth/register/complete', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(attestation),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) { msg.className = 'msg ok'; msg.textContent = '✓ Passkey created! Redirecting…'; setTimeout(() => window.location = '/', 900); }
|
||||
else { msg.className = 'msg err'; msg.textContent = 'Failed: ' + (data.error || 'try again'); btn.disabled = false; }
|
||||
} catch (e) {
|
||||
msg.className = 'msg err'; msg.textContent = 'Failed: ' + e.message; btn.disabled = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
57
templates/login.html
Normal file
57
templates/login.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign in · Moon Household Budget</title>
|
||||
<script src="https://unpkg.com/@simplewebauthn/browser@9.0.1/dist/bundle/index.umd.min.js"></script>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background:#FAF7F2;
|
||||
display:flex; min-height:100vh; align-items:center; justify-content:center; margin:0; color:#3A3330; }
|
||||
.card { background:#fff; border:1px solid #EDE8E2; border-radius:16px; padding:2rem; max-width:380px;
|
||||
width:90%; text-align:center; box-shadow:0 4px 16px rgba(80,60,50,0.08); }
|
||||
h1 { font-size:1.3rem; margin:0 0 .25rem; }
|
||||
p.sub { color:#7A6E68; margin:0 0 1.5rem; }
|
||||
button { background:#4338ca; color:#fff; border:0; border-radius:10px; padding:.8rem 1.2rem;
|
||||
font:inherit; font-weight:600; cursor:pointer; width:100%; }
|
||||
button:disabled { opacity:.5; }
|
||||
.msg { min-height:1.2rem; margin-top:.9rem; font-size:.9rem; color:#C57A6E; }
|
||||
a { color:#4338ca; font-size:.85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>🌙 Moon Household Budget</h1>
|
||||
<p class="sub">Sign in with your passkey</p>
|
||||
<button id="login">🔑 Sign in with passkey</button>
|
||||
<div class="msg" id="msg"></div>
|
||||
<p style="margin-top:1.25rem;"><a href="/enroll">Set up a new device</a></p>
|
||||
</div>
|
||||
<script>
|
||||
const { startAuthentication } = SimpleWebAuthnBrowser;
|
||||
const msg = document.getElementById('msg');
|
||||
const btn = document.getElementById('login');
|
||||
btn.onclick = async () => {
|
||||
btn.disabled = true; msg.textContent = 'Waiting for your passkey…';
|
||||
try {
|
||||
const opts = await (await fetch('/auth/login/begin', { method: 'POST' })).json();
|
||||
const assertion = await startAuthentication(opts);
|
||||
const res = await fetch('/auth/login/complete', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(assertion),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
window.location = new URLSearchParams(location.search).get('next') || '/';
|
||||
} else {
|
||||
msg.textContent = 'Sign-in failed: ' + (data.error || 'try again');
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
msg.textContent = 'Sign-in failed: ' + e.message;
|
||||
btn.disabled = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -55,5 +55,29 @@
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save settings</button>
|
||||
</form>
|
||||
|
||||
<fieldset style="border:1px solid #EDE8E2;border-radius:10px;padding:1rem 1.25rem;margin-top:1.5rem;">
|
||||
<legend style="padding:0 .5rem;font-weight:600;">🔑 Passkeys (sign-in)</legend>
|
||||
<p style="font-size:.85rem;color:#7A6E68;margin-top:0;">
|
||||
Login is currently <strong>{{ 'ON' if auth_enabled else 'OFF' }}</strong>.
|
||||
Devices enrolled with a passkey (Touch ID / Face ID / security key):
|
||||
</p>
|
||||
{% if passkeys %}
|
||||
<ul style="list-style:none;padding:0;margin:0 0 .75rem;">
|
||||
{% for d in passkeys %}
|
||||
<li style="display:flex;align-items:center;justify-content:space-between;border:1px solid #EDE8E2;border-radius:8px;padding:.5rem .75rem;margin-bottom:.4rem;">
|
||||
<span>🔑 {{ d.label }}</span>
|
||||
<form method="POST" action="{{ url_for('auth.device_remove') }}" onsubmit="return confirm('Remove this passkey?');" style="margin:0;">
|
||||
<input type="hidden" name="id" value="{{ d.id }}">
|
||||
<button type="submit" style="background:none;border:0;color:#C57A6E;cursor:pointer;font-size:.8rem;">Remove</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p style="font-size:.85rem;color:#B0A89E;">No devices enrolled yet.</p>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('auth.enroll') }}" class="btn btn-primary" style="display:inline-block;text-decoration:none;">+ Add a device</a>
|
||||
</fieldset>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user