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
@@ -0,0 +1,39 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Stores a WebAuthn public-key credential (passkey) registered by an application user.
/// One row per device per user. Does not inherit BaseEntity — passkeys are identity
/// credentials, not business-domain records, and require no soft-delete or company-scoped
/// global query filter (the Login flow queries across tenants by credentialId before auth).
/// </summary>
public class UserPasskey
{
public int Id { get; set; }
/// <summary>FK to AspNetUsers.Id (GUID string).</summary>
public string UserId { get; set; } = default!;
/// <summary>Stored for display/management queries. NOT used as a query filter.</summary>
public int CompanyId { get; set; }
/// <summary>WebAuthn credential ID — unique identifier for this passkey.</summary>
public byte[] CredentialId { get; set; } = default!;
/// <summary>COSE-encoded public key from the authenticator.</summary>
public byte[] PublicKey { get; set; } = default!;
/// <summary>Opaque user handle sent by the authenticator during login.</summary>
public byte[] UserHandle { get; set; } = default!;
/// <summary>
/// Monotonically increasing counter used to detect cloned authenticators.
/// Stored as long to avoid SQL Server uint mapping issues; Fido2NetLib uses uint.
/// </summary>
public long SignCount { get; set; }
/// <summary>User-supplied or browser-provided friendly name, e.g. "Scott's iPhone".</summary>
public string? DeviceFriendlyName { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastUsedAt { get; set; }
}