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,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));
}
}