Initial commit
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Web.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Action filter that injects active, non-dismissed platform announcements into
|
||||
/// <c>ViewBag.ActiveAnnouncements</c> before every authenticated page render.
|
||||
/// The shared layout (<c>_Layout.cshtml</c>) reads this bag property to render
|
||||
/// dismissible alert banners at the top of the page.
|
||||
/// <para>
|
||||
/// Design decisions:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// The filter queries <see cref="ApplicationDbContext"/> directly (rather than
|
||||
/// via <c>IUnitOfWork</c>) because the announcement and dismissal tables are
|
||||
/// platform-level — they do not go through the tenant-scoped unit of work.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// Announcements are filtered in SQL (not in memory) to avoid loading the
|
||||
/// full announcement set on every request. The <c>StartsAt</c>/<c>ExpiresAt</c>
|
||||
/// window and the <c>Target</c>/<c>TargetCompanyId</c>/<c>TargetPlan</c>
|
||||
/// predicates are all pushed down to the database.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// Dismissed announcement IDs are loaded in a second query (rather than a
|
||||
/// JOIN) to keep the EF query straightforward and avoid a potential
|
||||
/// Cartesian explosion when a user has many dismissals.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class AnnouncementFilter : IAsyncActionFilter
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the filter with the database context and HTTP context accessor.
|
||||
/// </summary>
|
||||
public AnnouncementFilter(ApplicationDbContext db, IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_db = db;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries active announcements targeting the current user's company and
|
||||
/// subscription plan, removes any that the user has already dismissed, and
|
||||
/// stores the remainder in <c>ViewBag.ActiveAnnouncements</c> before
|
||||
/// invoking the next action delegate.
|
||||
/// <para>
|
||||
/// The filter is a no-op for unauthenticated users and for non-controller
|
||||
/// action contexts (e.g. page filters, view components) — the
|
||||
/// <c>context.Controller is Controller</c> guard ensures we only set
|
||||
/// ViewBag when we have an MVC controller that will render a view.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="context">The action-executing context for the current request.</param>
|
||||
/// <param name="next">The delegate to invoke to execute the action.</param>
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
// Only inject for authenticated users making page requests
|
||||
if (context.Controller is Controller controller &&
|
||||
context.HttpContext.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var userId = context.HttpContext.User.FindFirst(
|
||||
System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
var companyIdStr = context.HttpContext.User.FindFirst("CompanyId")?.Value;
|
||||
int.TryParse(companyIdStr, out var companyId);
|
||||
|
||||
// Plan comes from middleware-injected Context.Items (set per-request from Company record)
|
||||
var plan = context.HttpContext.Items["SubscriptionPlan"] as int? ?? 0;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Load active announcements targeting this user's company/plan
|
||||
var announcements = await _db.Announcements
|
||||
.Where(a => a.IsActive
|
||||
&& a.StartsAt <= now
|
||||
&& (a.ExpiresAt == null || a.ExpiresAt > now)
|
||||
&& (a.Target == "All"
|
||||
|| (a.Target == "Plan" && a.TargetPlan == plan)
|
||||
|| (a.Target == "Company" && a.TargetCompanyId == companyId)))
|
||||
.ToListAsync();
|
||||
|
||||
if (announcements.Count > 0 && userId != null)
|
||||
{
|
||||
// Filter out dismissed ones
|
||||
var dismissedIds = await _db.AnnouncementDismissals
|
||||
.Where(d => d.UserId == userId && announcements.Select(a => a.Id).Contains(d.AnnouncementId))
|
||||
.Select(d => d.AnnouncementId)
|
||||
.ToListAsync();
|
||||
|
||||
announcements = announcements.Where(a => !dismissedIds.Contains(a.Id)).ToList();
|
||||
}
|
||||
|
||||
controller.ViewBag.ActiveAnnouncements = announcements;
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Web.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Redirects SuperAdmin users to the 2FA setup page if they have not yet
|
||||
/// configured two-factor authentication. Exempt routes are allowed through.
|
||||
/// Disabled entirely when IHostEnvironment.IsProduction() is false, or when
|
||||
/// AppSettings:Disable2FAEnforcement is true in appsettings (dev/staging only).
|
||||
/// </summary>
|
||||
public class EnforceSuperAdmin2FAFilter : IAsyncActionFilter
|
||||
{
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IHostEnvironment _env;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
/// <summary>
|
||||
/// Controllers and areas excluded from 2FA enforcement.
|
||||
/// <c>TwoFactorSetup</c> must be exempt so users can reach the setup page
|
||||
/// itself. <c>Home</c> is exempt to avoid an infinite redirect if the
|
||||
/// home page is the post-login landing. The entire <c>Identity</c> area
|
||||
/// is exempt so that login, logout, and 2FA challenge flows remain reachable.
|
||||
/// <see cref="HashSet{T}"/> with <see cref="StringComparer.OrdinalIgnoreCase"/>
|
||||
/// is used for O(1) lookup with case-insensitive matching (route values are
|
||||
/// not guaranteed to be consistently cased).
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ExemptControllers = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"TwoFactorSetup", "Home"
|
||||
};
|
||||
|
||||
/// <summary>Areas excluded from 2FA enforcement (currently only <c>Identity</c>).</summary>
|
||||
private static readonly HashSet<string> ExemptAreas = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Identity"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the filter with its required dependencies.
|
||||
/// </summary>
|
||||
/// <param name="userManager">Used to load the ASP.NET Identity user record and
|
||||
/// check whether two-factor authentication is enabled.</param>
|
||||
/// <param name="env">Hosting environment; enforcement is skipped in Development.</param>
|
||||
/// <param name="config">Application configuration; allows disabling enforcement
|
||||
/// via <c>AppSettings:Disable2FAEnforcement</c> for staging environments.</param>
|
||||
public EnforceSuperAdmin2FAFilter(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IHostEnvironment env,
|
||||
IConfiguration config)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_env = env;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the current SuperAdmin user has 2FA configured and, if not,
|
||||
/// redirects them to the setup page before the action executes.
|
||||
/// <para>
|
||||
/// AJAX/fetch requests (identified by the <c>Sec-Fetch-Mode</c> header being
|
||||
/// present and not equal to <c>navigate</c>) receive a <c>401 Unauthorized</c>
|
||||
/// response instead of a redirect. This is intentional: a redirect followed
|
||||
/// by a fetch call would silently hit the 2FA setup GET endpoint, which calls
|
||||
/// <c>ResetAuthenticatorKeyAsync</c> and would invalidate any in-progress
|
||||
/// TOTP setup the user is performing in another tab.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="context">The action-executing context for the current request.</param>
|
||||
/// <param name="next">The delegate to invoke to proceed with the action.</param>
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
// Skip enforcement in Development only, or when explicitly disabled via config.
|
||||
// Staging and Production both enforce 2FA.
|
||||
if (_env.IsDevelopment() ||
|
||||
_config.GetValue<bool>("AppSettings:Disable2FAEnforcement"))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
var user = context.HttpContext.User;
|
||||
|
||||
// Only enforce for authenticated SuperAdmins
|
||||
if (!user.Identity?.IsAuthenticated ?? true)
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.IsInRole("SuperAdmin"))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip exempt areas/controllers
|
||||
var routeData = context.RouteData;
|
||||
var area = routeData.Values["area"]?.ToString() ?? string.Empty;
|
||||
var controller = routeData.Values["controller"]?.ToString() ?? string.Empty;
|
||||
|
||||
if (ExemptAreas.Contains(area) || ExemptControllers.Contains(controller))
|
||||
{
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if 2FA is enabled
|
||||
var appUser = await _userManager.GetUserAsync(user);
|
||||
if (appUser != null && !appUser.TwoFactorEnabled)
|
||||
{
|
||||
// For AJAX/fetch requests (e.g. notification polling, SignalR negotiate),
|
||||
// return 401 instead of redirecting — fetch follows redirects and would
|
||||
// silently call Setup GET, resetting the authenticator key mid-setup.
|
||||
var fetchMode = context.HttpContext.Request.Headers["Sec-Fetch-Mode"].ToString();
|
||||
var isNonNavigation = !string.IsNullOrEmpty(fetchMode) && fetchMode != "navigate";
|
||||
if (isNonNavigation)
|
||||
{
|
||||
context.Result = new UnauthorizedResult();
|
||||
return;
|
||||
}
|
||||
|
||||
context.Result = new RedirectToActionResult("Setup", "TwoFactorSetup", null);
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Web.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Action filter that injects platform-level feature flags and the current tenant's display
|
||||
/// timezone into ViewBag before every controller action renders. Views read these to
|
||||
/// conditionally show features and convert UTC timestamps to local time.
|
||||
/// </summary>
|
||||
public class PlatformFeaturesFilter : IAsyncActionFilter
|
||||
{
|
||||
private readonly IPlatformSettingsService _settings;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
/// <summary>Initialises the filter.</summary>
|
||||
public PlatformFeaturesFilter(
|
||||
IPlatformSettingsService settings,
|
||||
ITenantContext tenantContext,
|
||||
ApplicationDbContext db,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
_settings = settings;
|
||||
_tenantContext = tenantContext;
|
||||
_db = db;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads platform feature flags and resolves the display timezone, storing both in ViewBag.
|
||||
/// Timezone resolution order: company timezone → user profile timezone → null (UTC).
|
||||
/// </summary>
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
if (context.Controller is Controller controller)
|
||||
{
|
||||
var rawSms = await _settings.GetAsync(PlatformSettingKeys.SmsEnabled);
|
||||
controller.ViewBag.SmsEnabled =
|
||||
string.Equals(rawSms, "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
string? displayTz = null;
|
||||
|
||||
// Company users: use their company's configured timezone
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId is > 1)
|
||||
{
|
||||
displayTz = await _db.Companies
|
||||
.Where(c => c.Id == companyId.Value)
|
||||
.Select(c => c.TimeZone)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
// SuperAdmins or no company timezone set: fall back to the user's personal timezone
|
||||
if (string.IsNullOrWhiteSpace(displayTz))
|
||||
{
|
||||
var appUser = await _userManager.GetUserAsync(context.HttpContext.User);
|
||||
displayTz = appUser?.TimeZone;
|
||||
}
|
||||
|
||||
controller.ViewBag.CompanyTimeZone = displayTz;
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user