1cb7a8ca4a
Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers, routing all data access through IUnitOfWork. Added IPlainRepository<T> for the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote) that intentionally don't extend BaseEntity and therefore can't use the constrained IRepository<T>. Added permanent-exception comments to the 18 controllers that legitimately retain direct DbContext access (Identity infra, cross-tenant platform ops, bulk streaming exports). Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup gate that reflects over every Controller subclass and throws at boot if any non-exempt controller injects ApplicationDbContext. The app cannot start with a violation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
342 lines
13 KiB
C#
342 lines
13 KiB
C#
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.
|
|
///
|
|
/// 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.
|
|
/// </summary>
|
|
// 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<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(
|
|
UserManager<ApplicationUser> userManager,
|
|
SignInManager<ApplicationUser> signInManager,
|
|
ApplicationDbContext db,
|
|
ILogger<PasskeyController> logger)
|
|
{
|
|
_userManager = userManager;
|
|
_signInManager = signInManager;
|
|
_db = db;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string> { origin },
|
|
TimestampDriftTolerance = 300
|
|
};
|
|
|
|
return new Fido2(config);
|
|
}
|
|
|
|
// ─── 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 = BuildFido2().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 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 ───────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Returns a WebAuthn assertion options object. No session required — called before login.
|
|
/// </summary>
|
|
[HttpPost]
|
|
[AllowAnonymous]
|
|
public IActionResult LoginOptions()
|
|
{
|
|
var options = BuildFido2().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 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 ─────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// Shown immediately after password login. Skips to returnUrl if the user already
|
|
/// has a passkey or has previously dismissed the prompt.
|
|
/// </summary>
|
|
[Authorize]
|
|
[HttpGet("/Passkey/EnrollPrompt")]
|
|
public async Task<IActionResult> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Permanently dismisses the passkey enrollment prompt for this user. They can
|
|
/// re-enable it from Profile → Security at any time.
|
|
/// </summary>
|
|
[Authorize]
|
|
[HttpPost("/Passkey/DismissPrompt")]
|
|
[ValidateAntiForgeryToken]
|
|
public async Task<IActionResult> DismissPrompt(string? returnUrl)
|
|
{
|
|
var user = await _userManager.GetUserAsync(User);
|
|
if (user == null) return Unauthorized();
|
|
|
|
user.PasskeyPromptDismissed = true;
|
|
await _userManager.UpdateAsync(user);
|
|
|
|
return Redirect(returnUrl ?? "/");
|
|
}
|
|
|
|
/// <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));
|
|
}
|
|
}
|