feat(infra): Docker-compose configuration and environment-specific security guards for Beta deployment to Test environment (#56)
This pull request introduces the dedicated containerized infrastructure and configuration for deploying NexusReader's beta version in the Test environment. ### Summary of Changes 1. **Docker Infrastructure & Secrets**: - **`docker-compose.test.yml`**: Configured dedicated database and auxiliary services (PostgreSQL 17, Qdrant, Neo4j) on isolated, non-standard ports to ensure zero conflict with the existing server configurations. - **`.env.test.template`**: Provided an environment variable template showing required setups, including mandatory database passwords, API keys, and admin custom passwords. - **`.gitignore`**: Excluded local `.env` files to prevent accidental commits of production or staging secrets. 2. **Database Hardening**: - Configured Neo4j with basic authentication (`IDriver` instantiation uses basic auth when credentials are provided in configuration). - Configured PostgreSQL to use mandatory authentication. - Configured the admin seeder (`DbInitializer.cs`) to dynamically use `NEXUS_ADMIN_PASSWORD` from environment variables, falling back to a default password in local Development only. 3. **Feature-Flagged Restrictions**: - **`appsettings.Test.json`**: Implemented `Features:AllowRegistration` and `Features:AllowPasswordReset` flags set to `false`. - **Middleware Enforcement (`Program.cs`)**: Intercepts requests to `/identity/register` and `/identity/forgotPassword` (and their MVC/form variations) and rejects them with a `403 Forbidden` response in restricted environments. - **OAuth Provisioning Guard (`Program.cs`)**: Blocks new account provisioning via Google OAuth callback by checking the `Features:AllowRegistration` configuration, redirecting users to the login page with a descriptive error. - **UI Protection (`Login.razor`, `Register.razor`)**: Conditionally hides registration/password reset links and intercepts manual navigation attempts to `/account/register` by redirecting to login with a warning. --------- Co-authored-by: Marek Jasiński <jasins.marek@gmail.com> Reviewed-on: #56 Co-authored-by: Antigravity <antigravity@google.com> Co-committed-by: Antigravity <antigravity@google.com>
This commit was merged in pull request #56.
This commit is contained in:
@@ -48,11 +48,15 @@ builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<IPlatformService, WebPlatformService>();
|
||||
builder.Services.AddScoped<INativeStorageService, NexusReader.Web.Services.NativeStorageService>();
|
||||
builder.Services.AddScoped<IThemeService, ThemeService>();
|
||||
// Feature settings (avoiding direct raw IConfiguration injection in client pages)
|
||||
var featureSettings = builder.Configuration.GetSection("Features").Get<FeatureSettings>() ?? new FeatureSettings();
|
||||
builder.Services.AddSingleton(featureSettings);
|
||||
builder.Services.AddScoped<IQuizStateService, QuizStateService>();
|
||||
builder.Services.AddScoped<IFocusModeService, FocusModeService>();
|
||||
builder.Services.AddScoped<IReaderNavigationService, ReaderNavigationService>();
|
||||
builder.Services.AddScoped<IKnowledgeGraphService, KnowledgeGraphService>();
|
||||
builder.Services.AddScoped<IReaderInteractionService, ReaderInteractionService>();
|
||||
builder.Services.AddScoped<IReaderStateService, ReaderStateService>();
|
||||
builder.Services.AddScoped<KnowledgeCoordinator>();
|
||||
builder.Services.AddScoped<ISyncService, SyncService>();
|
||||
|
||||
@@ -252,6 +256,35 @@ app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseAntiforgery();
|
||||
|
||||
// Feature flags: block registration & password reset in restricted environments (e.g. Test)
|
||||
var allowRegistration = app.Configuration.GetValue<bool?>("Features:AllowRegistration") ?? true;
|
||||
var allowPasswordReset = app.Configuration.GetValue<bool?>("Features:AllowPasswordReset") ?? true;
|
||||
|
||||
if (!allowRegistration || !allowPasswordReset)
|
||||
{
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var path = context.Request.Path.Value?.ToLowerInvariant();
|
||||
|
||||
if (!allowRegistration && path is "/identity/register" or "/account/register")
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await context.Response.WriteAsync("Registration is disabled in this environment.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allowPasswordReset && path is "/identity/forgotpassword" or "/account/forgot-password")
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await context.Response.WriteAsync("Password reset is disabled in this environment.");
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
app.MapStaticAssets();
|
||||
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
||||
|
||||
@@ -267,6 +300,50 @@ app.MapGet("/api/epub/{ebookId:guid}/{index:int}", async (Guid ebookId, int inde
|
||||
return Results.BadRequest(errorMsg);
|
||||
}).RequireAuthorization();
|
||||
|
||||
// API endpoint for WASM client/browser to fetch EPUB static resources (images, etc.)
|
||||
app.MapGet("/api/epub/{ebookId:guid}/resource", async (Guid ebookId, string path, IEpubReader epubService, ClaimsPrincipal user, HttpContext httpContext, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return Results.BadRequest("Path parameter is required.");
|
||||
}
|
||||
|
||||
var decodedPath = Uri.UnescapeDataString(path);
|
||||
if (decodedPath.Contains("..") || decodedPath.Contains(":") || decodedPath.StartsWith("/") || decodedPath.StartsWith("\\"))
|
||||
{
|
||||
return Results.BadRequest("Invalid resource path.");
|
||||
}
|
||||
|
||||
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var result = await epubService.GetEpubResourceAsync(ebookId, decodedPath, userId, cancellationToken);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
// Serve with client-side caching to avoid redundant roundtrips on chapter navigation
|
||||
httpContext.Response.Headers.CacheControl = "public, max-age=86400";
|
||||
|
||||
var ext = Path.GetExtension(decodedPath).ToLowerInvariant();
|
||||
var contentType = ext switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".svg" => "image/svg+xml",
|
||||
".webp" => "image/webp",
|
||||
".css" => "text/css",
|
||||
".otf" => "font/otf",
|
||||
".ttf" => "font/ttf",
|
||||
".woff" => "font/woff",
|
||||
".woff2" => "font/woff2",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
return Results.File(result.Value, contentType);
|
||||
}
|
||||
|
||||
var errorMsg = result.Errors.Count > 0 ? result.Errors[0].Message : "Resource not found";
|
||||
return Results.NotFound(errorMsg);
|
||||
}).RequireAuthorization();
|
||||
|
||||
var knowledgeApi = app.MapGroup("/api/knowledge")
|
||||
.RequireAuthorization("HasAvailableTokens")
|
||||
.DisableAntiforgery();
|
||||
@@ -489,7 +566,7 @@ app.MapGet("/identity/login/google", (string? returnUrl) =>
|
||||
var properties = new AuthenticationProperties
|
||||
{
|
||||
RedirectUri = "/identity/callback/google",
|
||||
Items = { { "returnUrl", returnUrl ?? "/" } }
|
||||
Items = { { "returnUrl", string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl } }
|
||||
};
|
||||
return Results.Challenge(properties, new[] { "Google" });
|
||||
});
|
||||
@@ -520,7 +597,13 @@ app.MapGet("/identity/callback/google", async (
|
||||
return Results.Redirect("/account/login?error=LockedOut");
|
||||
}
|
||||
|
||||
// New user provisioning
|
||||
// New user provisioning (blocked when registration is disabled)
|
||||
if (!allowRegistration)
|
||||
{
|
||||
logger.LogWarning("Google provisioning blocked: registration is disabled in this environment.");
|
||||
return Results.Redirect("/account/login?error=RegistrationDisabled");
|
||||
}
|
||||
|
||||
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
|
||||
if (email != null)
|
||||
{
|
||||
@@ -563,7 +646,7 @@ app.MapPost("/account/login-form", async (
|
||||
if (result.Succeeded)
|
||||
{
|
||||
logger.LogInformation("User logged in: {Email}", email);
|
||||
return Results.Redirect(returnUrl ?? "/");
|
||||
return Results.Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
|
||||
}
|
||||
|
||||
var error = result.IsLockedOut ? "LockedOut" : "InvalidCredentials";
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Features": {
|
||||
"AllowRegistration": false,
|
||||
"AllowPasswordReset": false
|
||||
},
|
||||
"ApiBaseUrl": "http://localhost:5000"
|
||||
}
|
||||
Reference in New Issue
Block a user