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