using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Core.Entities;
using QRCoder;
namespace PowderCoating.Web.Controllers;
///
/// TOTP-based 2FA setup and management for SuperAdmin accounts.
/// Accessible to any authenticated user — SuperAdmins are redirected here
/// by EnforceSuperAdmin2FAFilter when 2FA is not yet configured.
///
[Authorize]
public class TwoFactorSetupController : Controller
{
private readonly UserManager _userManager;
private readonly SignInManager _signInManager;
private readonly IHostEnvironment _env;
public TwoFactorSetupController(
UserManager userManager,
SignInManager signInManager,
IHostEnvironment env)
{
_userManager = userManager;
_signInManager = signInManager;
_env = env;
}
///
/// Returns the TOTP issuer name embedded in the authenticator URI.
/// Non-production environments append the environment name (e.g. "[Development]")
/// so that QR codes scanned against a staging server show up separately in the
/// authenticator app and cannot be accidentally used against production.
///
private string AppName => _env.IsProduction()
? "Powder Coating Logix"
: $"Powder Coating Logix [{_env.EnvironmentName}]";
///
/// Renders the 2FA management overview page showing the current user's
/// two-factor status, whether an authenticator key has been registered, and
/// whether they hold the SuperAdmin role (which affects which actions are available).
///
// GET: /TwoFactorSetup
public async Task Index()
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Challenge();
var isSuperAdmin = await _userManager.IsInRoleAsync(user, "SuperAdmin");
ViewBag.IsSuperAdmin = isSuperAdmin;
ViewBag.TwoFactorEnabled = user.TwoFactorEnabled;
ViewBag.HasAuthenticator = await _userManager.GetAuthenticatorKeyAsync(user) != null;
return View();
}
///
/// Returns the Setup page with a QR code and manual entry key for the user's
/// authenticator app. Only generates a new secret key if one does not already
/// exist — this prevents the key from being silently reset when background
/// notification-poll requests follow a filter redirect back to this page,
/// which would invalidate a code the user just scanned.
///
// GET: /TwoFactorSetup/Setup
public async Task Setup()
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Challenge();
// Only generate a new key if none exists — avoids resetting the key when
// background fetch requests (e.g. notification polling) follow filter redirects
// back to this page and silently invalidate the code the user just scanned.
var key = await _userManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(key))
{
await _userManager.ResetAuthenticatorKeyAsync(user);
key = await _userManager.GetAuthenticatorKeyAsync(user) ?? string.Empty;
}
var appName = AppName;
var email = user.Email ?? user.UserName ?? "user";
var totpUri = $"otpauth://totp/{Uri.EscapeDataString(appName)}:{Uri.EscapeDataString(email)}?secret={key}&issuer={Uri.EscapeDataString(appName)}&algorithm=SHA1&digits=6&period=30";
var qrBase64 = GenerateQrBase64(totpUri);
ViewBag.SharedKey = FormatKey(key);
ViewBag.QrCodeBase64 = qrBase64;
ViewBag.TotpUri = totpUri;
return View();
}
///
/// Verifies the TOTP code entered by the user and, on success, enables
/// two-factor authentication on their account. Strips spaces and dashes before
/// verification because authenticator apps often display codes with separators.
/// Calls RefreshSignInAsync so the authentication cookie reflects the
/// updated 2FA state without requiring a new login.
///
// POST: /TwoFactorSetup/Setup
[HttpPost, ValidateAntiForgeryToken]
public async Task Setup(string verificationCode)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Challenge();
var code = verificationCode?.Replace(" ", "").Replace("-", "") ?? string.Empty;
var isValid = await _userManager.VerifyTwoFactorTokenAsync(
user, _userManager.Options.Tokens.AuthenticatorTokenProvider, code);
if (!isValid)
{
// Re-show the setup page with current key (don't reset again)
var key = await _userManager.GetAuthenticatorKeyAsync(user) ?? string.Empty;
var appName = AppName;
var email = user.Email ?? user.UserName ?? "user";
var totpUri = $"otpauth://totp/{Uri.EscapeDataString(appName)}:{Uri.EscapeDataString(email)}?secret={key}&issuer={Uri.EscapeDataString(appName)}&algorithm=SHA1&digits=6&period=30";
ViewBag.SharedKey = FormatKey(key);
ViewBag.QrCodeBase64 = GenerateQrBase64(totpUri);
ViewBag.TotpUri = totpUri;
ViewBag.Error = "Verification code was incorrect. Make sure your authenticator app is synced and try again.";
return View();
}
await _userManager.SetTwoFactorEnabledAsync(user, true);
await _signInManager.RefreshSignInAsync(user);
TempData["Success"] = "Two-factor authentication has been enabled on your account.";
return RedirectToAction(nameof(Index));
}
///
/// Disables two-factor authentication and resets the authenticator key.
/// SuperAdmins must supply a valid current TOTP code before 2FA can be
/// removed — this prevents an attacker with a hijacked session from weakening
/// a privileged account's security posture without physical access to the device.
/// Non-SuperAdmin users can disable 2FA without a code (standard Identity behaviour).
/// The authenticator key is reset (not just disabled) so that a re-enabled key
/// requires fresh scanning of a new QR code.
///
// POST: /TwoFactorSetup/Disable
[HttpPost, ValidateAntiForgeryToken]
public async Task Disable(string confirmationCode)
{
var user = await _userManager.GetUserAsync(User);
if (user == null) return Challenge();
var isSuperAdmin = await _userManager.IsInRoleAsync(user, "SuperAdmin");
// SuperAdmins cannot disable 2FA without a valid TOTP code
if (isSuperAdmin)
{
var code = confirmationCode?.Replace(" ", "").Replace("-", "") ?? string.Empty;
var isValid = await _userManager.VerifyTwoFactorTokenAsync(
user, _userManager.Options.Tokens.AuthenticatorTokenProvider, code);
if (!isValid)
{
TempData["Error"] = "Verification code was incorrect. 2FA has not been disabled.";
return RedirectToAction(nameof(Index));
}
}
await _userManager.SetTwoFactorEnabledAsync(user, false);
await _userManager.ResetAuthenticatorKeyAsync(user);
await _signInManager.RefreshSignInAsync(user);
TempData["Success"] = "Two-factor authentication has been disabled.";
return RedirectToAction(nameof(Index));
}
// ── Helpers ──────────────────────────────────────────────────────────────
///
/// Generates a PNG QR code for the given string
/// and returns it as a Base64-encoded string suitable for embedding in an
/// <img src="data:image/png;base64,…"> element.
/// Uses error-correction level Q (≈25 % recovery capacity) — a balanced
/// choice that keeps the code scannable even if partially obscured, without
/// making the image too dense for typical phone cameras.
///
private static string GenerateQrBase64(string content)
{
using var qrGenerator = new QRCodeGenerator();
var qrData = qrGenerator.CreateQrCode(content, QRCodeGenerator.ECCLevel.Q);
using var qrCode = new PngByteQRCode(qrData);
var bytes = qrCode.GetGraphic(6);
return Convert.ToBase64String(bytes);
}
///
/// Formats a raw Base32 authenticator key into space-separated groups of four
/// uppercase characters (e.g. ABCD EFGH IJKL …) to improve readability
/// for users who prefer to type the key manually instead of scanning the QR code.
///
private static string FormatKey(string key)
{
// Group into chunks of 4 for readability: XXXX XXXX XXXX ...
var sb = new System.Text.StringBuilder();
for (int i = 0; i < key.Length; i++)
{
if (i > 0 && i % 4 == 0) sb.Append(' ');
sb.Append(char.ToUpperInvariant(key[i]));
}
return sb.ToString();
}
}