Separates website and admin roots, adds /health, hardens the availability gate
Prepares the single-host layout for deployment. The customer's public website moves from wwwroot/ to wwwroot/web/, so a CMS deploy can no longer overwrite content it does not own: with the website in its own directory, the release directory can be swapped without touching it. Each front-end gets its own file provider, and both tolerate a missing directory at startup — a fresh deployment has no website until a separate workspace deploys one, and the CMS must still serve /admin and the API. When the website's index.html is absent, an embedded placeholder is served instead of a 404, which also doubles as proof the CMS itself is running. The placeholder is embedded in the assembly rather than shipped into wwwroot/web/, because that directory is owned and overwritten by the website workspace. Adds GET /health for uptime monitoring. It reports infrastructure liveness only and is deliberately NOT the same thing as /api/v1/Availability/status or /api/v1/System/capabilities: those are CMS domain state that also serve the master/slave protocol. A healthy instance can be switched off by design, and a switched-on instance can be unhealthy, so conflating them would alert on business state and stay silent on real outages. /health is on the availability gate's bypass list for the same reason. Fixes a real defect in the gate's admin bypass. It parsed the bearer token with ReadJwtToken, which reads claims without verifying the signature, so an unauthenticated caller could forge an unsigned token carrying an Owner role claim and bypass the gate that suspends a customer's site. Protected endpoints still rejected them, so nothing leaked — but the gate itself was bypassable. The token is now fully validated against the same parameters as the bearer scheme, resolved from one shared source so the two cannot drift apart. Host wiring for these changes lands with the data-durability commit, since both units touch the same lines of Program.cs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
namespace SlpModularCms.Modules.Availability.Middleware;
|
||||
@@ -12,11 +11,16 @@ public class AvailabilityMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<AvailabilityMiddleware> _logger;
|
||||
private readonly IAdminTokenValidator _adminTokenValidator;
|
||||
|
||||
public AvailabilityMiddleware(RequestDelegate next, ILogger<AvailabilityMiddleware> logger)
|
||||
public AvailabilityMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<AvailabilityMiddleware> logger,
|
||||
IAdminTokenValidator adminTokenValidator)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
_adminTokenValidator = adminTokenValidator;
|
||||
}
|
||||
|
||||
// Paths that are always accessible regardless of system availability.
|
||||
@@ -25,6 +29,9 @@ public class AvailabilityMiddleware
|
||||
// Master endpoints bypass so master can always push status or re-register.
|
||||
// SlaveStatus bypasses so a slave can always pull the master's status, even if the
|
||||
// master instance is (for whatever reason) reporting itself as locally unavailable.
|
||||
// /health bypasses because it reports infrastructure liveness, which is a different
|
||||
// question from whether the CMS is switched on: an instance that is deliberately
|
||||
// disabled is still perfectly healthy, and must not be reported as down.
|
||||
private static readonly string[] _bypassPrefixes =
|
||||
[
|
||||
"/api/v1/Availability/status",
|
||||
@@ -32,6 +39,7 @@ public class AvailabilityMiddleware
|
||||
"/api/v1/Setup/status",
|
||||
"/api/v1/master/",
|
||||
"/api/v1/SlaveStatus",
|
||||
"/health",
|
||||
];
|
||||
|
||||
public async Task InvokeAsync(
|
||||
@@ -89,26 +97,18 @@ public class AvailabilityMiddleware
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets a verified Owner or Administrator through the gate, so administrators can always
|
||||
/// reach a disabled instance to switch it back on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The token is fully validated — signature, issuer, audience and lifetime — against the
|
||||
/// same parameters as the JWT bearer scheme. An earlier implementation read the claims
|
||||
/// without verifying the signature, which meant an unauthenticated caller could present a
|
||||
/// self-made token carrying an Owner role claim and bypass the gate.
|
||||
/// </remarks>
|
||||
private bool IsAdminBypass(HttpContext context)
|
||||
{
|
||||
var authHeader = context.Request.Headers.Authorization.ToString();
|
||||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tokenString = authHeader.Substring("Bearer ".Length);
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var token = handler.ReadJwtToken(tokenString);
|
||||
|
||||
var roles = token.Claims.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value);
|
||||
return roles.Any(r => r == "Owner" || r == "Administrator");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return _adminTokenValidator.IsVerifiedAdmin(context.Request.Headers.Authorization.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user