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:
2026-06-06 18:03:09 -05:00
parent 3a48021260
commit b5a136ed41
7 changed files with 447 additions and 1 deletions

65
templates/enroll.html Normal file
View 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
View 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>

View File

@@ -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 %}