Files
Moon-Household-Budget/auth.py
tonym b5a136ed41 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>
2026-06-06 18:03:09 -05:00

95 lines
2.4 KiB
Python

"""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)