Initial commit
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Web.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Ensures <see cref="ApplicationUser.LastLoginDate"/> is always stamped for every authenticated
|
||||
/// user, regardless of which sign-in path they used (password, 2FA, registration auto-sign-in,
|
||||
/// external providers, etc.).
|
||||
///
|
||||
/// The explicit stamps in <c>Login.cshtml.cs</c> and <c>RegistrationController</c> cover the most
|
||||
/// common paths; this middleware is the belt-and-suspenders layer that catches everything else —
|
||||
/// in particular the default Identity <c>LoginWith2fa</c> page, any future sign-in routes, and
|
||||
/// any user whose <c>LastLoginDate</c> is null because they signed in before the field existed.
|
||||
///
|
||||
/// To avoid a DB write on every request, an <see cref="IMemoryCache"/> entry keyed by user ID
|
||||
/// is set for one hour after a successful stamp. The first authenticated request in each hour
|
||||
/// window costs at most one DB read (to check if the field is null) plus one write (if it is).
|
||||
/// Subsequent requests within the hour are handled entirely from cache.
|
||||
/// </summary>
|
||||
public class LoginTrackingMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public LoginTrackingMiddleware(RequestDelegate next, IMemoryCache cache)
|
||||
{
|
||||
_next = next;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamps <see cref="ApplicationUser.LastLoginDate"/> for the current authenticated user if
|
||||
/// it has not been recorded yet, then delegates to the rest of the pipeline.
|
||||
/// The <paramref name="userManager"/> is resolved per-request (scoped lifetime) via the
|
||||
/// <c>InvokeAsync</c> parameter rather than constructor injection to avoid a captive-dependency
|
||||
/// problem (middleware instances are singletons but UserManager is scoped).
|
||||
/// </summary>
|
||||
public async Task InvokeAsync(HttpContext context, UserManager<ApplicationUser> userManager)
|
||||
{
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var userId = userManager.GetUserId(context.User);
|
||||
if (userId != null)
|
||||
{
|
||||
var cacheKey = $"loginstamp:{userId}";
|
||||
if (!_cache.TryGetValue(cacheKey, out _))
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(userId);
|
||||
if (user != null && user.LastLoginDate == null)
|
||||
{
|
||||
user.LastLoginDate = DateTime.UtcNow;
|
||||
await userManager.UpdateAsync(user);
|
||||
}
|
||||
|
||||
// Cache for 1 hour regardless of whether we wrote — avoids repeated DB reads
|
||||
// for users who already have a date set.
|
||||
_cache.Set(cacheKey, true, TimeSpan.FromHours(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
namespace PowderCoating.Web.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Intercepts authenticated requests and redirects any user that has the
|
||||
/// "MustChangePassword" claim to the change-password page before they can
|
||||
/// access any other part of the application.
|
||||
/// </summary>
|
||||
public class MustChangePasswordMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
/// <summary>
|
||||
/// URL path prefixes that are reachable even before the mandatory password
|
||||
/// change is completed. These are the minimum set of routes required to:
|
||||
/// <list type="bullet">
|
||||
/// <item>Display and submit the change-password form itself.</item>
|
||||
/// <item>Allow the user to log out if they do not want to proceed.</item>
|
||||
/// <item>Load static assets served by the Identity UI (CSS, JS, etc.).</item>
|
||||
/// <item>Serve logo and profile images referenced by the layout.</item>
|
||||
/// <item>Allow SignalR hub negotiation and API calls that the page may
|
||||
/// initiate before the redirect is completed client-side.</item>
|
||||
/// </list>
|
||||
/// All other paths redirect to <c>/Account/ChangeInitialPassword</c>.
|
||||
/// </summary>
|
||||
private static readonly string[] AllowedPrefixes =
|
||||
[
|
||||
"/Account/ChangeInitialPassword",
|
||||
"/Identity/Account/Logout",
|
||||
"/Identity/",
|
||||
"/CompanyLogo",
|
||||
"/Profile/Photo",
|
||||
"/api/",
|
||||
"/stripe/",
|
||||
"/hubs/",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the middleware with the next request delegate in the pipeline.
|
||||
/// </summary>
|
||||
/// <param name="next">The next middleware component.</param>
|
||||
public MustChangePasswordMiddleware(RequestDelegate next) => _next = next;
|
||||
|
||||
/// <summary>
|
||||
/// Intercepts every request. If the authenticated user carries the
|
||||
/// <c>MustChangePassword=true</c> claim and is requesting a path not in
|
||||
/// <see cref="AllowedPrefixes"/>, issues a redirect to the change-password
|
||||
/// page and short-circuits the rest of the pipeline.
|
||||
/// <para>
|
||||
/// The claim is set by the admin when creating or resetting a user account.
|
||||
/// Using a claim (rather than a DB flag) means the check is evaluated on
|
||||
/// every request without an additional database query, at the cost of a
|
||||
/// sign-out being required to clear it — which is acceptable since the
|
||||
/// password change form re-signs the user in automatically.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="context">The current HTTP context.</param>
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (context.User.Identity?.IsAuthenticated == true &&
|
||||
context.User.FindFirst("MustChangePassword")?.Value == "true")
|
||||
{
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
var allowed = AllowedPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!allowed)
|
||||
{
|
||||
context.Response.Redirect("/Account/ChangeInitialPassword");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.Security.Claims;
|
||||
using PowderCoating.Web.Services;
|
||||
|
||||
namespace PowderCoating.Web.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Updates the OnlineUserTracker for every authenticated page request.
|
||||
/// Throttled to once per 60 seconds per user to avoid excessive work.
|
||||
/// </summary>
|
||||
public class OnlineUserMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
/// <summary>
|
||||
/// Maps each user ID to the UTC time the tracker was last updated.
|
||||
/// A <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>
|
||||
/// is used because multiple concurrent requests from the same user (e.g. polling
|
||||
/// and a page navigation) may race to update the entry. The dictionary is
|
||||
/// static so the throttle window persists across DI scope lifetimes — the
|
||||
/// middleware instance itself may be recreated but the throttle state must not.
|
||||
/// </summary>
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _throttle = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the middleware with the next request delegate in the pipeline.
|
||||
/// </summary>
|
||||
/// <param name="next">The next middleware component.</param>
|
||||
public OnlineUserMiddleware(RequestDelegate next) => _next = next;
|
||||
|
||||
/// <summary>
|
||||
/// Calls the downstream pipeline first, then — after the response is
|
||||
/// produced — records the requesting user as online in the tracker if the
|
||||
/// 60-second throttle window has elapsed.
|
||||
/// <para>
|
||||
/// Tracking runs <em>after</em> the response (rather than before) so that
|
||||
/// slow downstream operations do not delay the response to the client.
|
||||
/// The tracker update is best-effort: if it throws, the exception propagates
|
||||
/// but does not affect the already-sent response.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// API, hub, and static-file requests are excluded because they are too
|
||||
/// frequent and do not represent meaningful user navigation activity.
|
||||
/// Static files are identified by the presence of a dot in the path
|
||||
/// (e.g. <c>.js</c>, <c>.css</c>), which is a pragmatic heuristic that
|
||||
/// avoids the overhead of checking extensions individually.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="context">The current HTTP context.</param>
|
||||
/// <param name="tracker">
|
||||
/// Scoped service that maintains the in-memory registry of online users.
|
||||
/// </param>
|
||||
public async Task InvokeAsync(HttpContext context, IOnlineUserTracker tracker)
|
||||
{
|
||||
await _next(context);
|
||||
|
||||
// Only track authenticated, non-API, non-asset requests
|
||||
if (!context.User.Identity?.IsAuthenticated ?? true) return;
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
if (path.StartsWith("/hubs") || path.StartsWith("/api") ||
|
||||
path.Contains('.')) // static files have extensions
|
||||
return;
|
||||
|
||||
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(userId)) return;
|
||||
|
||||
// Throttle: only update the tracker once per 60 seconds per user
|
||||
var now = DateTime.UtcNow;
|
||||
if (_throttle.TryGetValue(userId, out var lastWrite) && (now - lastWrite).TotalSeconds < 60)
|
||||
return;
|
||||
|
||||
_throttle[userId] = now;
|
||||
|
||||
var email = context.User.FindFirstValue(ClaimTypes.Email) ?? string.Empty;
|
||||
var firstName = context.User.FindFirstValue(ClaimTypes.GivenName) ?? string.Empty;
|
||||
var lastName = context.User.FindFirstValue(ClaimTypes.Surname) ?? string.Empty;
|
||||
var displayName = $"{firstName} {lastName}".Trim();
|
||||
if (string.IsNullOrEmpty(displayName)) displayName = email;
|
||||
|
||||
var isSuperAdmin = context.User.IsInRole("SuperAdmin");
|
||||
|
||||
tracker.Touch(new OnlineUserEntry
|
||||
{
|
||||
UserId = userId,
|
||||
Email = email,
|
||||
DisplayName = displayName,
|
||||
IsSuperAdmin = isSuperAdmin,
|
||||
CurrentPath = path,
|
||||
IpAddress = context.Connection.RemoteIpAddress?.ToString(),
|
||||
LastSeen = now
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Per-request middleware that enforces subscription status and gates plan-specific
|
||||
/// features for every authenticated non-SuperAdmin user.
|
||||
/// <para>
|
||||
/// On each request this middleware:
|
||||
/// <list type="number">
|
||||
/// <item>Sets <c>HttpContext.Items["AllowOnlinePayments"]</c> and
|
||||
/// <c>HttpContext.Items["AllowAccounting"]</c> from the company's
|
||||
/// subscription plan configuration (or per-company overrides).
|
||||
/// Controllers and views read these flags via helper methods such as
|
||||
/// <c>AllowAccounting()</c> to show/hide plan-gated UI elements.</item>
|
||||
/// <item>Redirects inactive companies to <c>/Billing/Inactive</c>.</item>
|
||||
/// <item>Redirects expired companies (past the grace period) to <c>/Billing/Expired</c>.</item>
|
||||
/// <item>Sets <c>HttpContext.Items["SubscriptionDaysRemaining"]</c> for companies
|
||||
/// within the expiry warning window (≤ 14 days) so the layout can render
|
||||
/// a countdown banner.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// SuperAdmins bypass subscription enforcement entirely but still receive the
|
||||
/// feature-flag <c>Items</c> entries (set to <c>true</c>) so that views relying
|
||||
/// on those flags render correctly when a SuperAdmin impersonates a tenant.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class SubscriptionMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<SubscriptionMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// URL path prefixes that bypass subscription enforcement.
|
||||
/// These are paths where the subscription status is either irrelevant
|
||||
/// (Identity/auth routes, static assets) or where the user must be able
|
||||
/// to reach even on an expired subscription (Billing, data export, Stripe
|
||||
/// webhook, SignalR).
|
||||
/// </summary>
|
||||
private static readonly string[] SkipPaths =
|
||||
{
|
||||
"/Identity/",
|
||||
"/Billing",
|
||||
"/api/",
|
||||
"/stripe/",
|
||||
"/Profile/Photo",
|
||||
"/CompanyLogo",
|
||||
"/AccountDataExport"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the middleware with the next delegate and a logger for
|
||||
/// subscription-check errors.
|
||||
/// </summary>
|
||||
public SubscriptionMiddleware(RequestDelegate next, ILogger<SubscriptionMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates subscription status and sets feature-flag <c>HttpContext.Items</c>
|
||||
/// entries for the current request.
|
||||
/// <para>
|
||||
/// <see cref="ISubscriptionService"/> and <see cref="ITenantContext"/> are
|
||||
/// injected per-request (not via the constructor) because this middleware is
|
||||
/// registered as a singleton — constructor injection of scoped services would
|
||||
/// cause a captive-dependency bug.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="context">The current HTTP context.</param>
|
||||
/// <param name="subscriptionService">
|
||||
/// Scoped service that computes subscription expiry dates and grace periods.
|
||||
/// </param>
|
||||
/// <param name="tenantContext">
|
||||
/// Scoped service that resolves the authenticated user's company ID from claims.
|
||||
/// </param>
|
||||
public async Task InvokeAsync(HttpContext context, ISubscriptionService subscriptionService, ITenantContext tenantContext)
|
||||
{
|
||||
// Skip for unauthenticated users
|
||||
if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// SuperAdmins get all features but still need the feature flags set for views
|
||||
if (context.User.IsInRole(AppConstants.Roles.SuperAdmin))
|
||||
{
|
||||
context.Items["AllowOnlinePayments"] = true;
|
||||
context.Items["AllowAccounting"] = true;
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip specific paths
|
||||
var path = context.Request.Path.Value ?? string.Empty;
|
||||
if (ShouldSkip(path))
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current company
|
||||
var companyId = tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var company = await GetCompanyAsync(context, companyId.Value);
|
||||
if (company == null)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if company is inactive
|
||||
if (!company.IsActive)
|
||||
{
|
||||
context.Response.Redirect("/Billing/Inactive");
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve plan config (used for feature flags regardless of expiry state)
|
||||
var unitOfWork = context.RequestServices.GetRequiredService<IUnitOfWork>();
|
||||
var planConfig = await unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(
|
||||
p => p.Plan == company.SubscriptionPlan);
|
||||
|
||||
// Comped/internal companies get all features; per-company overrides take precedence over plan defaults
|
||||
context.Items["AllowOnlinePayments"] = company.IsComped
|
||||
|| (company.OnlinePaymentsOverride ?? (planConfig?.AllowOnlinePayments ?? false));
|
||||
context.Items["AllowAccounting"] = company.IsComped
|
||||
|| (company.AccountingOverride ?? (planConfig?.AllowAccounting ?? false));
|
||||
|
||||
if (company.IsComped)
|
||||
{
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check subscription expiry
|
||||
var daysUntil = subscriptionService.DaysUntilExpiry(company);
|
||||
if (daysUntil != null)
|
||||
{
|
||||
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
|
||||
{
|
||||
// Past grace period — hard lockout
|
||||
context.Response.Redirect("/Billing/Expired");
|
||||
return;
|
||||
}
|
||||
|
||||
if (daysUntil <= 0)
|
||||
{
|
||||
// In grace period — show warning banner
|
||||
context.Items["SubscriptionDaysRemaining"] = daysUntil;
|
||||
context.Items["SubscriptionPlan"] = company.SubscriptionPlan;
|
||||
}
|
||||
else if (daysUntil <= 14)
|
||||
{
|
||||
// Expiring soon — show warning banner
|
||||
context.Items["SubscriptionDaysRemaining"] = daysUntil;
|
||||
context.Items["SubscriptionPlan"] = company.SubscriptionPlan;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in SubscriptionMiddleware for company {CompanyId}", companyId);
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if the request path begins with any of the prefixes
|
||||
/// in <see cref="SkipPaths"/>, meaning subscription enforcement should be
|
||||
/// skipped for this request.
|
||||
/// </summary>
|
||||
/// <param name="path">The request path value from <c>HttpContext.Request.Path</c>.</param>
|
||||
private static bool ShouldSkip(string path)
|
||||
{
|
||||
foreach (var skip in SkipPaths)
|
||||
{
|
||||
if (path.StartsWith(skip, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the company record from the database, bypassing global query filters
|
||||
/// so that inactive/soft-deleted companies can still be retrieved — this is
|
||||
/// necessary for the middleware to redirect inactive companies to the billing
|
||||
/// page rather than returning a generic 404.
|
||||
/// </summary>
|
||||
/// <param name="context">The current HTTP context (used for service resolution).</param>
|
||||
/// <param name="companyId">The tenant company's database ID.</param>
|
||||
/// <returns>The company entity, or <c>null</c> if not found.</returns>
|
||||
private static async Task<Core.Entities.Company?> GetCompanyAsync(HttpContext context, int companyId)
|
||||
{
|
||||
var unitOfWork = context.RequestServices.GetRequiredService<IUnitOfWork>();
|
||||
return await unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user