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:
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);
|
||||
}
|
||||
|
||||
+9244
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" />
|
||||
|
||||
@@ -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 & 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">• 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 & 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) ─────────────────────── -->
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user