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

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