Protect finance cockpit with login

This commit is contained in:
2026-05-19 09:40:15 +02:00
parent 5c654ad848
commit 9c544afa20
8 changed files with 217 additions and 32 deletions
@@ -0,0 +1,47 @@
@using TrafagSalesExporter.Services
@inject IFinanceCockpitAccessService FinanceAccess
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
@inject IUiTextService UiText
<MudText Typo="Typo.h4" Class="mb-4">@T("Finance Cockpit", "Finance Cockpit")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1" Style="max-width:520px;">
<MudStack Spacing="3">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
@T("Finance Cockpit ist geschuetzt. Bitte separat anmelden.", "Finance Cockpit is protected. Please sign in separately.")
</MudAlert>
@if (!FinanceAccess.IsConfigured)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Filled">
@T("Finance-Cockpit-Zugang ist noch nicht konfiguriert. Bitte Username und PasswordHash in FinanceCockpitAccess konfigurieren.", "Finance Cockpit access is not configured yet. Please configure Username and PasswordHash in FinanceCockpitAccess.")
</MudAlert>
}
<MudTextField @bind-Value="_username" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField @bind-Value="_password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="UnlockAsync"
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!FinanceAccess.IsConfigured)">
@T("Finance Cockpit entsperren", "Unlock Finance Cockpit")
</MudButton>
</MudStack>
</MudPaper>
@code {
private string? _username;
private string? _password;
private Task UnlockAsync()
{
if (!FinanceAccess.TryUnlock(_username ?? string.Empty, _password ?? string.Empty))
{
Snackbar.Add(T("Finance-Cockpit-Anmeldung fehlgeschlagen.", "Finance Cockpit sign-in failed."), Severity.Error);
return Task.CompletedTask;
}
_password = string.Empty;
Navigation.Refresh(forceReload: false);
return Task.CompletedTask;
}
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -1,5 +1,7 @@
@using TrafagSalesExporter.Security @using TrafagSalesExporter.Security
@inject TrafagSalesExporter.Services.IUiTextService UiText @inject TrafagSalesExporter.Services.IUiTextService UiText
@inject TrafagSalesExporter.Services.IFinanceCockpitAccessService FinanceAccess
@inject NavigationManager Navigation
<MudNavMenu> <MudNavMenu>
<MudNavGroup Title="@T("Finance Cockpit", "Finance Cockpit")" Icon="@Icons.Material.Filled.Analytics" Expanded="true"> <MudNavGroup Title="@T("Finance Cockpit", "Finance Cockpit")" Icon="@Icons.Material.Filled.Analytics" Expanded="true">
@@ -12,10 +14,6 @@
<MudNavLink Href="/finance-cockpit/vergleich" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.CompareArrows"> <MudNavLink Href="/finance-cockpit/vergleich" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.CompareArrows">
@T("Soll/Ist Vergleich", "Actual/reference comparison") @T("Soll/Ist Vergleich", "Actual/reference comparison")
</MudNavLink> </MudNavLink>
</MudNavGroup>
<MudNavLink Href="/hr-kpi" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Groups">
@T("HR KPI (Login)", "HR KPI (login)")
</MudNavLink>
<MudNavGroup Title="@T("Admin", "Admin")" Icon="@Icons.Material.Filled.AdminPanelSettings"> <MudNavGroup Title="@T("Admin", "Admin")" Icon="@Icons.Material.Filled.AdminPanelSettings">
<AuthorizeView Policy="@SecurityPolicies.AdminOnly"> <AuthorizeView Policy="@SecurityPolicies.AdminOnly">
<Authorized> <Authorized>
@@ -34,8 +32,25 @@
@T("Logs", "Logs") @T("Logs", "Logs")
</MudNavLink> </MudNavLink>
</MudNavGroup> </MudNavGroup>
@if (FinanceAccess.IsEnabled && FinanceAccess.IsUnlocked)
{
<MudButton Variant="Variant.Text" Color="Color.Secondary" Size="Size.Small"
StartIcon="@Icons.Material.Filled.Lock" OnClick="LockFinanceCockpit" Class="ml-3">
@T("Finance sperren", "Lock finance")
</MudButton>
}
</MudNavGroup>
<MudNavLink Href="/hr-kpi" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Groups">
@T("HR KPI (Login)", "HR KPI (login)")
</MudNavLink>
</MudNavMenu> </MudNavMenu>
@code { @code {
private void LockFinanceCockpit()
{
FinanceAccess.Lock();
Navigation.NavigateTo("/");
}
private string T(string german, string english) => UiText.Text(german, english); private string T(string german, string english) => UiText.Text(german, english);
} }
@@ -1,8 +1,18 @@
@using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Authorization
@inject NavigationManager Navigation
@inject TrafagSalesExporter.Services.IFinanceCockpitAccessService FinanceAccess
<CascadingAuthenticationState> <CascadingAuthenticationState>
<Router AppAssembly="typeof(Program).Assembly"> <Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData"> <Found Context="routeData">
@if (RequiresFinanceUnlock() && FinanceAccess.IsEnabled && !FinanceAccess.IsUnlocked)
{
<LayoutView Layout="typeof(Layout.MainLayout)">
<FinanceCockpitUnlockPanel />
</LayoutView>
}
else
{
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"> <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized> <NotAuthorized>
<LayoutView Layout="typeof(Layout.MainLayout)"> <LayoutView Layout="typeof(Layout.MainLayout)">
@@ -17,7 +27,27 @@
</LayoutView> </LayoutView>
</Authorizing> </Authorizing>
</AuthorizeRouteView> </AuthorizeRouteView>
}
<FocusOnNavigate RouteData="routeData" Selector="h1" /> <FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found> </Found>
</Router> </Router>
</CascadingAuthenticationState> </CascadingAuthenticationState>
@code {
private bool RequiresFinanceUnlock()
{
var path = Navigation.ToBaseRelativePath(Navigation.Uri)
.Split('?', '#')[0]
.Trim('/')
.ToLowerInvariant();
return path is "" or
"management-cockpit" or
"finance-cockpit/vergleich" or
"standorte" or
"transformations" or
"settings" or
"logs" or
"source-viewer";
}
}
@@ -7,5 +7,6 @@
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using MudBlazor @using MudBlazor
@using TrafagSalesExporter.Components @using TrafagSalesExporter.Components
@using TrafagSalesExporter.Components.FinanceCockpit
@using TrafagSalesExporter.Components.Layout @using TrafagSalesExporter.Components.Layout
@using TrafagSalesExporter.Models @using TrafagSalesExporter.Models
+2
View File
@@ -46,6 +46,7 @@ builder.Services.AddMudServices();
builder.Services.AddHttpClient(nameof(ExchangeRateImportService)); builder.Services.AddHttpClient(nameof(ExchangeRateImportService));
builder.Services.Configure<HrKpiDataSourceOptions>(builder.Configuration.GetSection(HrKpiDataSourceOptions.SectionName)); builder.Services.Configure<HrKpiDataSourceOptions>(builder.Configuration.GetSection(HrKpiDataSourceOptions.SectionName));
builder.Services.Configure<HrKpiAccessOptions>(builder.Configuration.GetSection(HrKpiAccessOptions.SectionName)); builder.Services.Configure<HrKpiAccessOptions>(builder.Configuration.GetSection(HrKpiAccessOptions.SectionName));
builder.Services.Configure<FinanceCockpitAccessOptions>(builder.Configuration.GetSection(FinanceCockpitAccessOptions.SectionName));
builder.Services.AddDbContextFactory<AppDbContext>(options => builder.Services.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60")); options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60"));
@@ -106,6 +107,7 @@ builder.Services.AddScoped<IDashboardPageService, DashboardPageService>();
builder.Services.AddScoped<ILogsPageService, LogsPageService>(); builder.Services.AddScoped<ILogsPageService, LogsPageService>();
builder.Services.AddScoped<ITransformationsPageService, TransformationsPageService>(); builder.Services.AddScoped<ITransformationsPageService, TransformationsPageService>();
builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>(); builder.Services.AddScoped<IHrKpiAccessService, HrKpiAccessService>();
builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>();
var app = builder.Build(); var app = builder.Build();
@@ -0,0 +1,11 @@
namespace TrafagSalesExporter.Security;
public sealed class FinanceCockpitAccessOptions
{
public const string SectionName = "FinanceCockpitAccess";
public bool Enabled { get; set; } = true;
public string Username { get; set; } = "finance";
public string PasswordHash { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
@@ -0,0 +1,74 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using TrafagSalesExporter.Security;
namespace TrafagSalesExporter.Services;
public interface IFinanceCockpitAccessService
{
bool IsEnabled { get; }
bool IsConfigured { get; }
bool IsUnlocked { get; }
bool TryUnlock(string username, string password);
void Lock();
}
public sealed class FinanceCockpitAccessService : IFinanceCockpitAccessService
{
private readonly FinanceCockpitAccessOptions _options;
public FinanceCockpitAccessService(IOptions<FinanceCockpitAccessOptions> options)
{
_options = options.Value;
}
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 void Lock() => IsUnlocked = false;
private static bool VerifyPasswordHash(string password, string configuredHash)
{
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(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);
}
}
+5
View File
@@ -29,5 +29,10 @@
"Enabled": true, "Enabled": true,
"Username": "hr", "Username": "hr",
"PasswordHash": "A8AF253007750E0C2986CBD0BC570530B4AE2417AAC59067591E708547834AE4" "PasswordHash": "A8AF253007750E0C2986CBD0BC570530B4AE2417AAC59067591E708547834AE4"
},
"FinanceCockpitAccess": {
"Enabled": true,
"Username": "finance",
"PasswordHash": "A8AF253007750E0C2986CBD0BC570530B4AE2417AAC59067591E708547834AE4"
} }
} }