feat(admin): shared-secret auth for /admin/import_viewer + Swagger authorize

Anonymous /admin/import_viewer was safe locally but exposed viewer-import surface
whenever the server was reachable off-box. Gate it on a shared secret carried in
X-Admin-Secret, sourced from Admin:ImportSecret in appsettings (override via
ADMIN__IMPORTSECRET env var). Fails closed: an unconfigured deployment 401s every
request (with a logged warning) instead of leaving the endpoint open.

Implementation is a plain IAuthorizationFilter attribute (RequireAdminSecret) that
runs before model binding — short-circuits the ImportViewerRequest deserialize on
unauthorized calls. Compare uses CryptographicOperations.FixedTimeEquals to avoid
timing signal. A companion Swagger IOperationFilter registers an ApiKey security
definition and attaches the requirement ONLY to actions carrying the attribute, so
the Swagger UI shows an Authorize dialog for admin endpoints without decorating
ordinary game routes with a padlock.

Tests: existing 27 AdminController tests routed through a new
SVSimTestFactory.CreateAdminClient() helper that bakes the header from
appsettings.Testing.json; added two negative-path tests
(missing_secret_header, wrong_secret) — 29/29 passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-04 18:24:59 -04:00
parent 7a13e99df7
commit c8e4bda6af
9 changed files with 223 additions and 33 deletions

View File

@@ -15,6 +15,7 @@ using SVSim.Database.Services.Friend;
using SVSim.Database.Services.Replay;
using SVSim.EmulatedEntrypoint.Configuration;
using SVSim.EmulatedEntrypoint.Extensions;
using SVSim.EmulatedEntrypoint.Infrastructure;
using SVSim.EmulatedEntrypoint.Matching;
using SVSim.EmulatedEntrypoint.Middlewares;
using SVSim.EmulatedEntrypoint.Security.SteamSessionAuthentication;
@@ -64,6 +65,19 @@ public class Program
// Disambiguate same-named DTOs across families (e.g. Story.StartRequest vs
// BasicPuzzle.StartRequest) by qualifying schema ids with the full type name.
c.CustomSchemaIds(t => t.FullName?.Replace("+", "."));
// Register the X-Admin-Secret shared-secret header so the Swagger UI shows an
// Authorize dialog for it. AdminSecretOperationFilter then attaches the requirement
// only to endpoints that carry [RequireAdminSecret], keeping the padlock off
// ordinary game endpoints.
c.AddSecurityDefinition(AdminSecretOperationFilter.SchemeId, new Microsoft.OpenApi.Models.OpenApiSecurityScheme
{
Name = RequireAdminSecretAttribute.HeaderName,
In = Microsoft.OpenApi.Models.ParameterLocation.Header,
Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey,
Description = "Shared secret for /admin/* endpoints (matches Admin:ImportSecret in appsettings).",
});
c.OperationFilter<AdminSecretOperationFilter>();
});
builder.Services.AddHttpLogging(opt =>
{
@@ -71,6 +85,7 @@ public class Program
});
builder.Services.Configure<DeckOptions>(builder.Configuration.GetSection(DeckOptions.SectionName));
builder.Services.Configure<AdminOptions>(builder.Configuration.GetSection(AdminOptions.SectionName));
#region Database Services