feat(infra): configure beta deployment to Test environment with hardened security and feature flags
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
# ===================================================================
|
||||||
|
# NexusReader — Test Environment Variables
|
||||||
|
# ===================================================================
|
||||||
|
# Copy this file to `.env` and fill in the values before deployment:
|
||||||
|
# cp .env.test.template .env
|
||||||
|
#
|
||||||
|
# Then deploy with:
|
||||||
|
# docker compose -f docker-compose.test.yml up -d --build
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
# === PostgreSQL ===
|
||||||
|
POSTGRES_USER=nexus_user
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||||
|
POSTGRES_DB=nexus_test_db
|
||||||
|
POSTGRES_PORT=5433
|
||||||
|
|
||||||
|
# === Neo4j ===
|
||||||
|
NEO4J_USERNAME=neo4j
|
||||||
|
NEO4J_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||||
|
|
||||||
|
# === Qdrant (leave empty to disable API key auth) ===
|
||||||
|
QDRANT_API_KEY=
|
||||||
|
|
||||||
|
# === Web App ===
|
||||||
|
WEB_PORT=5050
|
||||||
|
|
||||||
|
# === Google OAuth (placeholder for test) ===
|
||||||
|
GOOGLE_CLIENT_ID=placeholder
|
||||||
|
GOOGLE_CLIENT_SECRET=placeholder
|
||||||
|
|
||||||
|
# === Gemini AI (placeholder for test) ===
|
||||||
|
GOOGLE_AI_API_KEY=placeholder
|
||||||
|
|
||||||
|
# === Admin Seed Password ===
|
||||||
|
NEXUS_ADMIN_PASSWORD=aQ13EdSw2
|
||||||
|
|
||||||
|
# === Non-standard ports for auxiliary services ===
|
||||||
|
QDRANT_HTTP_PORT=6343
|
||||||
|
QDRANT_GRPC_PORT=6344
|
||||||
|
NEO4J_HTTP_PORT=7484
|
||||||
|
NEO4J_BOLT_PORT=7697
|
||||||
@@ -29,6 +29,7 @@ Thumbs.db
|
|||||||
*.epub
|
*.epub
|
||||||
|
|
||||||
.fake
|
.fake
|
||||||
|
.env
|
||||||
src/NexusReader.Web/nexus.db
|
src/NexusReader.Web/nexus.db
|
||||||
src/NexusReader.Web/wwwroot/covers/
|
src/NexusReader.Web/wwwroot/covers/
|
||||||
src/NexusReader.Web/wwwroot/uploads/
|
src/NexusReader.Web/wwwroot/uploads/
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
container_name: nexus-db-test
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-nexus_user}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-nexus_test_db}
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5433}:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata_test:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nexus_user} -d ${POSTGRES_DB:-nexus_test_db}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- nexus-test
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: nexus-web-test
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-5050}:5000"
|
||||||
|
environment:
|
||||||
|
- ASPNETCORE_ENVIRONMENT=Test
|
||||||
|
- ConnectionStrings__PostgresConnection=Host=db;Database=${POSTGRES_DB:-nexus_test_db};Username=${POSTGRES_USER:-nexus_user};Password=${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
||||||
|
- ConnectionStrings__QdrantConnection=http://qdrant:6334
|
||||||
|
- ConnectionStrings__Neo4jConnection=bolt://neo4j:7687
|
||||||
|
- Neo4j__Username=${NEO4J_USERNAME:-neo4j}
|
||||||
|
- Neo4j__Password=${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
||||||
|
- Authentication__Google__ClientId=${GOOGLE_CLIENT_ID:-placeholder}
|
||||||
|
- Authentication__Google__ClientSecret=${GOOGLE_CLIENT_SECRET:-placeholder}
|
||||||
|
- Ai__Google__ApiKey=${GOOGLE_AI_API_KEY:-placeholder}
|
||||||
|
- NEXUS_ADMIN_PASSWORD=${NEXUS_ADMIN_PASSWORD:-aQ13EdSw2}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
qdrant:
|
||||||
|
condition: service_healthy
|
||||||
|
neo4j:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- nexus-test
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
qdrant:
|
||||||
|
image: qdrant/qdrant:latest
|
||||||
|
container_name: nexus-qdrant-test
|
||||||
|
environment:
|
||||||
|
- QDRANT__SERVICE__API_KEY=${QDRANT_API_KEY:-}
|
||||||
|
ports:
|
||||||
|
- "${QDRANT_HTTP_PORT:-6343}:6333"
|
||||||
|
- "${QDRANT_GRPC_PORT:-6344}:6334"
|
||||||
|
volumes:
|
||||||
|
- qdrant_test_data:/qdrant/storage
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/6333'"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- nexus-test
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
neo4j:
|
||||||
|
image: neo4j:5-community
|
||||||
|
container_name: nexus-neo4j-test
|
||||||
|
environment:
|
||||||
|
- NEO4J_AUTH=${NEO4J_USERNAME:-neo4j}/${NEO4J_PASSWORD:?NEO4J_PASSWORD is required}
|
||||||
|
ports:
|
||||||
|
- "${NEO4J_HTTP_PORT:-7484}:7474"
|
||||||
|
- "${NEO4J_BOLT_PORT:-7697}:7687"
|
||||||
|
volumes:
|
||||||
|
- neo4j_test_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:7474 || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- nexus-test
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata_test:
|
||||||
|
qdrant_test_data:
|
||||||
|
neo4j_test_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
nexus-test:
|
||||||
|
driver: bridge
|
||||||
@@ -68,7 +68,8 @@ public static class DbInitializer
|
|||||||
SecurityStamp = Guid.NewGuid().ToString()
|
SecurityStamp = Guid.NewGuid().ToString()
|
||||||
};
|
};
|
||||||
|
|
||||||
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, "Admin123!");
|
var adminPassword = Environment.GetEnvironmentVariable("NEXUS_ADMIN_PASSWORD") ?? "Admin123!";
|
||||||
|
adminUser.PasswordHash = passwordHasher.HashPassword(adminUser, adminPassword);
|
||||||
|
|
||||||
dbContext.Users.Add(adminUser);
|
dbContext.Users.Add(adminUser);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
|
|||||||
@@ -56,9 +56,14 @@ public static class DependencyInjection
|
|||||||
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
var qdrantUrl = configuration.GetConnectionString("QdrantConnection") ?? "http://localhost:6334";
|
||||||
services.AddSingleton<QdrantClient>(sp => new QdrantClient(new Uri(qdrantUrl)));
|
services.AddSingleton<QdrantClient>(sp => new QdrantClient(new Uri(qdrantUrl)));
|
||||||
|
|
||||||
// Neo4j Driver registration
|
// Neo4j Driver registration (supports optional authentication)
|
||||||
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
|
var neo4jUrl = configuration.GetConnectionString("Neo4jConnection") ?? "bolt://localhost:7687";
|
||||||
services.AddSingleton<IDriver>(sp => GraphDatabase.Driver(neo4jUrl, AuthTokens.None));
|
var neo4jUser = configuration["Neo4j:Username"];
|
||||||
|
var neo4jPass = configuration["Neo4j:Password"];
|
||||||
|
var neo4jAuth = !string.IsNullOrEmpty(neo4jUser)
|
||||||
|
? AuthTokens.Basic(neo4jUser, neo4jPass ?? string.Empty)
|
||||||
|
: AuthTokens.None;
|
||||||
|
services.AddSingleton<IDriver>(sp => GraphDatabase.Driver(neo4jUrl, neo4jAuth));
|
||||||
|
|
||||||
// Hangfire registration
|
// Hangfire registration
|
||||||
if (!string.IsNullOrEmpty(pgConnectionString))
|
if (!string.IsNullOrEmpty(pgConnectionString))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@inject IIdentityService IdentityService
|
@inject IIdentityService IdentityService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
|
@inject IConfiguration Configuration
|
||||||
|
|
||||||
<div class="login-page-container">
|
<div class="login-page-container">
|
||||||
<div class="mesh-bg"></div>
|
<div class="mesh-bg"></div>
|
||||||
@@ -80,8 +81,14 @@
|
|||||||
</EditForm>
|
</EditForm>
|
||||||
|
|
||||||
<div class="auth-footer">
|
<div class="auth-footer">
|
||||||
|
@if (_allowPasswordReset)
|
||||||
|
{
|
||||||
<a href="/account/forgot-password" class="auth-link">Zapomniałem hasła?</a>
|
<a href="/account/forgot-password" class="auth-link">Zapomniałem hasła?</a>
|
||||||
|
}
|
||||||
|
@if (_allowRegistration)
|
||||||
|
{
|
||||||
<p class="auth-switch">Nie masz konta? <a href="/account/register">Zarejestruj się</a></p>
|
<p class="auth-switch">Nie masz konta? <a href="/account/register">Zarejestruj się</a></p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="auth-legal">
|
<div class="auth-legal">
|
||||||
@@ -106,9 +113,14 @@
|
|||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
private bool _isSubmitting;
|
private bool _isSubmitting;
|
||||||
private bool _showPassword;
|
private bool _showPassword;
|
||||||
|
private bool _allowRegistration;
|
||||||
|
private bool _allowPasswordReset;
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
|
_allowRegistration = Configuration.GetValue<bool?>("Features:AllowRegistration") ?? true;
|
||||||
|
_allowPasswordReset = Configuration.GetValue<bool?>("Features:AllowPasswordReset") ?? true;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(ErrorCode))
|
if (!string.IsNullOrEmpty(ErrorCode))
|
||||||
{
|
{
|
||||||
_errorMessage = ErrorCode switch
|
_errorMessage = ErrorCode switch
|
||||||
@@ -118,6 +130,7 @@
|
|||||||
"UserAlreadyExists" => "Użytkownik o tym adresie e-mail już istnieje. Zaloguj się tradycyjnie hasłem.",
|
"UserAlreadyExists" => "Użytkownik o tym adresie e-mail już istnieje. Zaloguj się tradycyjnie hasłem.",
|
||||||
"LockedOut" => "Twoje konto zostało zablokowane. Spróbuj ponownie później.",
|
"LockedOut" => "Twoje konto zostało zablokowane. Spróbuj ponownie później.",
|
||||||
"InvalidCredentials" => "Nieprawidłowy e-mail lub hasło.",
|
"InvalidCredentials" => "Nieprawidłowy e-mail lub hasło.",
|
||||||
|
"RegistrationDisabled" => "Rejestracja jest wyłączona w tym środowisku.",
|
||||||
_ => "Wystąpił nieoczekiwany błąd podczas logowania."
|
_ => "Wystąpił nieoczekiwany błąd podczas logowania."
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
@inject IIdentityService IdentityService
|
@inject IIdentityService IdentityService
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
|
@inject IConfiguration Configuration
|
||||||
|
|
||||||
<div class="login-page-container">
|
<div class="login-page-container">
|
||||||
<div class="mesh-bg"></div>
|
<div class="mesh-bg"></div>
|
||||||
@@ -81,6 +82,15 @@
|
|||||||
private string? _errorMessage;
|
private string? _errorMessage;
|
||||||
private bool _isSubmitting;
|
private bool _isSubmitting;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
var allowRegistration = Configuration.GetValue<bool?>("Features:AllowRegistration") ?? true;
|
||||||
|
if (!allowRegistration)
|
||||||
|
{
|
||||||
|
NavigationManager.NavigateTo("/account/login?error=RegistrationDisabled", replace: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleRegister()
|
private async Task HandleRegister()
|
||||||
{
|
{
|
||||||
_isSubmitting = true;
|
_isSubmitting = true;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
@using NexusReader.UI.Shared.Components.Organisms
|
@using NexusReader.UI.Shared.Components.Organisms
|
||||||
@using NexusReader.UI.Shared.Services
|
@using NexusReader.UI.Shared.Services
|
||||||
@using Microsoft.Extensions.Logging
|
@using Microsoft.Extensions.Logging
|
||||||
|
@using Microsoft.Extensions.Configuration
|
||||||
@using NexusReader.Application.Abstractions.Services
|
@using NexusReader.Application.Abstractions.Services
|
||||||
@using NexusReader.Application.DTOs.User
|
@using NexusReader.Application.DTOs.User
|
||||||
@using NexusReader.Application.Queries.Reader
|
@using NexusReader.Application.Queries.Reader
|
||||||
|
|||||||
@@ -252,6 +252,35 @@ app.UseRouting();
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseAntiforgery();
|
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.MapStaticAssets();
|
||||||
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
app.MapHub<NexusReader.Infrastructure.RealTime.SyncHub>("/synchub");
|
||||||
|
|
||||||
@@ -520,7 +549,13 @@ app.MapGet("/identity/callback/google", async (
|
|||||||
return Results.Redirect("/account/login?error=LockedOut");
|
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);
|
var email = info.Principal.FindFirstValue(ClaimTypes.Email);
|
||||||
if (email != null)
|
if (email != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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