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:
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)
|
||||
Reference in New Issue
Block a user