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:
@@ -0,0 +1,187 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace SlpModularCms.Api.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Serves the two independent front-ends this host carries.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shared hosting typically allows only one site or application pool, so this single process
|
||||
/// serves everything:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>/</c> — the customer's public website, from <c>wwwroot/web/</c>. Built and
|
||||
/// deployed separately; it is NOT part of this repository and must survive every CMS deploy.</description></item>
|
||||
/// <item><description><c>/admin</c> — the CMS admin SPA, from <c>wwwroot/admin/</c>, produced by
|
||||
/// <c>dotnet publish</c>.</description></item>
|
||||
/// </list>
|
||||
/// Each mount gets its own file provider so neither can ever serve files belonging to the other,
|
||||
/// and so each can later carry its own headers or caching without disturbing the other.
|
||||
/// </remarks>
|
||||
public static class StaticContentExtensions
|
||||
{
|
||||
/// <summary>Directory under the web root holding the customer's public website.</summary>
|
||||
public const string WebsiteDirectoryName = "web";
|
||||
|
||||
/// <summary>Directory under the web root holding the admin SPA.</summary>
|
||||
public const string AdminDirectoryName = "admin";
|
||||
|
||||
/// <summary>Request path the admin SPA is mounted at.</summary>
|
||||
public const string AdminRequestPath = "/admin";
|
||||
|
||||
private const string PlaceholderResourceName = "SlpModularCms.Api.Extensions.WebsitePlaceholder.html";
|
||||
|
||||
/// <summary>
|
||||
/// Registers both static mounts and the <c>/admin</c> trailing-slash redirect.
|
||||
/// Must be called BEFORE any middleware that needs to observe static responses, because
|
||||
/// static files short-circuit the pipeline.
|
||||
/// </summary>
|
||||
public static WebApplication UseCmsStaticContent(this WebApplication app)
|
||||
{
|
||||
var webRoot = app.Environment.WebRootPath
|
||||
?? Path.Combine(app.Environment.ContentRootPath, "wwwroot");
|
||||
|
||||
var adminRoot = Path.Combine(webRoot, AdminDirectoryName);
|
||||
var websiteRoot = Path.Combine(webRoot, WebsiteDirectoryName);
|
||||
|
||||
var logger = app.Services.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger(typeof(StaticContentExtensions).FullName!);
|
||||
|
||||
WarnIfMissing(logger, adminRoot, "admin SPA");
|
||||
WarnIfMissing(logger, websiteRoot, "public website");
|
||||
|
||||
// A request for exactly "/admin" must become "/admin/", or the SPA — built with
|
||||
// base '/admin/' — resolves its relative asset references one level too high and
|
||||
// fails in a way that looks like a broken deployment.
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
if (context.Request.Path.Equals(AdminRequestPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var target = $"{AdminRequestPath}/{context.Request.QueryString}";
|
||||
context.Response.Redirect(target, permanent: true);
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// The admin mount is registered FIRST. Registered the other way around, a request for
|
||||
// /admin/... would be resolved against the website root.
|
||||
RegisterMount(app, adminRoot, AdminRequestPath);
|
||||
RegisterMount(app, websiteRoot, requestPath: string.Empty);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the SPA fallbacks for both mounts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <c>nonfile</c> constraint on both routes is deliberate: a request for a path that
|
||||
/// looks like a file (has an extension) and does not exist must stay a 404. Serving HTML
|
||||
/// for a missing script would turn a clear "asset is missing" into a confusing parse error.
|
||||
/// </remarks>
|
||||
public static WebApplication MapCmsSpaFallbacks(this WebApplication app)
|
||||
{
|
||||
var webRoot = app.Environment.WebRootPath
|
||||
?? Path.Combine(app.Environment.ContentRootPath, "wwwroot");
|
||||
|
||||
var adminIndex = Path.Combine(webRoot, AdminDirectoryName, "index.html");
|
||||
var websiteIndex = Path.Combine(webRoot, WebsiteDirectoryName, "index.html");
|
||||
|
||||
app.MapFallback($"{AdminRequestPath}/{{*path:nonfile}}", async context =>
|
||||
{
|
||||
if (File.Exists(adminIndex))
|
||||
{
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
await context.Response.SendFileAsync(adminIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||
});
|
||||
|
||||
app.MapFallback("{*path:nonfile}", async context =>
|
||||
{
|
||||
if (File.Exists(websiteIndex))
|
||||
{
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
await context.Response.SendFileAsync(websiteIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
// No website deployed yet. Serving the placeholder rather than a 404 makes a fresh
|
||||
// installation self-explanatory and doubles as proof the CMS itself is running.
|
||||
await WritePlaceholderAsync(context);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void RegisterMount(WebApplication app, string physicalRoot, string requestPath)
|
||||
{
|
||||
// Tolerating a missing directory is required, not defensive: a fresh deployment has no
|
||||
// wwwroot/web/ until a website workspace deploys into it, and the CMS must still start
|
||||
// and serve /admin and /api/v1.
|
||||
if (!Directory.Exists(physicalRoot))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var provider = new PhysicalFileProvider(physicalRoot);
|
||||
|
||||
app.UseDefaultFiles(new DefaultFilesOptions
|
||||
{
|
||||
FileProvider = provider,
|
||||
RequestPath = requestPath
|
||||
});
|
||||
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = provider,
|
||||
RequestPath = requestPath
|
||||
// Directory browsing is not enabled — a request for a directory resolves to its
|
||||
// default file or falls through to the fallback rules, never to a file listing.
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task WritePlaceholderAsync(HttpContext context)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status200OK;
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
|
||||
// Embedded in the assembly rather than placed in wwwroot/web/, because that directory is
|
||||
// owned and overwritten by a website workspace: a file there would be deleted by the first
|
||||
// real website deployment, or mistaken for part of the customer's site.
|
||||
await using var stream = typeof(StaticContentExtensions).Assembly
|
||||
.GetManifestResourceStream(PlaceholderResourceName);
|
||||
|
||||
if (stream is null)
|
||||
{
|
||||
await context.Response.WriteAsync("<!doctype html><title>Nog geen website geplaatst</title>" +
|
||||
"<p>Er staat hier nog geen website. Beheer via <a href=\"/admin/\">/admin/</a>.</p>");
|
||||
return;
|
||||
}
|
||||
|
||||
await stream.CopyToAsync(context.Response.Body);
|
||||
}
|
||||
|
||||
private static void WarnIfMissing(ILogger logger, string path, string description)
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal on a fresh installation, but on a running production instance it means the
|
||||
// content has disappeared — worth being visible in Sentry without blocking startup.
|
||||
logger.LogWarning(
|
||||
"Static content directory for the {Description} was not found at {Path}. " +
|
||||
"The application will start, but this path will not serve any files until content is deployed there.",
|
||||
description,
|
||||
path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Nog geen website geplaatst</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100svh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2rem;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
line-height: 1.6;
|
||||
background: #fafafa;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #141414; color: #ededed; }
|
||||
code { background: #262626; }
|
||||
a { color: #ff6b6b; }
|
||||
}
|
||||
main { max-width: 34rem; }
|
||||
h1 { font-size: 1.5rem; margin: 0 0 1rem; }
|
||||
p { margin: 0 0 1rem; }
|
||||
code {
|
||||
background: #ececec;
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
a { color: #ac0000; }
|
||||
.muted { font-size: 0.9rem; opacity: 0.75; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Er staat hier nog geen website</h1>
|
||||
<p>
|
||||
Het CMS draait, maar er is nog geen publieke website geplaatst. De website
|
||||
wordt apart aangeleverd en hoort in de map <code>wwwroot/web/</code> te staan,
|
||||
met een <code>index.html</code> in de hoofdmap daarvan.
|
||||
</p>
|
||||
<p>
|
||||
Beheerders kunnen inloggen via <a href="/admin/">/admin/</a>.
|
||||
</p>
|
||||
<p class="muted">
|
||||
Deze pagina wordt automatisch vervangen zodra de website is geplaatst.
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -17,6 +17,16 @@
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.16.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Served at '/' when no public website has been deployed into wwwroot/web/ yet.
|
||||
Embedded rather than shipped as a file under wwwroot/web/, because that directory is
|
||||
owned and overwritten by a separate website workspace — a file there would be deleted
|
||||
by the first real website deployment, or mistaken for part of the customer's site.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Extensions\WebsitePlaceholder.html" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SlpModularCms.Core\SlpModularCms.Core.csproj" />
|
||||
<ProjectReference Include="..\SlpModularCms.Modules.Availability\SlpModularCms.Modules.Availability.csproj" />
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SlpModularCms.Core.Hosting;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using SlpModularCms.Core.Identity.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the availability gate's admin bypass.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The behaviour under test is the fix for a real defect: the bypass previously parsed the
|
||||
/// bearer token without verifying its signature, so an unauthenticated caller could forge an
|
||||
/// unsigned token carrying an Owner role claim and pass the gate. The forged-token cases below
|
||||
/// are the point of this suite — the happy paths only prove the fix did not break the feature.
|
||||
/// </remarks>
|
||||
public class AdminTokenValidatorTests
|
||||
{
|
||||
private const string Secret = "TestSecretKeyThatIsLongEnoughForHmacSha256Signing!!";
|
||||
private const string Issuer = "SlpModularCms";
|
||||
private const string Audience = "SlpModularCmsPortal";
|
||||
|
||||
private readonly AdminTokenValidator _validator;
|
||||
|
||||
public AdminTokenValidatorTests()
|
||||
{
|
||||
var settings = new JwtSettings
|
||||
{
|
||||
Secret = Secret,
|
||||
Issuer = Issuer,
|
||||
Audience = Audience
|
||||
};
|
||||
|
||||
_validator = new AdminTokenValidator(JwtTokenValidation.Create(settings));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Owner")]
|
||||
[InlineData("Administrator")]
|
||||
public void IsVerifiedAdmin_ShouldReturnTrue_ForValidAdminToken(string role)
|
||||
{
|
||||
var header = $"Bearer {CreateToken(role)}";
|
||||
|
||||
_validator.IsVerifiedAdmin(header).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsVerifiedAdmin_ShouldReturnFalse_ForValidNonAdminToken()
|
||||
{
|
||||
var header = $"Bearer {CreateToken("User")}";
|
||||
|
||||
_validator.IsVerifiedAdmin(header).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsVerifiedAdmin_ShouldReturnFalse_ForForgedUnsignedToken()
|
||||
{
|
||||
// The exact attack the fix closes: a token that carries the right claim but was never
|
||||
// signed by us. Reading claims without validating would have accepted this.
|
||||
var forged = CreateUnsignedToken("Owner");
|
||||
|
||||
_validator.IsVerifiedAdmin($"Bearer {forged}").Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsVerifiedAdmin_ShouldReturnFalse_ForTokenSignedWithAnotherKey()
|
||||
{
|
||||
var otherKey = new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes("AnEntirelyDifferentSecretKeyUsedByNobodyElse!!!!!"));
|
||||
var credentials = new SigningCredentials(otherKey, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: [new Claim(ClaimTypes.Role, "Owner")],
|
||||
expires: DateTime.UtcNow.AddMinutes(10),
|
||||
signingCredentials: credentials);
|
||||
|
||||
var header = $"Bearer {new JwtSecurityTokenHandler().WriteToken(token)}";
|
||||
|
||||
_validator.IsVerifiedAdmin(header).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsVerifiedAdmin_ShouldReturnFalse_ForExpiredAdminToken()
|
||||
{
|
||||
var header = $"Bearer {CreateToken("Owner", expiresIn: TimeSpan.FromMinutes(-5))}";
|
||||
|
||||
_validator.IsVerifiedAdmin(header).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsVerifiedAdmin_ShouldReturnFalse_ForWrongIssuer()
|
||||
{
|
||||
var header = $"Bearer {CreateToken("Owner", issuer: "SomeoneElse")}";
|
||||
|
||||
_validator.IsVerifiedAdmin(header).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("Bearer")]
|
||||
[InlineData("Bearer ")]
|
||||
[InlineData("Basic dXNlcjpwYXNz")]
|
||||
[InlineData("Bearer not-a-token")]
|
||||
[InlineData("Bearer a.b.c")]
|
||||
public void IsVerifiedAdmin_ShouldReturnFalse_ForAbsentOrMalformedHeaders(string? header)
|
||||
{
|
||||
// Never throws — an unusable header simply means "not an admin". Rejecting the request
|
||||
// is the authentication middleware's job, not the availability gate's.
|
||||
_validator.IsVerifiedAdmin(header).Should().BeFalse();
|
||||
}
|
||||
|
||||
private static string CreateToken(
|
||||
string role,
|
||||
TimeSpan? expiresIn = null,
|
||||
string issuer = Issuer)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: issuer,
|
||||
audience: Audience,
|
||||
claims: [new Claim(ClaimTypes.Role, role)],
|
||||
expires: DateTime.UtcNow.Add(expiresIn ?? TimeSpan.FromMinutes(10)),
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
private static string CreateUnsignedToken(string role)
|
||||
{
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: [new Claim(ClaimTypes.Role, role)],
|
||||
expires: DateTime.UtcNow.AddMinutes(10));
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using SlpModularCms.Core.Hosting.Health;
|
||||
using Xunit;
|
||||
|
||||
namespace SlpModularCms.Core.Tests.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the shape of the liveness report.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The report is served anonymously, so what it does NOT contain matters as much as what it does.
|
||||
/// It carries the loaded module names because <c>ModuleOrchestrator</c> logs rather than throws
|
||||
/// when a module fails to load — an instance can start "successfully" with a missing capability,
|
||||
/// and this is the only way to detect that after a deploy without host access.
|
||||
/// </remarks>
|
||||
public class HealthReportTests
|
||||
{
|
||||
[Fact]
|
||||
public void HealthReport_ShouldCarryTheFourReportedFields()
|
||||
{
|
||||
var report = new HealthReport("Healthy", DateTimeOffset.UtcNow, "1.2.3", ["Identity", "Availability"]);
|
||||
|
||||
report.Status.Should().Be("Healthy");
|
||||
report.Version.Should().Be("1.2.3");
|
||||
report.Modules.Should().Equal("Identity", "Availability");
|
||||
report.Timestamp.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HealthReport_ShouldSerializeWithoutAnyAdditionalFields()
|
||||
{
|
||||
// Anonymous endpoint: no configuration values, connection details, paths or environment
|
||||
// data may leak in through an accidentally added property.
|
||||
var report = new HealthReport("Healthy", DateTimeOffset.UtcNow, "1.0.0", ["Identity"]);
|
||||
|
||||
var json = JsonSerializer.Serialize(report);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
|
||||
document.RootElement.EnumerateObject()
|
||||
.Select(p => p.Name.ToLowerInvariant())
|
||||
.Should().BeEquivalentTo("status", "timestamp", "version", "modules");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HealthReport_ShouldSupportAnEmptyModuleList()
|
||||
{
|
||||
// A host with no modules discovered is still alive. Liveness must not depend on
|
||||
// capability — that distinction is the entire reason this endpoint exists separately
|
||||
// from /api/v1/System/capabilities.
|
||||
var report = new HealthReport("Healthy", DateTimeOffset.UtcNow, "1.0.0", []);
|
||||
|
||||
report.Modules.Should().BeEmpty();
|
||||
report.Status.Should().Be("Healthy");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the infrastructure liveness endpoint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately takes no options parameter. Adding a database probe or any other dependency
|
||||
/// check must therefore be a visible code change here rather than a configuration setting
|
||||
/// someone can flip — liveness-only is enforced by the shape of this API, not by discipline.
|
||||
///
|
||||
/// Why liveness alone is a meaningful signal: startup applies database migrations and fails
|
||||
/// fast when they cannot be applied (see <see cref="DatabaseMigrationExtensions"/>). A process
|
||||
/// that cannot reach its database therefore never starts, so <c>/health</c> stops answering
|
||||
/// entirely. The unhealthy signal is the absence of a response, not a response saying so.
|
||||
/// </remarks>
|
||||
public static class HealthCheckExtensions
|
||||
{
|
||||
/// <summary>Path of the liveness endpoint. Also present in the availability gate's bypass list.</summary>
|
||||
public const string HealthPath = "/health";
|
||||
|
||||
public static IServiceCollection AddCmsHealthChecks(this IServiceCollection services)
|
||||
{
|
||||
services.AddHealthChecks();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps <c>GET /health</c>, returning a JSON report composed from in-process state only.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapCmsHealthChecks(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapGet(HealthPath, (HttpContext context) =>
|
||||
{
|
||||
var orchestrator = context.RequestServices.GetService<ModuleOrchestrator>();
|
||||
|
||||
var report = new HealthReport(
|
||||
Status: "Healthy",
|
||||
Timestamp: DateTimeOffset.UtcNow,
|
||||
Version: GetVersion(),
|
||||
// Module names are already public via /api/v1/System/capabilities, so including
|
||||
// them here discloses nothing new. They are included because ModuleOrchestrator
|
||||
// logs rather than throws when a module fails to load: an instance can start
|
||||
// "successfully" with a missing capability, and this is the only way to detect
|
||||
// that after a deploy without host access.
|
||||
Modules: orchestrator?.ModuleNames ?? []);
|
||||
|
||||
return Results.Ok(report);
|
||||
})
|
||||
.AllowAnonymous()
|
||||
.WithName("HealthCheck");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static string GetVersion()
|
||||
{
|
||||
var assembly = Assembly.GetEntryAssembly() ?? Assembly.GetExecutingAssembly();
|
||||
|
||||
return assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? assembly.GetName().Version?.ToString()
|
||||
?? "unknown";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting.Health;
|
||||
|
||||
/// <summary>
|
||||
/// Response model for the infrastructure liveness endpoint.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reports infrastructure liveness ONLY. This is deliberately not the same thing as the CMS's
|
||||
/// own availability state (<c>/api/v1/Availability/status</c>) or its loaded-capability report
|
||||
/// (<c>/api/v1/System/capabilities</c>), both of which are domain functionality 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 the two must never be conflated in monitoring.
|
||||
///
|
||||
/// Every field is derived from in-process state. Nothing here may require a database query,
|
||||
/// file read or network call.
|
||||
/// </remarks>
|
||||
[ExcludeFromCodeCoverage]
|
||||
public sealed record HealthReport(
|
||||
string Status,
|
||||
DateTimeOffset Timestamp,
|
||||
string Version,
|
||||
IReadOnlyList<string> Modules);
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using SlpModularCms.Core.Identity.Models;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of the JWT validation parameters used across the application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These parameters are consumed in two places: the JWT bearer authentication scheme,
|
||||
/// and the availability gate's admin bypass (see <c>IAdminTokenValidator</c>).
|
||||
///
|
||||
/// They MUST come from here rather than being configured separately in each place.
|
||||
/// If the two ever drifted apart and the gate became the more permissive of the two,
|
||||
/// a token the bearer scheme rejects could still bypass the availability gate — which
|
||||
/// is exactly the defect the validated admin bypass was introduced to close.
|
||||
/// </remarks>
|
||||
public static class JwtTokenValidation
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the validation parameters for the given settings.
|
||||
/// </summary>
|
||||
public static TokenValidationParameters Create(JwtSettings settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
|
||||
return new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = settings.Issuer,
|
||||
ValidAudience = settings.Audience,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.Secret)),
|
||||
// Exact expiry — a token is valid until its expiry moment and not a second longer.
|
||||
ClockSkew = TimeSpan.Zero
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace SlpModularCms.Core.Hosting.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a bearer token against the application's JWT validation parameters and checks
|
||||
/// for an administrative role.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This replaces an earlier implementation that parsed the token with
|
||||
/// <c>JwtSecurityTokenHandler.ReadJwtToken</c> — which reads claims WITHOUT verifying the
|
||||
/// signature. Under that implementation an unauthenticated caller could present a self-made,
|
||||
/// unsigned token carrying an Owner role claim and bypass the availability gate. Protected
|
||||
/// endpoints still rejected such a caller, so no data was exposed, but the gate that suspends
|
||||
/// a customer's site could be bypassed by anyone who knew the claim name.
|
||||
/// </remarks>
|
||||
public sealed class AdminTokenValidator : IAdminTokenValidator
|
||||
{
|
||||
private const string BearerPrefix = "Bearer ";
|
||||
|
||||
private static readonly string[] AdminRoles = ["Owner", "Administrator"];
|
||||
|
||||
private readonly TokenValidationParameters _validationParameters;
|
||||
private readonly JwtSecurityTokenHandler _handler = new();
|
||||
|
||||
public AdminTokenValidator(TokenValidationParameters validationParameters)
|
||||
{
|
||||
_validationParameters = validationParameters ?? throw new ArgumentNullException(nameof(validationParameters));
|
||||
}
|
||||
|
||||
public bool IsVerifiedAdmin(string? authorizationHeader)
|
||||
{
|
||||
if (string.IsNullOrEmpty(authorizationHeader) ||
|
||||
!authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var token = authorizationHeader[BearerPrefix.Length..].Trim();
|
||||
if (token.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ClaimsPrincipal principal;
|
||||
try
|
||||
{
|
||||
// Validates signature, issuer, audience and lifetime. A forged or expired token
|
||||
// throws here and is treated as "not an admin" rather than as an error — deciding
|
||||
// whether to serve is this component's job; returning 401 is not.
|
||||
principal = _handler.ValidateToken(token, _validationParameters, out _);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return AdminRoles.Any(role => principal.IsInRole(role))
|
||||
|| principal.FindAll(ClaimTypes.Role).Any(c => AdminRoles.Contains(c.Value))
|
||||
|| principal.FindAll("role").Any(c => AdminRoles.Contains(c.Value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace SlpModularCms.Core.Hosting.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Decides whether a request carries a genuinely valid Owner or Administrator token.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Used by the availability gate, which runs before authentication middleware and therefore
|
||||
/// cannot read <c>HttpContext.User</c>. Validation uses the same parameters as the JWT bearer
|
||||
/// scheme (see <see cref="JwtTokenValidation"/>), so the gate can never be more permissive
|
||||
/// than authentication itself.
|
||||
/// </remarks>
|
||||
public interface IAdminTokenValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true only when the supplied Authorization header contains a bearer token that
|
||||
/// validates successfully and carries the Owner or Administrator role.
|
||||
/// </summary>
|
||||
/// <param name="authorizationHeader">Raw Authorization header value; may be null or empty.</param>
|
||||
/// <returns>
|
||||
/// True when the caller is a verified Owner or Administrator; false in every other case,
|
||||
/// including an absent, malformed, forged, expired or non-admin token. Never throws.
|
||||
/// </returns>
|
||||
bool IsVerifiedAdmin(string? authorizationHeader);
|
||||
}
|
||||
@@ -11,11 +11,11 @@ using Microsoft.IdentityModel.Tokens;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Data;
|
||||
using SlpModularCms.Core.Exceptions;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using SlpModularCms.Core.Identity.Authorization;
|
||||
using SlpModularCms.Core.Identity.Entities;
|
||||
using SlpModularCms.Core.Identity.Models;
|
||||
using SlpModularCms.Core.Identity.Services;
|
||||
using System.Text;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
@@ -56,6 +56,16 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ISetupService, SetupService>();
|
||||
|
||||
// 4. Authentication
|
||||
//
|
||||
// The validation parameters are built once and shared: the bearer scheme below and the
|
||||
// availability gate's admin bypass (IAdminTokenValidator) both use this same instance.
|
||||
// Configuring them separately would allow the two to drift, and a gate more permissive
|
||||
// than the bearer scheme would let a token that authentication rejects still bypass the
|
||||
// availability gate.
|
||||
var tokenValidationParameters = JwtTokenValidation.Create(jwtSettings);
|
||||
services.AddSingleton(tokenValidationParameters);
|
||||
services.AddSingleton<IAdminTokenValidator>(_ => new AdminTokenValidator(tokenValidationParameters));
|
||||
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
@@ -63,17 +73,7 @@ public static class ServiceCollectionExtensions
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwtSettings.Issuer,
|
||||
ValidAudience = jwtSettings.Audience,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.Secret)),
|
||||
ClockSkew = TimeSpan.Zero
|
||||
};
|
||||
options.TokenValidationParameters = tokenValidationParameters;
|
||||
});
|
||||
|
||||
// 5. Authorization
|
||||
|
||||
+23
-16
@@ -1,12 +1,9 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using NSubstitute;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using SlpModularCms.Modules.Availability.Middleware;
|
||||
using SlpModularCms.Modules.Availability.Services;
|
||||
|
||||
@@ -16,6 +13,7 @@ public class AvailabilityMiddlewareMasterGateTests
|
||||
{
|
||||
private readonly IAvailabilityService _localSvc;
|
||||
private readonly IMasterAvailabilityService _masterSvc;
|
||||
private readonly IAdminTokenValidator _adminTokenValidator;
|
||||
private readonly AvailabilityMiddleware _middleware;
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
@@ -23,8 +21,12 @@ public class AvailabilityMiddlewareMasterGateTests
|
||||
{
|
||||
_localSvc = Substitute.For<IAvailabilityService>();
|
||||
_masterSvc = Substitute.For<IMasterAvailabilityService>();
|
||||
_adminTokenValidator = Substitute.For<IAdminTokenValidator>();
|
||||
_next = Substitute.For<RequestDelegate>();
|
||||
_middleware = new AvailabilityMiddleware(_next, NullLogger<AvailabilityMiddleware>.Instance);
|
||||
_middleware = new AvailabilityMiddleware(
|
||||
_next,
|
||||
NullLogger<AvailabilityMiddleware>.Instance,
|
||||
_adminTokenValidator);
|
||||
|
||||
_localSvc.IsAvailableAsync().Returns(AvailabilityStatus.Available);
|
||||
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(true, null));
|
||||
@@ -108,7 +110,8 @@ public class AvailabilityMiddlewareMasterGateTests
|
||||
public async Task InvokeAsync_BypassesBothGates_WhenAdminJwtPresent()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Owner")}";
|
||||
context.Request.Headers.Authorization = "Bearer owner-token";
|
||||
_adminTokenValidator.IsVerifiedAdmin("Bearer owner-token").Returns(true);
|
||||
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
|
||||
_localSvc.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
|
||||
|
||||
@@ -119,11 +122,12 @@ public class AvailabilityMiddlewareMasterGateTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_DoesNotBypass_WhenUserRoleJwtAndMasterBlocks()
|
||||
public async Task InvokeAsync_DoesNotBypass_WhenNonAdminJwtAndMasterBlocks()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("User")}";
|
||||
context.Request.Headers.Authorization = "Bearer user-token";
|
||||
_adminTokenValidator.IsVerifiedAdmin(Arg.Any<string>()).Returns(false);
|
||||
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
|
||||
|
||||
await _middleware.InvokeAsync(context, _localSvc, _masterSvc);
|
||||
@@ -132,14 +136,17 @@ public class AvailabilityMiddlewareMasterGateTests
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
private static string CreateJwtWithRole(string role)
|
||||
[Fact]
|
||||
public async Task InvokeAsync_BypassesBothGates_ForHealthEndpoint()
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-signing-key-at-least-32-chars-long!"));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
var token = new JwtSecurityToken(
|
||||
claims: [new Claim(ClaimTypes.Role, role)],
|
||||
expires: DateTime.UtcNow.AddHours(1),
|
||||
signingCredentials: creds);
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = "/health";
|
||||
_masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, null));
|
||||
_localSvc.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
|
||||
|
||||
await _middleware.InvokeAsync(context, _localSvc, _masterSvc);
|
||||
|
||||
await _next.Received(1).Invoke(context);
|
||||
_masterSvc.DidNotReceive().GetMasterStatus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using NSubstitute;
|
||||
using SlpModularCms.Core.Availability;
|
||||
using SlpModularCms.Core.Hosting;
|
||||
using SlpModularCms.Core.Hosting.Security;
|
||||
using SlpModularCms.Core.Identity.Models;
|
||||
using SlpModularCms.Modules.Availability.Middleware;
|
||||
using SlpModularCms.Modules.Availability.Services;
|
||||
using Xunit;
|
||||
@@ -17,6 +20,7 @@ public class AvailabilityMiddlewareTests
|
||||
{
|
||||
private readonly IAvailabilityService _service;
|
||||
private readonly IMasterAvailabilityService _masterService;
|
||||
private readonly IAdminTokenValidator _adminTokenValidator;
|
||||
private readonly AvailabilityMiddleware _middleware;
|
||||
private readonly RequestDelegate _next;
|
||||
|
||||
@@ -24,8 +28,12 @@ public class AvailabilityMiddlewareTests
|
||||
{
|
||||
_service = Substitute.For<IAvailabilityService>();
|
||||
_masterService = Substitute.For<IMasterAvailabilityService>();
|
||||
_adminTokenValidator = Substitute.For<IAdminTokenValidator>();
|
||||
_next = Substitute.For<RequestDelegate>();
|
||||
_middleware = new AvailabilityMiddleware(_next, NullLogger<AvailabilityMiddleware>.Instance);
|
||||
_middleware = new AvailabilityMiddleware(
|
||||
_next,
|
||||
NullLogger<AvailabilityMiddleware>.Instance,
|
||||
_adminTokenValidator);
|
||||
|
||||
// Master gate passes by default in these local gate tests
|
||||
_masterService.GetMasterStatus().Returns(new MasterGateStatus(true, null));
|
||||
@@ -91,6 +99,34 @@ public class AvailabilityMiddlewareTests
|
||||
await _next.Received(1).Invoke(context);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Infrastructure liveness must survive the CMS being switched off. A deliberately disabled
|
||||
/// instance is still perfectly healthy, and monitoring must not report it as down.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ShouldAllowBypass_ForHealthEndpoint_WhenSystemUnavailable()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = "/health";
|
||||
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
|
||||
|
||||
await _middleware.InvokeAsync(context, _service, _masterService);
|
||||
|
||||
await _next.Received(1).Invoke(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ShouldAllowBypass_ForHealthEndpoint_WhenMasterGateClosed()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Path = "/health";
|
||||
_masterService.GetMasterStatus().Returns(new MasterGateStatus(false, "Disabled by master"));
|
||||
|
||||
await _middleware.InvokeAsync(context, _service, _masterService);
|
||||
|
||||
await _next.Received(1).Invoke(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ShouldBlockRequest_WhenSystemInMaintenance()
|
||||
{
|
||||
@@ -105,10 +141,11 @@ public class AvailabilityMiddlewareTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ShouldAllowAdminBypass_WhenOwnerToken()
|
||||
public async Task InvokeAsync_ShouldAllowAdminBypass_WhenValidatorAcceptsTheToken()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Owner")}";
|
||||
context.Request.Headers.Authorization = "Bearer some-token";
|
||||
_adminTokenValidator.IsVerifiedAdmin("Bearer some-token").Returns(true);
|
||||
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
|
||||
|
||||
await _middleware.InvokeAsync(context, _service, _masterService);
|
||||
@@ -117,23 +154,12 @@ public class AvailabilityMiddlewareTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ShouldAllowAdminBypass_WhenAdministratorToken()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("Administrator")}";
|
||||
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
|
||||
|
||||
await _middleware.InvokeAsync(context, _service, _masterService);
|
||||
|
||||
await _next.Received(1).Invoke(context);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ShouldNotBypass_WhenUserRoleToken()
|
||||
public async Task InvokeAsync_ShouldNotBypass_WhenValidatorRejectsTheToken()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
context.Request.Headers.Authorization = $"Bearer {CreateJwtWithRole("User")}";
|
||||
context.Request.Headers.Authorization = "Bearer some-token";
|
||||
_adminTokenValidator.IsVerifiedAdmin(Arg.Any<string>()).Returns(false);
|
||||
_service.IsAvailableAsync().Returns(AvailabilityStatus.Maintenance);
|
||||
|
||||
await _middleware.InvokeAsync(context, _service, _masterService);
|
||||
@@ -147,6 +173,7 @@ public class AvailabilityMiddlewareTests
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
_adminTokenValidator.IsVerifiedAdmin(Arg.Any<string>()).Returns(false);
|
||||
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
|
||||
|
||||
await _middleware.InvokeAsync(context, _service, _masterService);
|
||||
@@ -154,28 +181,80 @@ public class AvailabilityMiddlewareTests
|
||||
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ShouldNotBypass_WhenInvalidJwtToken()
|
||||
/// <summary>
|
||||
/// Wires the middleware to the real validator instead of a substitute, so the two are proven
|
||||
/// to fit together. A substitute alone would keep passing even if the middleware were wired
|
||||
/// back to unvalidated token parsing.
|
||||
/// </summary>
|
||||
public class WithRealValidator
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
context.Request.Headers.Authorization = "Bearer not-a-valid-jwt";
|
||||
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
|
||||
private const string Secret = "TestSecretKeyThatIsLongEnoughForHmacSha256Signing!!";
|
||||
private const string Issuer = "SlpModularCms";
|
||||
private const string Audience = "SlpModularCmsPortal";
|
||||
|
||||
await _middleware.InvokeAsync(context, _service, _masterService);
|
||||
private readonly IAvailabilityService _service = Substitute.For<IAvailabilityService>();
|
||||
private readonly IMasterAvailabilityService _masterService = Substitute.For<IMasterAvailabilityService>();
|
||||
private readonly RequestDelegate _next = Substitute.For<RequestDelegate>();
|
||||
private readonly AvailabilityMiddleware _middleware;
|
||||
|
||||
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
|
||||
}
|
||||
public WithRealValidator()
|
||||
{
|
||||
var parameters = JwtTokenValidation.Create(new JwtSettings
|
||||
{
|
||||
Secret = Secret,
|
||||
Issuer = Issuer,
|
||||
Audience = Audience
|
||||
});
|
||||
|
||||
private static string CreateJwtWithRole(string role)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-signing-key-at-least-32-chars-long!"));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
var token = new JwtSecurityToken(
|
||||
claims: [new Claim(ClaimTypes.Role, role)],
|
||||
expires: DateTime.UtcNow.AddHours(1),
|
||||
signingCredentials: creds
|
||||
);
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
_middleware = new AvailabilityMiddleware(
|
||||
_next,
|
||||
NullLogger<AvailabilityMiddleware>.Instance,
|
||||
new AdminTokenValidator(parameters));
|
||||
|
||||
_masterService.GetMasterStatus().Returns(new MasterGateStatus(true, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ShouldNotBypass_ForForgedUnsignedOwnerToken()
|
||||
{
|
||||
// The defect this unit fixes: an unsigned token carrying an Owner claim used to pass.
|
||||
var forged = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: [new Claim(ClaimTypes.Role, "Owner")],
|
||||
expires: DateTime.UtcNow.AddHours(1)));
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Response.Body = new MemoryStream();
|
||||
context.Request.Headers.Authorization = $"Bearer {forged}";
|
||||
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
|
||||
|
||||
await _middleware.InvokeAsync(context, _service, _masterService);
|
||||
|
||||
await _next.DidNotReceive().Invoke(Arg.Any<HttpContext>());
|
||||
context.Response.StatusCode.Should().Be(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ShouldStillBypass_ForGenuineOwnerToken()
|
||||
{
|
||||
// Preserved behaviour: an administrator can always reach a disabled instance to
|
||||
// switch it back on.
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret));
|
||||
var genuine = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: [new Claim(ClaimTypes.Role, "Owner")],
|
||||
expires: DateTime.UtcNow.AddHours(1),
|
||||
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)));
|
||||
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Headers.Authorization = $"Bearer {genuine}";
|
||||
_service.IsAvailableAsync().Returns(AvailabilityStatus.NotAvailable);
|
||||
|
||||
await _middleware.InvokeAsync(context, _service, _masterService);
|
||||
|
||||
await _next.Received(1).Invoke(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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