711480f8f6
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>
142 lines
5.9 KiB
C#
142 lines
5.9 KiB
C#
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
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, PersistentComponentState persistentState)
|
|
{
|
|
_storageService = storageService;
|
|
_persistentState = persistentState;
|
|
}
|
|
|
|
public void ClearCache()
|
|
{
|
|
_cachedState = null;
|
|
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
|
|
}
|
|
|
|
private AuthenticationState? _cachedState;
|
|
|
|
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
|
{
|
|
try
|
|
{
|
|
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) && !JwtTokenValidator.IsExpired(token))
|
|
{
|
|
var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
|
|
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
|
|
var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles);
|
|
|
|
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value))
|
|
{
|
|
_cachedState = CreateState(
|
|
emailResult.Value,
|
|
tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown",
|
|
"OpaqueBearer",
|
|
rolesResult.IsSuccess ? rolesResult.Value! : "");
|
|
return _cachedState;
|
|
}
|
|
}
|
|
|
|
// 2. Try Cookie-based auth indicators
|
|
var storedEmailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
|
|
if (storedEmailResult.IsSuccess && !string.IsNullOrEmpty(storedEmailResult.Value))
|
|
{
|
|
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
|
|
var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles);
|
|
_cachedState = CreateState(
|
|
storedEmailResult.Value,
|
|
tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown",
|
|
"CookieAuth",
|
|
rolesResult.IsSuccess ? rolesResult.Value! : "");
|
|
return _cachedState;
|
|
}
|
|
|
|
// 3. Fallback: If we have no local info, we might still have a cookie (e.g. after refresh or Google login).
|
|
// We should return anonymous for now but trigger a background check if we're in WASM.
|
|
// Wait! In WASM, the first GetAuthenticationStateAsync is awaited.
|
|
// We can do a quick check here if it's the first time.
|
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
|
|
}
|
|
}
|
|
|
|
private AuthenticationState CreateState(string email, string tenantId, string authType, string rolesStr = "")
|
|
{
|
|
var claims = new List<Claim>
|
|
{
|
|
new Claim(ClaimTypes.Name, email),
|
|
new Claim(ClaimTypes.Email, email),
|
|
new Claim("TenantId", tenantId)
|
|
};
|
|
|
|
if (!string.IsNullOrEmpty(rolesStr))
|
|
{
|
|
var roles = rolesStr.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
|
|
foreach (var role in roles)
|
|
{
|
|
claims.Add(new Claim(ClaimTypes.Role, role.Trim()));
|
|
}
|
|
}
|
|
|
|
var identity = new ClaimsIdentity(claims, authType);
|
|
return new AuthenticationState(new ClaimsPrincipal(identity));
|
|
}
|
|
|
|
public void NotifyUserAuthentication(string email, string tenantId, string rolesStr = "")
|
|
{
|
|
_cachedState = CreateState(email, tenantId, "OpaqueBearer", rolesStr);
|
|
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState));
|
|
}
|
|
|
|
public void NotifyUserLogout()
|
|
{
|
|
_cachedState = null;
|
|
var guest = new ClaimsPrincipal(new ClaimsIdentity());
|
|
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;
|
|
}
|