Add admin access and landing dashboard

This commit is contained in:
2026-05-21 13:43:47 +02:00
parent 6b3dc2de60
commit 9471c5c310
19 changed files with 1442 additions and 456 deletions
@@ -0,0 +1,40 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace TrafagSalesExporter.Services;
internal static class AccessPasswordSettingsWriter
{
private static readonly object FileLock = new();
public static string HashPassword(string password)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
public static void SavePasswordHash(string contentRootPath, string sectionName, string passwordHash)
{
var path = Path.Combine(contentRootPath, "appsettings.json");
lock (FileLock)
{
var json = File.Exists(path)
? File.ReadAllText(path, Encoding.UTF8)
: "{}";
var root = JsonNode.Parse(json)?.AsObject() ?? new JsonObject();
var section = root[sectionName] as JsonObject;
if (section is null)
{
section = new JsonObject();
root[sectionName] = section;
}
section["PasswordHash"] = passwordHash;
section["Password"] = string.Empty;
var options = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(path, root.ToJsonString(options), new UTF8Encoding(false));
}
}
}
@@ -0,0 +1,54 @@
using System.Collections.Concurrent;
namespace TrafagSalesExporter.Services;
public interface IAccessSessionTracker
{
IReadOnlyList<AccessSessionSnapshot> GetActiveSessions();
void Register(string sessionId, string area, string username, string? remoteAddress);
void Touch(string sessionId);
void Unregister(string sessionId);
}
public sealed class AccessSessionTracker : IAccessSessionTracker
{
private readonly ConcurrentDictionary<string, AccessSessionSnapshot> _sessions = new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyList<AccessSessionSnapshot> GetActiveSessions()
=> _sessions.Values
.OrderByDescending(session => session.LastSeenAt)
.ToList();
public void Register(string sessionId, string area, string username, string? remoteAddress)
{
var now = DateTimeOffset.Now;
_sessions[sessionId] = new AccessSessionSnapshot(
sessionId,
area,
username,
string.IsNullOrWhiteSpace(remoteAddress) ? "unbekannt" : remoteAddress,
now,
now);
}
public void Touch(string sessionId)
{
if (!_sessions.TryGetValue(sessionId, out var session))
return;
_sessions[sessionId] = session with { LastSeenAt = DateTimeOffset.Now };
}
public void Unregister(string sessionId)
{
_sessions.TryRemove(sessionId, out _);
}
}
public sealed record AccessSessionSnapshot(
string SessionId,
string Area,
string Username,
string RemoteAddress,
DateTimeOffset StartedAt,
DateTimeOffset LastSeenAt);
@@ -0,0 +1,96 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using TrafagSalesExporter.Security;
namespace TrafagSalesExporter.Services;
public interface IAdminAccessService
{
bool IsEnabled { get; }
bool IsConfigured { get; }
bool IsUnlocked { get; }
bool TryUnlock(string username, string password);
bool TryChangePassword(string username, string currentPassword, string newPassword);
void Lock();
}
public sealed class AdminAccessService : IAdminAccessService
{
private readonly AdminAccessOptions _options;
private readonly IHostEnvironment _environment;
public AdminAccessService(IOptions<AdminAccessOptions> options, IHostEnvironment environment)
{
_options = options.Value;
_environment = environment;
}
public bool IsEnabled => _options.Enabled;
public bool IsConfigured =>
!IsEnabled ||
!string.IsNullOrWhiteSpace(_options.Username) &&
(!string.IsNullOrWhiteSpace(_options.PasswordHash) || !string.IsNullOrEmpty(_options.Password));
public bool IsUnlocked { get; private set; }
public bool TryUnlock(string username, string password)
{
if (!IsEnabled)
{
IsUnlocked = true;
return true;
}
if (!IsConfigured ||
string.IsNullOrWhiteSpace(username) ||
string.IsNullOrEmpty(password) ||
!FixedEquals(username.Trim(), _options.Username.Trim()))
{
return false;
}
var valid = !string.IsNullOrWhiteSpace(_options.PasswordHash)
? VerifyPasswordHash(password, _options.PasswordHash)
: FixedEquals(password, _options.Password);
IsUnlocked = valid;
return valid;
}
public bool TryChangePassword(string username, string currentPassword, string newPassword)
{
if (!IsEnabled ||
!IsConfigured ||
string.IsNullOrWhiteSpace(newPassword) ||
newPassword.Length < 8 ||
!TryUnlock(username, currentPassword))
{
return false;
}
var passwordHash = AccessPasswordSettingsWriter.HashPassword(newPassword);
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, AdminAccessOptions.SectionName, passwordHash);
_options.PasswordHash = passwordHash;
_options.Password = string.Empty;
IsUnlocked = true;
return true;
}
public void Lock() => IsUnlocked = false;
private static bool VerifyPasswordHash(string password, string configuredHash)
{
var passwordHash = AccessPasswordSettingsWriter.HashPassword(password);
return FixedEquals(passwordHash, configuredHash.Trim());
}
private static bool FixedEquals(string left, string right)
{
var leftBytes = Encoding.UTF8.GetBytes(left);
var rightBytes = Encoding.UTF8.GetBytes(right);
return leftBytes.Length == rightBytes.Length &&
CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes);
}
}
@@ -11,16 +11,28 @@ public interface IFinanceCockpitAccessService
bool IsConfigured { get; }
bool IsUnlocked { get; }
bool TryUnlock(string username, string password);
bool TryChangePassword(string username, string currentPassword, string newPassword);
void Lock();
}
public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService
public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService, IDisposable
{
private readonly FinanceCockpitAccessOptions _options;
private readonly IHostEnvironment _environment;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IAccessSessionTracker _sessionTracker;
private readonly string _sessionId = Guid.NewGuid().ToString("N");
public FinanceCockpitAccessService(IOptions<FinanceCockpitAccessOptions> options)
public FinanceCockpitAccessService(
IOptions<FinanceCockpitAccessOptions> options,
IHostEnvironment environment,
IHttpContextAccessor httpContextAccessor,
IAccessSessionTracker sessionTracker)
{
_options = options.Value;
_environment = environment;
_httpContextAccessor = httpContextAccessor;
_sessionTracker = sessionTracker;
}
public bool IsEnabled => _options.Enabled;
@@ -53,14 +65,48 @@ public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService
: FixedEquals(password, _options.Password);
IsUnlocked = valid;
if (valid)
_sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress());
return valid;
}
public void Lock() => IsUnlocked = false;
public void Lock()
{
IsUnlocked = false;
_sessionTracker.Unregister(_sessionId);
}
public bool TryChangePassword(string username, string currentPassword, string newPassword)
{
if (!IsEnabled ||
!IsConfigured ||
string.IsNullOrWhiteSpace(newPassword) ||
newPassword.Length < 8 ||
!TryUnlock(username, currentPassword))
{
return false;
}
var passwordHash = AccessPasswordSettingsWriter.HashPassword(newPassword);
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, FinanceCockpitAccessOptions.SectionName, passwordHash);
_options.PasswordHash = passwordHash;
_options.Password = string.Empty;
IsUnlocked = true;
_sessionTracker.Register(_sessionId, "Finance Cockpit", username.Trim(), GetRemoteAddress());
return true;
}
public void Dispose()
{
_sessionTracker.Unregister(_sessionId);
}
private string? GetRemoteAddress()
=> _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
private static bool VerifyPasswordHash(string password, string configuredHash)
{
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
var passwordHash = AccessPasswordSettingsWriter.HashPassword(password);
return FixedEquals(passwordHash, configuredHash.Trim());
}
@@ -11,16 +11,28 @@ public interface IHrKpiAccessService
bool IsConfigured { get; }
bool IsUnlocked { get; }
bool TryUnlock(string username, string password);
bool TryChangePassword(string username, string currentPassword, string newPassword);
void Lock();
}
public sealed class HrKpiAccessService : IHrKpiAccessService
public sealed class HrKpiAccessService : IHrKpiAccessService, IDisposable
{
private readonly HrKpiAccessOptions _options;
private readonly IHostEnvironment _environment;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IAccessSessionTracker _sessionTracker;
private readonly string _sessionId = Guid.NewGuid().ToString("N");
public HrKpiAccessService(IOptions<HrKpiAccessOptions> options)
public HrKpiAccessService(
IOptions<HrKpiAccessOptions> options,
IHostEnvironment environment,
IHttpContextAccessor httpContextAccessor,
IAccessSessionTracker sessionTracker)
{
_options = options.Value;
_environment = environment;
_httpContextAccessor = httpContextAccessor;
_sessionTracker = sessionTracker;
}
public bool IsEnabled => _options.Enabled;
@@ -53,14 +65,48 @@ public sealed class HrKpiAccessService : IHrKpiAccessService
: FixedEquals(password, _options.Password);
IsUnlocked = valid;
if (valid)
_sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress());
return valid;
}
public void Lock() => IsUnlocked = false;
public void Lock()
{
IsUnlocked = false;
_sessionTracker.Unregister(_sessionId);
}
public bool TryChangePassword(string username, string currentPassword, string newPassword)
{
if (!IsEnabled ||
!IsConfigured ||
string.IsNullOrWhiteSpace(newPassword) ||
newPassword.Length < 8 ||
!TryUnlock(username, currentPassword))
{
return false;
}
var passwordHash = AccessPasswordSettingsWriter.HashPassword(newPassword);
AccessPasswordSettingsWriter.SavePasswordHash(_environment.ContentRootPath, HrKpiAccessOptions.SectionName, passwordHash);
_options.PasswordHash = passwordHash;
_options.Password = string.Empty;
IsUnlocked = true;
_sessionTracker.Register(_sessionId, "HR KPI", username.Trim(), GetRemoteAddress());
return true;
}
public void Dispose()
{
_sessionTracker.Unregister(_sessionId);
}
private string? GetRemoteAddress()
=> _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
private static bool VerifyPasswordHash(string password, string configuredHash)
{
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(password)));
var passwordHash = AccessPasswordSettingsWriter.HashPassword(password);
return FixedEquals(passwordHash, configuredHash.Trim());
}
@@ -26,6 +26,7 @@ public sealed class UiTextService : IUiTextService
["es"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Trafag Finance/Sales Management Cockpit"] = "Trafag Cockpit de finanzas y ventas",
["Willkommen im Trafag Analyse Dashboard"] = "Bienvenido al panel analítico de Trafag",
["Finance Cockpit"] = "Cockpit financiero",
["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "El cockpit financiero está protegido. Inicie sesión por separado.",
["Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren."] = "El acceso al cockpit financiero aún no está configurado. Configure Username y PasswordHash en FinanceCockpitAccess.",
@@ -40,6 +41,13 @@ public sealed class UiTextService : IUiTextService
["Finance Regeln"] = "Reglas financieras",
["Settings"] = "Configuración",
["Logs"] = "Registros",
["Aktive Logins"] = "Inicios de sesión activos",
["Admin Bereich"] = "Área de administración",
["Adminbereich ist geschützt. Bitte anmelden."] = "El área de administración está protegida. Inicie sesión.",
["Admin-Zugang ist noch nicht konfiguriert."] = "El acceso de administrador aún no está configurado.",
["Admin entsperren"] = "Desbloquear administración",
["Admin-Anmeldung fehlgeschlagen."] = "Error al iniciar sesión como administrador.",
["Admin sperren"] = "Bloquear administración",
["Finance sperren"] = "Bloquear finanzas",
["HR KPI (Login)"] = "KPI RR. HH. (login)",
["HR Dashboard"] = "Panel HR",
@@ -50,6 +58,24 @@ public sealed class UiTextService : IUiTextService
["HR-KPI-Anmeldung fehlgeschlagen."] = "Error al iniciar sesión en HR KPI.",
["Name"] = "Nombre",
["Passwort"] = "Contraseña",
["Passwort ändern"] = "Cambiar contraseña",
["Aktuelles Passwort"] = "Contraseña actual",
["Neues Passwort"] = "Nueva contraseña",
["Mindestens 8 Zeichen."] = "Al menos 8 caracteres.",
["Neues Passwort wiederholen"] = "Repetir nueva contraseña",
["Passwort speichern"] = "Guardar contraseña",
["Das neue Passwort muss mindestens 8 Zeichen lang sein."] = "La nueva contraseña debe tener al menos 8 caracteres.",
["Die neuen Passwörter stimmen nicht überein."] = "Las nuevas contraseñas no coinciden.",
["Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen."] = "No se pudo cambiar la contraseña. Compruebe el nombre o la contraseña actual.",
["Passwort wurde geändert."] = "La contraseña se ha cambiado.",
["HR-/Finance-Cockpit Sessions"] = "Sesiones de cockpit HR/Finance",
["Gezählt werden App-interne Entsperrungen seit dem letzten App-Start."] = "Se cuentan los desbloqueos internos de la app desde el último inicio.",
["Bereich"] = "Área",
["IP-Adresse"] = "Dirección IP",
["Entsperrt seit"] = "Desbloqueado desde",
["Zuletzt gesehen"] = "Visto por última vez",
["Keine aktiven HR-/Finance-Logins erfasst."] = "No hay inicios de sesión HR/Finance activos registrados.",
["Hinweis: HR und Finance verwenden gemeinsame App-Logins. Diese Seite zeigt daher den verwendeten Login-Namen und die Session, nicht zwingend die echte Person."] = "Nota: HR y Finance usan logins compartidos de la app. Esta página muestra el nombre de login usado y la sesión, no necesariamente la persona real.",
["Finance Cockpit entsperren"] = "Desbloquear cockpit financiero",
["Finance-Jahr"] = "Año financiero",
["Finance Summary laden"] = "Cargar resumen financiero",
@@ -230,6 +256,7 @@ public sealed class UiTextService : IUiTextService
["it"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Trafag Finance/Sales Management Cockpit"] = "Cockpit Trafag finanza e vendite",
["Willkommen im Trafag Analyse Dashboard"] = "Benvenuto nel dashboard analitico Trafag",
["Finance Cockpit"] = "Cockpit finance",
["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "Il cockpit finance è protetto. Effettuare un accesso separato.",
["Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren."] = "L'accesso al cockpit finance non è ancora configurato. Configurare Username e PasswordHash in FinanceCockpitAccess.",
@@ -244,6 +271,13 @@ public sealed class UiTextService : IUiTextService
["Finance Regeln"] = "Regole finance",
["Settings"] = "Impostazioni",
["Logs"] = "Log",
["Aktive Logins"] = "Login attivi",
["Admin Bereich"] = "Area admin",
["Adminbereich ist geschützt. Bitte anmelden."] = "L'area admin è protetta. Effettuare l'accesso.",
["Admin-Zugang ist noch nicht konfiguriert."] = "L'accesso admin non è ancora configurato.",
["Admin entsperren"] = "Sblocca admin",
["Admin-Anmeldung fehlgeschlagen."] = "Accesso admin non riuscito.",
["Admin sperren"] = "Blocca admin",
["Finance sperren"] = "Blocca finance",
["HR KPI (Login)"] = "KPI HR (login)",
["HR Dashboard"] = "Dashboard HR",
@@ -254,6 +288,24 @@ public sealed class UiTextService : IUiTextService
["HR-KPI-Anmeldung fehlgeschlagen."] = "Accesso a HR KPI non riuscito.",
["Name"] = "Nome",
["Passwort"] = "Password",
["Passwort ändern"] = "Cambia password",
["Aktuelles Passwort"] = "Password attuale",
["Neues Passwort"] = "Nuova password",
["Mindestens 8 Zeichen."] = "Almeno 8 caratteri.",
["Neues Passwort wiederholen"] = "Ripeti nuova password",
["Passwort speichern"] = "Salva password",
["Das neue Passwort muss mindestens 8 Zeichen lang sein."] = "La nuova password deve contenere almeno 8 caratteri.",
["Die neuen Passwörter stimmen nicht überein."] = "Le nuove password non corrispondono.",
["Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen."] = "Impossibile modificare la password. Controllare nome o password attuale.",
["Passwort wurde geändert."] = "La password è stata modificata.",
["HR-/Finance-Cockpit Sessions"] = "Sessioni cockpit HR/Finance",
["Gezählt werden App-interne Entsperrungen seit dem letzten App-Start."] = "Vengono contati gli sblocchi interni dell'app dall'ultimo avvio.",
["Bereich"] = "Area",
["IP-Adresse"] = "Indirizzo IP",
["Entsperrt seit"] = "Sbloccato da",
["Zuletzt gesehen"] = "Ultima attività",
["Keine aktiven HR-/Finance-Logins erfasst."] = "Nessun login HR/Finance attivo registrato.",
["Hinweis: HR und Finance verwenden gemeinsame App-Logins. Diese Seite zeigt daher den verwendeten Login-Namen und die Session, nicht zwingend die echte Person."] = "Nota: HR e Finance usano login condivisi dell'app. Questa pagina mostra quindi il nome login usato e la sessione, non necessariamente la persona reale.",
["Finance Cockpit entsperren"] = "Sblocca cockpit finance",
["Finance-Jahr"] = "Anno finance",
["Finance Summary laden"] = "Carica riepilogo finance",
@@ -434,6 +486,7 @@ public sealed class UiTextService : IUiTextService
["hi"] = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Trafag Finance/Sales Management Cockpit"] = "Trafag वित्त और बिक्री प्रबंधन कॉकपिट",
["Willkommen im Trafag Analyse Dashboard"] = "Trafag विश्लेषण डैशबोर्ड में आपका स्वागत है",
["Finance Cockpit"] = "वित्त कॉकपिट",
["Finance Cockpit ist geschuetzt. Bitte separat anmelden."] = "वित्त कॉकपिट सुरक्षित है. कृपया अलग से साइन इन करें.",
["Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren."] = "वित्त कॉकपिट एक्सेस अभी कॉन्फ़िगर नहीं है. कृपया FinanceCockpitAccess में Username और PasswordHash सेट करें.",
@@ -448,6 +501,13 @@ public sealed class UiTextService : IUiTextService
["Finance Regeln"] = "वित्त नियम",
["Settings"] = "सेटिंग्स",
["Logs"] = "लॉग",
["Aktive Logins"] = "सक्रिय लॉगिन",
["Admin Bereich"] = "Admin क्षेत्र",
["Adminbereich ist geschützt. Bitte anmelden."] = "Admin क्षेत्र सुरक्षित है. कृपया साइन इन करें.",
["Admin-Zugang ist noch nicht konfiguriert."] = "Admin एक्सेस अभी कॉन्फ़िगर नहीं है.",
["Admin entsperren"] = "Admin अनलॉक करें",
["Admin-Anmeldung fehlgeschlagen."] = "Admin साइन-इन विफल.",
["Admin sperren"] = "Admin लॉक करें",
["Finance sperren"] = "वित्त लॉक करें",
["HR KPI (Login)"] = "HR KPI (लॉगिन)",
["HR Dashboard"] = "HR डैशबोर्ड",
@@ -458,6 +518,24 @@ public sealed class UiTextService : IUiTextService
["HR-KPI-Anmeldung fehlgeschlagen."] = "HR KPI साइन-इन विफल.",
["Name"] = "नाम",
["Passwort"] = "पासवर्ड",
["Passwort ändern"] = "पासवर्ड बदलें",
["Aktuelles Passwort"] = "वर्तमान पासवर्ड",
["Neues Passwort"] = "नया पासवर्ड",
["Mindestens 8 Zeichen."] = "कम से कम 8 अक्षर.",
["Neues Passwort wiederholen"] = "नया पासवर्ड दोहराएं",
["Passwort speichern"] = "पासवर्ड सहेजें",
["Das neue Passwort muss mindestens 8 Zeichen lang sein."] = "नया पासवर्ड कम से कम 8 अक्षरों का होना चाहिए.",
["Die neuen Passwörter stimmen nicht überein."] = "नए पासवर्ड मेल नहीं खाते.",
["Passwort konnte nicht geändert werden. Name oder aktuelles Passwort prüfen."] = "पासवर्ड बदला नहीं जा सका. नाम या वर्तमान पासवर्ड जांचें.",
["Passwort wurde geändert."] = "पासवर्ड बदल दिया गया.",
["HR-/Finance-Cockpit Sessions"] = "HR/Finance cockpit sessions",
["Gezählt werden App-interne Entsperrungen seit dem letzten App-Start."] = "ऐप के पिछले प्रारंभ के बाद से आंतरिक अनलॉक गिने जाते हैं.",
["Bereich"] = "क्षेत्र",
["IP-Adresse"] = "IP पता",
["Entsperrt seit"] = "अनलॉक समय",
["Zuletzt gesehen"] = "अंतिम बार देखा गया",
["Keine aktiven HR-/Finance-Logins erfasst."] = "कोई सक्रिय HR/Finance लॉगिन दर्ज नहीं है.",
["Hinweis: HR und Finance verwenden gemeinsame App-Logins. Diese Seite zeigt daher den verwendeten Login-Namen und die Session, nicht zwingend die echte Person."] = "नोट: HR और Finance साझा ऐप लॉगिन का उपयोग करते हैं. इसलिए यह पेज उपयोग किया गया लॉगिन नाम और सत्र दिखाता है, जरूरी नहीं कि वास्तविक व्यक्ति.",
["Finance Cockpit entsperren"] = "वित्त कॉकपिट अनलॉक करें",
["Finance-Jahr"] = "वित्त वर्ष",
["Finance Summary laden"] = "वित्त सारांश लोड करें",