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; /// /// 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. /// /// Fido2 is constructed per-request from the incoming Host header so the RPID /// matches automatically on localhost, dev, staging, and production without any /// environment-specific configuration. /// // Intentional exception: WebAuthn/FIDO2 identity infrastructure. UserPasskeys is an ASP.NET Identity concern not exposed through IUnitOfWork; the anonymous login path has no tenant context; FIDO2 async callbacks capture _db by closure. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions. [Route("[controller]/[action]")] public class PasskeyController : Controller { private readonly UserManager _userManager; private readonly SignInManager _signInManager; private readonly ApplicationDbContext _db; private readonly ILogger _logger; private const string RegChallengeKey = "passkey:reg:challenge"; private const string AuthChallengeKey = "passkey:auth:challenge"; public PasskeyController( UserManager userManager, SignInManager signInManager, ApplicationDbContext db, ILogger logger) { _userManager = userManager; _signInManager = signInManager; _db = db; _logger = logger; } /// /// Builds a Fido2 instance whose RPID and origin are derived from the current /// request, so the same code works on localhost, dev, and production unchanged. /// private IFido2 BuildFido2() { var req = HttpContext.Request; var host = req.Host.Host; // "localhost" or "myapp.azurewebsites.net" var port = req.Host.Port; var origin = port.HasValue ? $"{req.Scheme}://{host}:{port}" : $"{req.Scheme}://{host}"; var config = new Fido2Configuration { ServerDomain = host, ServerName = "Powder Coating Logix", Origins = new HashSet { origin }, TimestampDriftTolerance = 300 }; return new Fido2(config); } // ─── Registration ──────────────────────────────────────────────────────── /// /// Returns a WebAuthn creation options object for the currently signed-in user. /// Stores the challenge in session so Register can verify it. /// [HttpPost] [Authorize] public async Task 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 = BuildFido2().RequestNewCredential(new RequestNewCredentialParams { User = fidoUser, ExcludeCredentials = excludeCredentials, AuthenticatorSelection = authenticatorSelection, AttestationPreference = AttestationConveyancePreference.None }); HttpContext.Session.SetString(RegChallengeKey, options.ToJson()); return Ok(options); } /// /// Verifies the authenticator response and persists the new passkey credential. /// [HttpPost] [Authorize] public async Task 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 BuildFido2().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 ─────────────────────────────────────────────────────── /// /// Returns a WebAuthn assertion options object. No session required — called before login. /// [HttpPost] [AllowAnonymous] public IActionResult LoginOptions() { var options = BuildFido2().GetAssertionOptions(new GetAssertionOptionsParams { AllowedCredentials = [], UserVerification = UserVerificationRequirement.Required }); HttpContext.Session.SetString(AuthChallengeKey, options.ToJson()); return Ok(options); } /// /// Verifies the assertion response against stored credentials and signs the user in. /// [HttpPost] [AllowAnonymous] public async Task 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 BuildFido2().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); // Track login date so CompanyHealth and audit pages show accurate last-login times user.LastLoginDate = DateTime.UtcNow; await _userManager.UpdateAsync(user); _logger.LogInformation("User {UserId} signed in via passkey", user.Id); return Ok(new { redirectUrl = Url.Action("Index", "Dashboard") }); } // ─── Management ─────────────────────────────────────────────────────────── // ─── Post-login enrollment prompt ───────────────────────────────────────── /// /// Shown immediately after password login. Skips to returnUrl if the user already /// has a passkey or has previously dismissed the prompt. /// [Authorize] [HttpGet("/Passkey/EnrollPrompt")] public async Task EnrollPrompt(string? returnUrl) { var user = await _userManager.GetUserAsync(User); if (user == null) return Unauthorized(); var hasPasskey = await _db.UserPasskeys.AnyAsync(p => p.UserId == user.Id); if (hasPasskey || user.PasskeyPromptDismissed) return Redirect(returnUrl ?? "/"); ViewBag.ReturnUrl = returnUrl ?? "/"; return View(); } /// /// Permanently dismisses the passkey enrollment prompt for this user. They can /// re-enable it from Profile → Security at any time. /// [Authorize] [HttpPost("/Passkey/DismissPrompt")] [ValidateAntiForgeryToken] public async Task DismissPrompt(string? returnUrl) { var user = await _userManager.GetUserAsync(User); if (user == null) return Unauthorized(); user.PasskeyPromptDismissed = true; await _userManager.UpdateAsync(user); return Redirect(returnUrl ?? "/"); } /// Shows all passkeys registered by the current user. [Authorize] [HttpGet("/Passkey/Manage")] public async Task 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); } /// Removes a specific passkey for the current user. [HttpPost] [Authorize] [ValidateAntiForgeryToken] public async Task 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)); } }