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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user