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
File diff suppressed because it is too large Load Diff
@@ -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; }
}
@@ -385,6 +385,13 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
/// </summary>
public DbSet<PendingRegistrationSession> PendingRegistrationSessions { get; set; }
/// <summary>
/// WebAuthn passkey credentials registered by users for biometric login (Face ID, fingerprint).
/// No global query filter — the login flow queries by credentialId before authentication,
/// requiring cross-tenant lookup. Per-user isolation is enforced in the controller.
/// </summary>
public DbSet<UserPasskey> UserPasskeys { get; set; }
/// <summary>
/// Configures the EF Core model: applies entity type configurations from the assembly,
/// registers global query filters, defines relationships, adds performance indexes, and seeds
@@ -792,9 +799,14 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
property.SetColumnType("decimal(18,2)");
}
// UserPasskey: unique index on CredentialId (WebAuthn requires global uniqueness)
modelBuilder.Entity<UserPasskey>()
.HasIndex(p => p.CredentialId)
.IsUnique();
// Configure relationships
ConfigureRelationships(modelBuilder);
// Seed initial data
SeedInitialData(modelBuilder);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,91 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddUserPasskeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserPasskeys",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
CredentialId = table.Column<byte[]>(type: "varbinary(900)", nullable: false),
PublicKey = table.Column<byte[]>(type: "varbinary(max)", nullable: false),
UserHandle = table.Column<byte[]>(type: "varbinary(max)", nullable: false),
SignCount = table.Column<long>(type: "bigint", nullable: false),
DeviceFriendlyName = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
LastUsedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_UserPasskeys", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4555));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4562));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4563));
migrationBuilder.CreateIndex(
name: "IX_UserPasskeys_CredentialId",
table: "UserPasskeys",
column: "CredentialId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserPasskeys");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5147));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5155));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5156));
}
}
}
@@ -5782,7 +5782,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5147),
CreatedAt = new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4555),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -5793,7 +5793,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5155),
CreatedAt = new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4562),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -5804,7 +5804,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 4, 25, 12, 32, 52, 295, DateTimeKind.Utc).AddTicks(5156),
CreatedAt = new DateTime(2026, 4, 25, 18, 27, 8, 537, DateTimeKind.Utc).AddTicks(4563),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7255,6 +7255,53 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("TermsAcceptances");
});
modelBuilder.Entity("PowderCoating.Core.Entities.UserPasskey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<byte[]>("CredentialId")
.IsRequired()
.HasColumnType("varbinary(900)");
b.Property<string>("DeviceFriendlyName")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("LastUsedAt")
.HasColumnType("datetime2");
b.Property<byte[]>("PublicKey")
.IsRequired()
.HasColumnType("varbinary(max)");
b.Property<long>("SignCount")
.HasColumnType("bigint");
b.Property<byte[]>("UserHandle")
.IsRequired()
.HasColumnType("varbinary(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CredentialId")
.IsUnique();
b.ToTable("UserPasskeys");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
{
b.Property<int>("Id")
@@ -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>
}
@@ -0,0 +1,272 @@
using System.Text;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Handles WebAuthn / FIDO2 passkey registration and authentication.
/// Registration requires an authenticated session (user logs in once with password,
/// then enrolls a passkey for future logins). Authentication is anonymous — the
/// browser sends the credential before any session exists.
/// </summary>
[Route("[controller]/[action]")]
public class PasskeyController : Controller
{
private readonly IFido2 _fido2;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly ApplicationDbContext _db;
private readonly ILogger<PasskeyController> _logger;
private const string RegChallengeKey = "passkey:reg:challenge";
private const string AuthChallengeKey = "passkey:auth:challenge";
public PasskeyController(
IFido2 fido2,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
ApplicationDbContext db,
ILogger<PasskeyController> logger)
{
_fido2 = fido2;
_userManager = userManager;
_signInManager = signInManager;
_db = db;
_logger = logger;
}
// ─── Registration ────────────────────────────────────────────────────────
/// <summary>
/// Returns a WebAuthn creation options object for the currently signed-in user.
/// Stores the challenge in session so Register can verify it.
/// </summary>
[HttpPost]
[Authorize]
public async Task<IActionResult> RegisterOptions()
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var existingKeys = await _db.UserPasskeys
.Where(p => p.UserId == user.Id)
.Select(p => p.CredentialId)
.ToListAsync();
var fidoUser = new Fido2User
{
Id = Encoding.UTF8.GetBytes(user.Id),
Name = user.Email!,
DisplayName = user.FullName ?? user.Email!
};
var excludeCredentials = existingKeys
.Select(k => new PublicKeyCredentialDescriptor(k))
.ToList();
var authenticatorSelection = new AuthenticatorSelection
{
ResidentKey = ResidentKeyRequirement.Required,
UserVerification = UserVerificationRequirement.Required
};
var options = _fido2.RequestNewCredential(new RequestNewCredentialParams
{
User = fidoUser,
ExcludeCredentials = excludeCredentials,
AuthenticatorSelection = authenticatorSelection,
AttestationPreference = AttestationConveyancePreference.None
});
HttpContext.Session.SetString(RegChallengeKey, options.ToJson());
return Ok(options);
}
/// <summary>
/// Verifies the authenticator response and persists the new passkey credential.
/// </summary>
[HttpPost]
[Authorize]
public async Task<IActionResult> Register(
[FromBody] AuthenticatorAttestationRawResponse attestationResponse,
[FromQuery] string? deviceName)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var optionsJson = HttpContext.Session.GetString(RegChallengeKey);
if (string.IsNullOrEmpty(optionsJson))
return BadRequest(new { error = "Session expired — please try again." });
HttpContext.Session.Remove(RegChallengeKey);
RegisteredPublicKeyCredential credential;
try
{
var options = CredentialCreateOptions.FromJson(optionsJson);
credential = await _fido2.MakeNewCredentialAsync(new MakeNewCredentialParams
{
AttestationResponse = attestationResponse,
OriginalOptions = options,
IsCredentialIdUniqueToUserCallback = async (args, _) =>
!await _db.UserPasskeys.AnyAsync(p => p.CredentialId == args.CredentialId)
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Passkey registration failed for user {UserId}", user.Id);
return BadRequest(new { error = ex.Message });
}
var passkey = new UserPasskey
{
UserId = user.Id,
CompanyId = user.CompanyId,
CredentialId = credential.Id,
PublicKey = credential.PublicKey,
UserHandle = credential.User.Id,
SignCount = credential.SignCount,
DeviceFriendlyName = string.IsNullOrWhiteSpace(deviceName) ? null : deviceName.Trim()
};
_db.UserPasskeys.Add(passkey);
await _db.SaveChangesAsync();
_logger.LogInformation("Passkey registered for user {UserId} ({DeviceName})",
user.Id, passkey.DeviceFriendlyName ?? "(unnamed)");
return Ok(new { message = "Passkey registered successfully." });
}
// ─── Authentication ───────────────────────────────────────────────────────
/// <summary>
/// Returns a WebAuthn assertion options object. No session required — called before login.
/// </summary>
[HttpPost]
[AllowAnonymous]
public IActionResult LoginOptions()
{
var options = _fido2.GetAssertionOptions(new GetAssertionOptionsParams
{
AllowedCredentials = [],
UserVerification = UserVerificationRequirement.Required
});
HttpContext.Session.SetString(AuthChallengeKey, options.ToJson());
return Ok(options);
}
/// <summary>
/// Verifies the assertion response against stored credentials and signs the user in.
/// </summary>
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> Login([FromBody] AuthenticatorAssertionRawResponse assertionResponse)
{
var optionsJson = HttpContext.Session.GetString(AuthChallengeKey);
if (string.IsNullOrEmpty(optionsJson))
return BadRequest(new { error = "Session expired — please try again." });
HttpContext.Session.Remove(AuthChallengeKey);
// Look up passkey by credential ID (RawId is byte[], Id is base64url string)
var credentialId = assertionResponse.RawId;
var passkey = await _db.UserPasskeys
.FirstOrDefaultAsync(p => p.CredentialId == credentialId);
if (passkey == null)
return BadRequest(new { error = "Passkey not recognised." });
// Load the user — verify account is still active
var user = await _userManager.FindByIdAsync(passkey.UserId);
if (user == null || !user.IsActive)
return BadRequest(new { error = "Account not found or deactivated." });
if (await _userManager.IsLockedOutAsync(user))
return BadRequest(new { error = "Account is locked. Please contact your administrator." });
VerifyAssertionResult verifyResult;
try
{
var options = AssertionOptions.FromJson(optionsJson);
verifyResult = await _fido2.MakeAssertionAsync(new MakeAssertionParams
{
AssertionResponse = assertionResponse,
OriginalOptions = options,
StoredPublicKey = passkey.PublicKey,
StoredSignatureCounter = (uint)passkey.SignCount,
IsUserHandleOwnerOfCredentialIdCallback = (args, _) =>
Task.FromResult(args.UserHandle.SequenceEqual(passkey.UserHandle))
});
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Passkey assertion failed for user {UserId}", passkey.UserId);
return BadRequest(new { error = "Passkey verification failed." });
}
// Update sign count and last-used timestamp
passkey.SignCount = verifyResult.SignCount;
passkey.LastUsedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
// Sign in — passkey satisfies both factors; no further 2FA required
await _signInManager.SignInAsync(user, isPersistent: false);
_logger.LogInformation("User {UserId} signed in via passkey", user.Id);
return Ok(new { redirectUrl = Url.Action("Index", "Dashboard") });
}
// ─── Management ───────────────────────────────────────────────────────────
/// <summary>Shows all passkeys registered by the current user.</summary>
[Authorize]
[HttpGet("/Passkey/Manage")]
public async Task<IActionResult> Manage()
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var passkeys = await _db.UserPasskeys
.Where(p => p.UserId == user.Id)
.OrderByDescending(p => p.CreatedAt)
.ToListAsync();
return View(passkeys);
}
/// <summary>Removes a specific passkey for the current user.</summary>
[HttpPost]
[Authorize]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Remove(int id)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Unauthorized();
var passkey = await _db.UserPasskeys
.FirstOrDefaultAsync(p => p.Id == id && p.UserId == user.Id);
if (passkey == null)
return NotFound();
_db.UserPasskeys.Remove(passkey);
await _db.SaveChangesAsync();
_logger.LogInformation("Passkey {PasskeyId} removed for user {UserId}", id, user.Id);
TempData["Success"] = "Passkey removed.";
return RedirectToAction(nameof(Manage));
}
}
@@ -18,6 +18,8 @@
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Azure.Monitor.Query" Version="1.7.1" />
<PackageReference Include="EPPlus" Version="7.0.0" />
<PackageReference Include="Fido2" Version="4.0.1" />
<PackageReference Include="Fido2.AspNet" Version="4.0.1" />
<PackageReference Include="Markdig" Version="0.40.0" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.11" />
+11
View File
@@ -290,6 +290,17 @@ builder.Services.AddSession(options =>
// Add memory cache
builder.Services.AddMemoryCache();
// Register Fido2/WebAuthn for passkey (biometric) login
builder.Services.AddFido2(options =>
{
options.ServerDomain = builder.Configuration["Fido2:ServerDomain"] ?? "localhost";
options.ServerName = builder.Configuration["Fido2:ServerName"] ?? "Powder Coating Logix";
var origins = builder.Configuration.GetSection("Fido2:Origins").Get<HashSet<string>>();
if (origins?.Count > 0) options.Origins = origins;
options.TimestampDriftTolerance = int.Parse(
builder.Configuration["Fido2:TimestampDriftTolerance"] ?? "300");
});
// Configure authorization policies for multi-tenancy
builder.Services.AddAuthorization(options =>
{
@@ -0,0 +1,97 @@
@model IEnumerable<PowderCoating.Core.Entities.UserPasskey>
@{
ViewData["Title"] = "My Passkeys";
}
<div class="container-fluid py-4" style="max-width:760px;">
<div class="d-flex align-items-center gap-3 mb-4">
<div class="rounded-circle d-flex align-items-center justify-content-center"
style="width:48px;height:48px;background:#e0f2fe;">
<i class="bi bi-fingerprint" style="font-size:1.5rem;color:#0284c7;"></i>
</div>
<div>
<h4 class="mb-0 fw-semibold">Passkeys &amp; Biometric Login</h4>
<p class="text-muted small mb-0">
Passkeys let you sign in with Face ID, fingerprint, or your device PIN — no password needed.
</p>
</div>
</div>
@if (TempData["Success"] is string msg)
{
<div class="alert alert-success alert-permanent">
<i class="bi bi-check-circle-fill me-2"></i>@msg
</div>
}
<!-- Add new passkey -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<h6 class="card-title mb-1">Add a passkey for this device</h6>
<p class="text-muted small mb-3">
You'll be prompted to authenticate using Face ID, Touch ID, Windows Hello, or a security key.
</p>
<div class="d-flex gap-2 align-items-center flex-wrap">
<input type="text" id="pk-device-name" class="form-control" style="max-width:220px;"
placeholder="Device name (e.g. iPhone 15)" maxlength="64" />
<button type="button" id="pk-add-btn" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>Add Passkey
</button>
</div>
<p id="pk-add-status" class="mt-2 small mb-0"></p>
</div>
</div>
<!-- Existing passkeys -->
@if (!Model.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-fingerprint" style="font-size:3rem;opacity:.3;"></i>
<p class="mt-3">No passkeys registered yet.<br />Add one above to enable biometric login on this device.</p>
</div>
}
else
{
<div class="list-group shadow-sm">
@foreach (var pk in Model)
{
<div class="list-group-item list-group-item-action d-flex align-items-center gap-3">
<i class="bi bi-phone" style="font-size:1.4rem;color:#64748b;flex-shrink:0;"></i>
<div class="flex-grow-1 min-width-0">
<div class="fw-medium text-truncate">
@(pk.DeviceFriendlyName ?? "Unnamed device")
</div>
<div class="text-muted small">
Added @pk.CreatedAt.ToLocalTime().ToString("MMM d, yyyy")
@if (pk.LastUsedAt.HasValue)
{
<span class="ms-2">&bull; Last used @pk.LastUsedAt.Value.ToLocalTime().ToString("MMM d, yyyy")</span>
}
</div>
</div>
<form method="post" asp-action="Remove" asp-route-id="@pk.Id"
onsubmit="return confirm('Remove this passkey?');">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash3"></i> Remove
</button>
</form>
</div>
}
</div>
<p class="text-muted small mt-3">
Removing a passkey means you'll need to use your password on that device next time.
</p>
}
<div class="mt-4">
<a asp-controller="CompanySettings" asp-action="Index" class="text-decoration-none">
<i class="bi bi-arrow-left me-1"></i>Back to Settings
</a>
</div>
</div>
@section Scripts {
<script src="~/js/passkey.js"></script>
<script src="~/js/passkey-manage.js"></script>
}
@@ -895,6 +895,33 @@
<div id="tempdata-info-message" style="display:none;">@TempData["Info"]</div>
}
@* Passkey setup prompt — shown once per session to authenticated users who have no passkeys yet *@
@if (User.Identity?.IsAuthenticated == true && !User.IsInRole("SuperAdmin"))
{
<div id="passkey-setup-prompt" class="d-none"
style="position:fixed;bottom:1.25rem;right:1.25rem;z-index:1090;max-width:320px;">
<div class="card shadow-lg border-0">
<div class="card-body p-3">
<div class="d-flex align-items-start gap-2 mb-2">
<i class="bi bi-fingerprint text-primary" style="font-size:1.4rem;flex-shrink:0;margin-top:2px;"></i>
<div>
<div class="fw-semibold" style="font-size:.9rem;">Enable Face ID / Biometric Login</div>
<div class="text-muted" style="font-size:.8rem;">Skip the password next time — use your fingerprint or Face ID.</div>
</div>
<button type="button" id="passkey-dismiss-btn" class="btn-close ms-auto" style="font-size:.75rem;" aria-label="Dismiss"></button>
</div>
<p id="passkey-setup-status" class="small mb-2"></p>
<div class="d-flex gap-2">
<button id="passkey-enable-btn" type="button" class="btn btn-primary btn-sm flex-grow-1">
<i class="bi bi-fingerprint me-1"></i>Enable
</button>
<a href="/Passkey/Manage" class="btn btn-outline-secondary btn-sm">Manage</a>
</div>
</div>
</div>
</div>
}
@* Hidden container for ModelState errors (read by toast-notifications.js) *@
@if (!ViewData.ModelState.IsValid && ViewData.ModelState.ErrorCount > 0)
{
@@ -1487,6 +1514,7 @@
}
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" asp-controller="Profile" asp-action="Index"><i class="bi bi-person me-2"></i>Profile</a></li>
<li><a class="dropdown-item" asp-controller="Passkey" asp-action="Manage"><i class="bi bi-fingerprint me-2"></i>Passkeys &amp; Biometrics</a></li>
<li><a class="dropdown-item" asp-controller="TwoFactorSetup" asp-action="Index"><i class="bi bi-shield-lock me-2"></i>Two-Factor Auth</a></li>
<li><a class="dropdown-item" asp-controller="ReleaseNotes" asp-action="Index"><i class="bi bi-rocket-takeoff me-2"></i>What's New</a></li>
<li><a class="dropdown-item" asp-controller="Help" asp-action="Index"><i class="bi bi-question-circle me-2"></i>Help</a></li>
@@ -2091,6 +2119,7 @@
{
@* @await Html.PartialAsync("_AiQuickQuoteWidget") *@
@await Html.PartialAsync("_AiHelpWidget")
<script src="~/js/passkey.js"></script>
}
<!-- ── Quick-Add Modal (reusable inline form host) ─────────────────────── -->
+6
View File
@@ -68,6 +68,12 @@
"Enterprise": "price_enterprise_monthly_id_here"
}
},
"Fido2": {
"ServerDomain": "localhost",
"ServerName": "Powder Coating Logix",
"Origins": [ "https://localhost:58461", "http://localhost:58462" ],
"TimestampDriftTolerance": 300
},
"Storage": {
"ConnectionString": "DefaultEndpointsProtocol=https;AccountName=powdercoatingappdev;AccountKey=DN3eVfhytXb7aBC0md9h/6jE0Uzg6FJ+PK6MFc772qyqpf0kgTeXH0C2VCBBun9PiuItPd9CDKTP+ASthFCuCg==;EndpointSuffix=core.windows.net",
"Containers": {
@@ -0,0 +1,11 @@
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';
}
});
@@ -0,0 +1,42 @@
document.addEventListener('DOMContentLoaded', async () => {
const addBtn = document.getElementById('pk-add-btn');
const statusEl = document.getElementById('pk-add-status');
const deviceNameInput = document.getElementById('pk-device-name');
if (!addBtn) return;
const supported = await passkeySupported();
if (!supported) {
addBtn.disabled = true;
addBtn.textContent = 'Not supported on this browser';
if (statusEl) {
statusEl.textContent = 'Your browser does not support passkeys. Try Safari on iOS 16+, Chrome 108+, or Edge 108+.';
statusEl.className = 'mt-2 small mb-0 text-muted';
}
return;
}
addBtn.addEventListener('click', async () => {
const name = deviceNameInput.value.trim();
addBtn.disabled = true;
addBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Follow the prompt…';
if (statusEl) { statusEl.textContent = ''; statusEl.className = 'mt-2 small mb-0'; }
const result = await registerPasskey(name);
if (result.success) {
if (statusEl) {
statusEl.textContent = '✓ Passkey added! Reloading…';
statusEl.className = 'mt-2 small mb-0 text-success';
}
setTimeout(() => window.location.reload(), 1200);
} else {
addBtn.disabled = false;
addBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Add Passkey';
if (statusEl) {
statusEl.textContent = result.error || 'Setup failed. Please try again.';
statusEl.className = 'mt-2 small mb-0 text-danger';
}
}
});
});
+321
View File
@@ -0,0 +1,321 @@
/**
* passkey.js WebAuthn / FIDO2 client helpers.
*
* WebAuthn deals in raw ArrayBuffers; the server (and Fido2NetLib) serialize
* them as base64url strings. These helpers convert back and forth so the JS
* fetch payloads match what the server expects.
*/
// ─── base64url helpers ────────────────────────────────────────────────────────
function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer);
let str = '';
for (const b of bytes) str += String.fromCharCode(b);
return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
function base64urlToBuffer(base64url) {
const padded = base64url.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(padded);
const buffer = new ArrayBuffer(binary.length);
const bytes = new Uint8Array(buffer);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return buffer;
}
/**
* Converts server-side CredentialCreateOptions into the shape expected by
* navigator.credentials.create(). Only the fields that the WebAuthn spec
* requires as ArrayBuffer are converted everything else (rp.id, attestation,
* type strings, etc.) must stay as plain strings.
*/
function prepareCreateOptions(opts) {
return {
...opts,
challenge: base64urlToBuffer(opts.challenge),
user: {
...opts.user,
id: base64urlToBuffer(opts.user.id)
},
excludeCredentials: (opts.excludeCredentials ?? []).map(c => ({
...c,
id: base64urlToBuffer(c.id)
}))
};
}
/**
* Converts server-side AssertionOptions into the shape expected by
* navigator.credentials.get(). Only challenge and allowCredentials[].id
* need to be ArrayBuffers.
*/
function prepareGetOptions(opts) {
return {
...opts,
challenge: base64urlToBuffer(opts.challenge),
allowCredentials: (opts.allowCredentials ?? []).map(c => ({
...c,
id: base64urlToBuffer(c.id)
}))
};
}
/** Recursively convert all ArrayBuffers in a credential to base64url strings for JSON. */
function encodeCredential(obj) {
if (obj instanceof ArrayBuffer) return bufferToBase64url(obj);
if (ArrayBuffer.isView(obj)) return bufferToBase64url(obj.buffer.slice(obj.byteOffset, obj.byteOffset + obj.byteLength));
if (Array.isArray(obj)) return obj.map(encodeCredential);
if (obj && typeof obj === 'object') {
const out = {};
for (const [k, v] of Object.entries(obj)) out[k] = encodeCredential(v);
return out;
}
return obj;
}
// ─── Registration (after password login) ─────────────────────────────────────
/**
* Starts the passkey registration flow for the currently signed-in user.
* @param {string} deviceName Optional friendly name (e.g. "Scott's iPhone")
*/
async function registerPasskey(deviceName) {
try {
// 1. Ask the server for creation options (challenge)
const optRes = await fetch('/Passkey/RegisterOptions', { method: 'POST' });
if (!optRes.ok) throw new Error(await optRes.text());
const optionsRaw = await optRes.json();
// 2. Convert server options into WebAuthn API format
const options = prepareCreateOptions(optionsRaw);
// 3. Prompt the authenticator (FaceID / fingerprint / security key)
const credential = await navigator.credentials.create({ publicKey: options });
if (!credential) throw new Error('No credential returned from authenticator.');
// 4. Build the response payload (all ArrayBuffers → base64url)
const payload = {
id: bufferToBase64url(credential.rawId),
rawId: bufferToBase64url(credential.rawId),
type: credential.type,
response: {
attestationObject: bufferToBase64url(credential.response.attestationObject),
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON)
}
};
// 5. Send to server
const qs = deviceName ? `?deviceName=${encodeURIComponent(deviceName)}` : '';
const regRes = await fetch(`/Passkey/Register${qs}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!regRes.ok) {
const err = await regRes.json().catch(() => ({ error: regRes.statusText }));
throw new Error(err.error || 'Registration failed.');
}
return { success: true };
} catch (err) {
console.error('Passkey registration error:', err);
return { success: false, error: err.message };
}
}
// ─── Authentication (at login page) ──────────────────────────────────────────
/**
* Attempts to sign in using a registered passkey.
* Resolves with { success, redirectUrl } or { success: false, error }.
*/
async function loginWithPasskey() {
try {
// 1. Get assertion options from the server
const optRes = await fetch('/Passkey/LoginOptions', { method: 'POST' });
if (!optRes.ok) throw new Error(await optRes.text());
const optionsRaw = await optRes.json();
// 2. Convert to WebAuthn format
const options = prepareGetOptions(optionsRaw);
// 3. Prompt authenticator — browser shows passkey picker automatically
const assertion = await navigator.credentials.get({ publicKey: options });
if (!assertion) throw new Error('No credential returned.');
// 4. Build payload
const payload = {
id: bufferToBase64url(assertion.rawId),
rawId: bufferToBase64url(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
signature: bufferToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle
? bufferToBase64url(assertion.response.userHandle)
: null
}
};
// 5. Verify on server
const loginRes = await fetch('/Passkey/Login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!loginRes.ok) {
const err = await loginRes.json().catch(() => ({ error: loginRes.statusText }));
throw new Error(err.error || 'Login failed.');
}
const data = await loginRes.json();
return { success: true, redirectUrl: data.redirectUrl };
} catch (err) {
if (err.name === 'NotAllowedError') {
// User cancelled or timed out — not a real error
return { success: false, cancelled: true };
}
console.error('Passkey login error:', err);
return { success: false, error: err.message };
}
}
// ─── Feature detection ────────────────────────────────────────────────────────
/** True if this browser + platform support WebAuthn conditional UI (passkeys). */
async function passkeySupported() {
if (!window.PublicKeyCredential) return false;
try {
return await PublicKeyCredential.isConditionalMediationAvailable?.() ?? false;
} catch {
return false;
}
}
/**
* Returns a platform-appropriate label for the passkey button, e.g.
* "Use Face ID / Touch ID" on iOS, "Use Windows Hello" on Windows.
*/
function passkeyLabel() {
const ua = navigator.userAgent;
const platform = navigator.platform ?? '';
// iOS / iPadOS (iPads report MacIntel on platform in some browsers — check UA too)
if (/iphone|ipad|ipod/i.test(ua) || (/macintosh/i.test(ua) && navigator.maxTouchPoints > 1)) {
return 'Use Face ID / Touch ID';
}
// Android
if (/android/i.test(ua)) {
return 'Use Fingerprint / Face Unlock';
}
// macOS (non-touch — Touch ID on MacBook)
if (/mac/i.test(platform) || /macintosh/i.test(ua)) {
return 'Use Touch ID';
}
// Windows
if (/win/i.test(platform) || /windows/i.test(ua)) {
return 'Use Windows Hello';
}
// ChromeOS / Linux / unknown
return 'Use Passkey / Biometric';
}
// ─── Login page wiring ────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
const passkeyBtn = document.getElementById('passkey-login-btn');
if (!passkeyBtn) return;
const supported = await passkeySupported();
if (!supported) {
passkeyBtn.closest('.passkey-login-section')?.remove();
return;
}
const label = passkeyLabel();
passkeyBtn.innerHTML = `<i class="bi bi-fingerprint"></i> ${label}`;
passkeyBtn.addEventListener('click', async () => {
passkeyBtn.disabled = true;
passkeyBtn.textContent = 'Waiting for authentication…';
const result = await loginWithPasskey();
if (result.success) {
window.location.href = result.redirectUrl || '/';
} else if (!result.cancelled) {
passkeyBtn.disabled = false;
passkeyBtn.innerHTML = `<i class="bi bi-fingerprint"></i> ${label}`;
const errEl = document.getElementById('passkey-error');
if (errEl) {
errEl.textContent = result.error || 'Authentication failed. Try again.';
errEl.classList.remove('d-none');
}
} else {
passkeyBtn.disabled = false;
passkeyBtn.innerHTML = `<i class="bi bi-fingerprint"></i> ${label}`;
}
});
});
// ─── Post-login prompt wiring (layout) ───────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
const prompt = document.getElementById('passkey-setup-prompt');
if (!prompt) return;
const supported = await passkeySupported();
if (!supported) { prompt.remove(); return; }
const label = passkeyLabel();
const enableBtn = document.getElementById('passkey-enable-btn');
if (enableBtn) enableBtn.innerHTML = `<i class="bi bi-fingerprint me-1"></i>Enable ${label.replace('Use ', '')}`;
prompt.classList.remove('d-none');
const dismissBtn = document.getElementById('passkey-dismiss-btn');
const statusEl = document.getElementById('passkey-setup-status');
enableBtn?.addEventListener('click', async () => {
enableBtn.disabled = true;
if (statusEl) statusEl.textContent = 'Follow the prompt on your device…';
const ua = navigator.userAgent;
const deviceName = /iphone/i.test(ua) ? 'iPhone'
: /ipad/i.test(ua) ? 'iPad'
: /android/i.test(ua) ? 'Android device'
: /macintosh/i.test(ua) ? 'Mac'
: /windows/i.test(ua) ? 'Windows PC'
: 'This device';
const result = await registerPasskey(deviceName);
if (result.success) {
if (statusEl) {
statusEl.textContent = `${label.replace('Use ', '')} enabled for this device!`;
statusEl.className = 'small mb-0 text-success';
}
enableBtn.classList.add('d-none');
if (dismissBtn) dismissBtn.textContent = 'Close';
setTimeout(() => prompt.classList.add('d-none'), 3000);
} else {
enableBtn.disabled = false;
enableBtn.innerHTML = `<i class="bi bi-fingerprint me-1"></i>Enable ${label.replace('Use ', '')}`;
if (statusEl) statusEl.textContent = result.error || 'Setup failed. Try again.';
}
});
dismissBtn?.addEventListener('click', () => {
prompt.classList.add('d-none');
// Remember dismissal for this session so it doesn't re-appear on every page load
sessionStorage.setItem('passkey-prompt-dismissed', '1');
});
if (sessionStorage.getItem('passkey-prompt-dismissed')) {
prompt.classList.add('d-none');
}
});