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:
@@ -0,0 +1,11 @@
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly-typed feature settings for the client UI layer.
|
||||
/// Used to decouple the UI from raw IConfiguration to prevent exposure of sensitive settings.
|
||||
/// </summary>
|
||||
public class FeatureSettings
|
||||
{
|
||||
public bool AllowRegistration { get; set; } = true;
|
||||
public bool AllowPasswordReset { get; set; } = true;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using NexusReader.UI.Shared.Models;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public interface IReaderInteractionService
|
||||
@@ -6,11 +8,16 @@ public interface IReaderInteractionService
|
||||
event Func<string, Task>? OnScrollToBlockRequested;
|
||||
event Func<string, Task>? OnHighlightBlockRequested;
|
||||
event Func<string, string, SelectionCoordinates, Task>? OnTextSelected;
|
||||
event Func<Task>? OnAssistantRequested;
|
||||
event Func<int, Task>? OnScrollPercentChanged;
|
||||
event Func<string, Task>? OnBlockReached;
|
||||
|
||||
Task NotifyNodeSelected(string nodeId);
|
||||
Task RequestScrollToBlock(string blockId);
|
||||
Task RequestHighlightBlock(string blockId);
|
||||
Task NotifyTextSelected(string text, string blockId, SelectionCoordinates coords);
|
||||
Task RequestAssistant();
|
||||
Task NotifyScrollPercentChanged(int percent);
|
||||
Task NotifyBlockReached(string blockId);
|
||||
}
|
||||
|
||||
public record SelectionCoordinates(double Top, double Left, double Width);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using NexusReader.UI.Shared.Models;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service to maintain local UI state for the reader, separating state from event bus.
|
||||
/// </summary>
|
||||
public interface IReaderStateService
|
||||
{
|
||||
int CurrentScrollPercentage { get; set; }
|
||||
List<string> CurrentCheckpoints { get; set; }
|
||||
string CurrentBlockId { get; set; }
|
||||
MobileReaderTab ActiveTab { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A lightweight, Native AOT-friendly JWT validator that decodes the payload of a JWT token
|
||||
/// to verify expiration without standard library dependencies.
|
||||
/// </summary>
|
||||
public static class JwtTokenValidator
|
||||
{
|
||||
public static bool IsExpired(string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)) return true;
|
||||
|
||||
try
|
||||
{
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 3) return true;
|
||||
|
||||
var payload = parts[1];
|
||||
|
||||
// Pad the base64 string
|
||||
var padLength = 4 - (payload.Length % 4);
|
||||
if (padLength < 4)
|
||||
{
|
||||
payload += new string('=', padLength);
|
||||
}
|
||||
|
||||
// Base64URL to standard Base64 conversion
|
||||
payload = payload.Replace('-', '+').Replace('_', '/');
|
||||
|
||||
var bytes = Convert.FromBase64String(payload);
|
||||
using var jsonDoc = JsonDocument.Parse(bytes);
|
||||
|
||||
if (jsonDoc.RootElement.TryGetProperty("exp", out var expElement))
|
||||
{
|
||||
var exp = expElement.GetInt64();
|
||||
var expTime = DateTimeOffset.FromUnixTimeSeconds(exp);
|
||||
|
||||
// Allow a small 10-second clock skew buffer
|
||||
return expTime <= DateTimeOffset.UtcNow.AddSeconds(10);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true; // Treat invalid token as expired
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
public sealed partial class KnowledgeCoordinator : IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly IKnowledgeService _knowledgeService;
|
||||
private readonly IKnowledgeGraphService _graphService;
|
||||
@@ -16,6 +16,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
private readonly IPlatformService _platformService;
|
||||
private readonly IReaderInteractionService _interactionService;
|
||||
private readonly ILogger<KnowledgeCoordinator> _logger;
|
||||
|
||||
private CancellationTokenSource? _graphCts;
|
||||
private CancellationTokenSource? _quizCts;
|
||||
|
||||
public string CurrentFullPageContent { get; private set; } = string.Empty;
|
||||
|
||||
@@ -75,9 +78,38 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelAndDisposeCts(ref CancellationTokenSource? cts)
|
||||
{
|
||||
var localCts = cts;
|
||||
cts = null;
|
||||
if (localCts != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
localCts.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException) { }
|
||||
finally
|
||||
{
|
||||
localCts.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ProcessFullPageAsync(string fullContent, string tenantId = "global", Guid? ebookId = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fullContent)) return;
|
||||
if (string.IsNullOrWhiteSpace(fullContent))
|
||||
{
|
||||
CancelAndDisposeCts(ref _graphCts);
|
||||
await _graphService.Clear();
|
||||
await _graphService.SetLoading(false);
|
||||
CurrentFullPageContent = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
CancelAndDisposeCts(ref _graphCts);
|
||||
_graphCts = new CancellationTokenSource();
|
||||
var token = _graphCts.Token;
|
||||
|
||||
CurrentFullPageContent = fullContent;
|
||||
LogGeneratingGraph(tenantId);
|
||||
@@ -87,7 +119,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId, ebookId);
|
||||
var result = await _knowledgeService.GetGraphDataAsync(fullContent, tenantId, ebookId, token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
var packet = result.Value;
|
||||
@@ -103,10 +137,17 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
|
||||
await _graphService.SetLoading(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("[KnowledgeCoordinator] Graph generation task was canceled.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _graphService.SetLoading(false);
|
||||
LogGraphError(ex, tenantId);
|
||||
if (!token.IsCancellationRequested)
|
||||
{
|
||||
await _graphService.SetLoading(false);
|
||||
LogGraphError(ex, tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,11 +159,17 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
|
||||
public async Task<Result<KnowledgePacket>> RequestSummaryAndQuizAsync(string content, string tenantId = "global")
|
||||
{
|
||||
CancelAndDisposeCts(ref _quizCts);
|
||||
_quizCts = new CancellationTokenSource();
|
||||
var token = _quizCts.Token;
|
||||
|
||||
await _quizService.SetHydrating(true);
|
||||
LogRequestingSummary(tenantId);
|
||||
try
|
||||
{
|
||||
var result = await _knowledgeService.GetSummaryAndQuizAsync(content, tenantId);
|
||||
var result = await _knowledgeService.GetSummaryAndQuizAsync(content, tenantId, cancellationToken: token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
var packet = result.Value;
|
||||
@@ -138,10 +185,19 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
LogSummaryWarning(tenantId);
|
||||
return Result.Fail(result.Errors);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("[KnowledgeCoordinator] Quiz and summary generation task was canceled.");
|
||||
return Result.Fail("Task canceled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogSummaryError(ex, tenantId);
|
||||
return Result.Fail(new Error("Error requesting summary and quiz").CausedBy(ex));
|
||||
if (!token.IsCancellationRequested)
|
||||
{
|
||||
LogSummaryError(ex, tenantId);
|
||||
return Result.Fail(new Error("Error requesting summary and quiz").CausedBy(ex));
|
||||
}
|
||||
return Result.Fail("Task canceled");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -151,6 +207,9 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
|
||||
public async Task ClearAsync()
|
||||
{
|
||||
CancelAndDisposeCts(ref _graphCts);
|
||||
CancelAndDisposeCts(ref _quizCts);
|
||||
|
||||
CurrentFullPageContent = string.Empty;
|
||||
await _graphService.Clear();
|
||||
await _quizService.SetQuiz(null, null);
|
||||
@@ -159,6 +218,27 @@ public sealed partial class KnowledgeCoordinator : IDisposable
|
||||
public void Dispose()
|
||||
{
|
||||
_interactionService.OnNodeSelected -= HandleNodeSelected;
|
||||
|
||||
CancelAndDisposeCts(ref _graphCts);
|
||||
CancelAndDisposeCts(ref _quizCts);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_interactionService.OnNodeSelected -= HandleNodeSelected;
|
||||
|
||||
CancelAndDisposeCts(ref _graphCts);
|
||||
CancelAndDisposeCts(ref _quizCts);
|
||||
|
||||
try
|
||||
{
|
||||
await _graphService.Clear();
|
||||
await _quizService.SetQuiz(null, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error clearing services during KnowledgeCoordinator disposal.");
|
||||
}
|
||||
}
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "[KnowledgeCoordinator] Generating full page graph for tenant: {TenantId}")]
|
||||
|
||||
@@ -4,20 +4,24 @@ using Microsoft.AspNetCore.Components.Authorization;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.Constants;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
private readonly INativeStorageService _storageService;
|
||||
private readonly PersistentComponentState _persistentState;
|
||||
|
||||
// SECURITY NOTE: We currently store roles in local storage to persist state across refreshes.
|
||||
// In a production SaaS environment, consider using ProtectedBrowserStorage (Blazor Server)
|
||||
// or encrypted storage/JWT claims validation to prevent client-side role tampering.
|
||||
private const string TokenKey = StorageKeys.AuthToken;
|
||||
|
||||
public NexusAuthenticationStateProvider(INativeStorageService storageService)
|
||||
public NexusAuthenticationStateProvider(INativeStorageService storageService, PersistentComponentState persistentState)
|
||||
{
|
||||
_storageService = storageService;
|
||||
_persistentState = persistentState;
|
||||
}
|
||||
|
||||
public void ClearCache()
|
||||
@@ -34,11 +38,23 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||
{
|
||||
if (_cachedState != null) return _cachedState;
|
||||
|
||||
// 0. Hydrate state from SSR if available in PersistentComponentState
|
||||
if (_persistentState.TryTakeFromJson<UserInfo>("UserInfo", out var userInfo) && userInfo != null)
|
||||
{
|
||||
// Save to local storage for subsequent client-only transitions/refreshes
|
||||
await _storageService.SaveSecureString(StorageKeys.UserEmail, userInfo.Email);
|
||||
await _storageService.SaveSecureString(StorageKeys.UserTenant, userInfo.TenantId);
|
||||
await _storageService.SaveSecureString(StorageKeys.UserRoles, userInfo.Roles);
|
||||
|
||||
_cachedState = CreateState(userInfo.Email, userInfo.TenantId, "FederatedHydration", userInfo.Roles);
|
||||
return _cachedState;
|
||||
}
|
||||
|
||||
var tokenResult = await _storageService.GetSecureString(TokenKey);
|
||||
var token = tokenResult.IsSuccess ? tokenResult.Value : null;
|
||||
|
||||
// 1. Try Token-based auth
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
if (!string.IsNullOrWhiteSpace(token) && !JwtTokenValidator.IsExpired(token))
|
||||
{
|
||||
var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
|
||||
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
|
||||
@@ -116,3 +132,10 @@ public class NexusAuthenticationStateProvider : AuthenticationStateProvider
|
||||
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest)));
|
||||
}
|
||||
}
|
||||
|
||||
public class UserInfo
|
||||
{
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public string Roles { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using NexusReader.UI.Shared.Models;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
public sealed class ReaderInteractionService : IReaderInteractionService
|
||||
@@ -6,6 +8,9 @@ public sealed class ReaderInteractionService : IReaderInteractionService
|
||||
public event Func<string, Task>? OnScrollToBlockRequested;
|
||||
public event Func<string, Task>? OnHighlightBlockRequested;
|
||||
public event Func<string, string, SelectionCoordinates, Task>? OnTextSelected;
|
||||
public event Func<Task>? OnAssistantRequested;
|
||||
public event Func<int, Task>? OnScrollPercentChanged;
|
||||
public event Func<string, Task>? OnBlockReached;
|
||||
|
||||
public async Task NotifyNodeSelected(string nodeId)
|
||||
{
|
||||
@@ -26,4 +31,20 @@ public sealed class ReaderInteractionService : IReaderInteractionService
|
||||
{
|
||||
if (OnTextSelected != null) await OnTextSelected(text, blockId, coords);
|
||||
}
|
||||
|
||||
public async Task RequestAssistant()
|
||||
{
|
||||
if (OnAssistantRequested != null) await OnAssistantRequested();
|
||||
}
|
||||
|
||||
public async Task NotifyScrollPercentChanged(int percent)
|
||||
{
|
||||
if (OnScrollPercentChanged != null) await OnScrollPercentChanged(percent);
|
||||
}
|
||||
|
||||
public async Task NotifyBlockReached(string blockId)
|
||||
{
|
||||
if (OnBlockReached != null) await OnBlockReached(blockId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using NexusReader.UI.Shared.Models;
|
||||
|
||||
namespace NexusReader.UI.Shared.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe implementation of IReaderStateService.
|
||||
/// Thread safety is ensured via lock-guarded property getters/setters.
|
||||
/// UI updates originating from the JS event loop (via JSInvokable) are synchronized at Blazor's InvokeAsync(StateHasChanged) render boundary.
|
||||
/// </summary>
|
||||
public sealed class ReaderStateService : IReaderStateService
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private int _scrollPercent;
|
||||
private List<string> _checkpoints = new();
|
||||
private string _blockId = string.Empty;
|
||||
private MobileReaderTab _activeTab = MobileReaderTab.Reader;
|
||||
|
||||
public int CurrentScrollPercentage
|
||||
{
|
||||
get { lock (_lock) return _scrollPercent; }
|
||||
set { lock (_lock) _scrollPercent = value; }
|
||||
}
|
||||
|
||||
public List<string> CurrentCheckpoints
|
||||
{
|
||||
get { lock (_lock) return _checkpoints; }
|
||||
set { lock (_lock) _checkpoints = value ?? new(); }
|
||||
}
|
||||
|
||||
public string CurrentBlockId
|
||||
{
|
||||
get { lock (_lock) return _blockId; }
|
||||
set { lock (_lock) _blockId = value ?? string.Empty; }
|
||||
}
|
||||
|
||||
public MobileReaderTab ActiveTab
|
||||
{
|
||||
get { lock (_lock) return _activeTab; }
|
||||
set { lock (_lock) _activeTab = value; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user