Add passkey / biometric login (WebAuthn FIDO2)

Shop floor workers can log in once with a password, enroll a passkey,
and use Face ID / Windows Hello / fingerprint for all future logins.

- UserPasskey entity + AddUserPasskeys migration (Fido2 v4.0.1)
- PasskeyController: RegisterOptions, Register, LoginOptions, Login,
  Manage, Remove endpoints
- Login page: platform-aware button (Face ID / Windows Hello / etc.)
  hidden automatically if browser doesn't support WebAuthn
- Post-login floating prompt to enroll on first use; session-dismissed
- Passkeys & Biometrics link in user dropdown menu
- Manage page: list registered devices, add new, remove individual
- passkey.js: targeted base64url conversion (only challenge + user.id
  + credential IDs) — fixes "Required parameters missing" error caused
  by blindly converting rp.id and other string fields to ArrayBuffers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 15:07:01 -04:00
parent 4f976b1332
commit 0bb96a502a
16 changed files with 16101 additions and 17 deletions
@@ -247,6 +247,17 @@
</div>
</form>
<!-- Passkey / Biometric login — shown only if browser supports WebAuthn -->
<div class="passkey-login-section">
<div class="auth-divider"><span>or</span></div>
<div class="d-grid mb-2">
<button id="passkey-login-btn" type="button" class="btn btn-outline-secondary btn-lg d-flex align-items-center justify-content-center gap-2">
<i class="bi bi-fingerprint"></i> Use Face ID / Biometric
</button>
</div>
<p id="passkey-error" class="text-danger small text-center d-none mb-0"></p>
</div>
@if (Model.SignupOpen)
{
<div class="auth-divider"><span>or</span></div>
@@ -269,17 +280,6 @@
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
document.getElementById('togglePw').addEventListener('click', function () {
var input = document.getElementById('passwordInput');
var icon = document.getElementById('togglePwIcon');
if (input.type === 'password') {
input.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
input.type = 'password';
icon.className = 'bi bi-eye';
}
});
</script>
<script src="~/js/login-toggle-pw.js"></script>
<script src="~/js/passkey.js"></script>
}