Desktop: Passwortabfrage vor Konten, Geheimnissen und WebUI-Zugang
Umsetzung von #567. Entscheidung Richard, 13.09.2026: Geheimnisse verwalten wir ausschliesslich im Desktop, und selbst dort erst nach zusaetzlicher Passwortabfrage. Bisher sah und aenderte jeder, der die Desktop-Oberflaeche offen vor sich hatte: - Polymarket-Konten mit ApiKey, ApiSecret, ApiPassphrase und PrivateKey - Master-Key, OpenRouter-Key, Watchdog-Token und Lizenzschluessel - Mullvad-Account und Polygon-RPC-URL mit Alchemy-Key, offen im Editor - das WebUI-Token, per "Anzeigen" lesbar Jetzt gesperrt, bis entsperrt: - Tab "Polymarket Accounts": Gesperrt stehen die Konten gar nicht im visuellen Baum. Das Raster bleibt leer, der Editor mit den Private Keys ebenfalls; nichts ist nur verdeckt. - Master-Key erzeugen, OpenRouter-, Watchdog- und Lizenzschluessel setzen. - Mullvad-Account und Polygon-RPC-URL: mit [SensitiveSetting] markiert und im Editor ausgeblendet. - WebUI-Zugang: Gesperrt steht das Token nicht einmal maskiert im Feld. Speichern ist mitgesperrt, weil es den Feldinhalt als Token schreibt; gesperrt und leer haette es das Token geloescht. - Frei bleiben Lizenzpruefung und Test-Heartbeat, weil sie keinen Schluessel zeigen. Kern im Core (PolyTrader.Core.Security), weil nur dort testbar: - SettingsPasswordStore: PBKDF2-HMAC-SHA256, 600 000 Iterationen, Salt, eigene Datei settings-password.json neben master.key, atomar geschrieben und nur fuer den Besitzer lesbar. Nicht in server_settings.xml, die seit #561 das WebUI mitschreibt. Ein vorhandenes Passwort wird nie ueberschrieben. - SettingsLock: - erstes Festlegen mit Wiederholung und mindestens 10 Zeichen - zwei freie Fehlversuche, danach 5 s bis 5 min Wartezeit, in der auch das richtige Passwort nicht geprueft wird - Sperre nach 5 Minuten ohne geschuetzte Aktion und beim Verlassen der Einstellungen - eine unlesbare Passwortdatei entsperrt nie und gilt auch nicht still als "kein Passwort" - Protokoll ohne eingegebene Werte Oberflaeche: PasswordDialog deklarativ in .axaml, Sperr-Leiste ueber den Tabs. Die PBKDF2-Pruefung dauert etwa eine halbe Sekunde und laeuft nicht im UI-Thread. Grenzen: - Die Sperre schuetzt gegen jemanden an einer entsperrten Desktop-Sitzung. Wer Dateizugriff hat, liest master.key selbst. Deshalb gibt es kein "Passwort vergessen": Wer settings-password.json loeschen kann, braucht keins. - Die Wartezeit nach Fehlversuchen beginnt nach einem Neustart von vorn. - "Datenbankeinstellungen": Eine Ansicht dafuer gibt es im Desktop nicht; die Verbindungszeichenfolge steht nur in appsettings.Local.json. Entsteht eine, gehoert sie hinter dieselbe Sperre. Tests: 20 fuer Passwortdatei und Sperre (Festlegen, Fehlversuche, Wartezeit, Leerlauf, unlesbare Datei, Aendern, Iterationszahl aus der Datei, Dateirechte) und 5 fuer die Markierung der Geheimnisfelder. Die Oberflaeche selbst wird auf der VM abgenommen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,10 +17,11 @@ namespace PolyTrader.App.Avalonia.Controls
|
||||
public SettingsEditor() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
/// <summary>Setzt das anzuzeigende Objekt (oder <c>null</c>, um zu leeren).</summary>
|
||||
public void Show(object? target)
|
||||
/// <param name="includeSensitive"><c>false</c> blendet Geheimnisfelder aus (#567).</param>
|
||||
public void Show(object? target, bool includeSensitive = true)
|
||||
{
|
||||
var list = this.FindControl<ItemsControl>("sections")!;
|
||||
list.ItemsSource = target == null ? null : SettingsModelBuilder.Build(target);
|
||||
list.ItemsSource = target == null ? null : SettingsModelBuilder.Build(target, includeSensitive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +293,14 @@ namespace PolyTrader.App.Avalonia
|
||||
// Einstellungsdatei zur Laufzeit fortzuschreiben - gemeinsam fuer Desktop und WebUI.
|
||||
services.AddSingleton(new KnownModules(allModules));
|
||||
services.AddSingleton(new ServerSettingsFile(ServerSettingsPath));
|
||||
|
||||
// #567: Passwortschutz fuer Konten, Geheimnisse und WebUI-Zugang in der Desktop-Oberflaeche.
|
||||
// Eigene Datei neben master.key - nicht in server_settings.xml, die das WebUI mitschreibt.
|
||||
services.AddSingleton(sp => new SettingsLock(
|
||||
new SettingsPasswordStore(
|
||||
Path.Combine(AppContext.BaseDirectory, "settings-password.json"),
|
||||
onWarning: m => sp.GetRequiredService<TerminalLogger>().Warning(m)),
|
||||
log: m => sp.GetRequiredService<TerminalLogger>().Info(m)));
|
||||
services.AddSingleton(logger);
|
||||
services.AddSingleton<TradingState>();
|
||||
services.AddSingleton<JobManager>();
|
||||
|
||||
@@ -64,7 +64,8 @@ namespace PolyTrader.App.Avalonia.Shell
|
||||
services.GetService<WatchdogHeartbeatService>(),
|
||||
services.GetService<MullvadVpnService>(),
|
||||
services.GetService<PolyTrader.Core.Configuration.DeploymentcenterOptions>(),
|
||||
services.GetService<PolyTrader.Web.Configuration.WebUiOptions>())
|
||||
services.GetService<PolyTrader.Web.Configuration.WebUiOptions>(),
|
||||
services.GetRequiredService<PolyTrader.Core.Security.SettingsLock>())
|
||||
});
|
||||
|
||||
host.RegisterView(new ModuleView
|
||||
|
||||
@@ -244,12 +244,17 @@ namespace PolyTrader.App.Avalonia.ViewModels
|
||||
/// </summary>
|
||||
public static class SettingsModelBuilder
|
||||
{
|
||||
public static IReadOnlyList<SettingsSection> Build(object target)
|
||||
/// <param name="includeSensitive">
|
||||
/// <c>false</c> blendet Felder mit <see cref="PolyTrader.Core.Security.SensitiveSettingAttribute"/> aus –
|
||||
/// solange die geschützten Einstellungen gesperrt sind (#567).
|
||||
/// </param>
|
||||
public static IReadOnlyList<SettingsSection> Build(object target, bool includeSensitive = true)
|
||||
{
|
||||
var fields = target.GetType()
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => p.GetIndexParameters().Length == 0)
|
||||
.Where(p => p.GetCustomAttribute<BrowsableAttribute>()?.Browsable != false)
|
||||
.Where(p => includeSensitive || p.GetCustomAttribute<PolyTrader.Core.Security.SensitiveSettingAttribute>() == null)
|
||||
.Select(p => new
|
||||
{
|
||||
Property = p,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PolyTrader.App.Avalonia.Views.PasswordDialog"
|
||||
Icon="avares://PolyTrader.App.Avalonia/Assets/icon.png"
|
||||
Title="Einstellungs-Passwort"
|
||||
Width="480" SizeToContent="Height"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
CanResize="False"
|
||||
ShowInTaskbar="False">
|
||||
|
||||
<!--
|
||||
Passwortabfrage vor geschützten Einstellungen (#567): Polymarket-Konten, Geheimnisse, WebUI-Zugang.
|
||||
Zwei Fälle in einem Dialog: Festlegen beim allerersten Zugriff (mit Wiederholung) und Entsperren.
|
||||
Eigener Dialog statt DialogWindow.Prompt, weil das Festlegen zwei Felder und eine Fehlerzeile braucht,
|
||||
die bei einem Fehlversuch stehen bleibt, ohne den Dialog zu schließen.
|
||||
-->
|
||||
<StackPanel Margin="20" Spacing="10">
|
||||
<TextBlock Name="lblTitle" FontSize="15" FontWeight="SemiBold" />
|
||||
<TextBlock Name="lblMessage" TextWrapping="Wrap" />
|
||||
|
||||
<TextBox Name="txtPassword" PasswordChar="•" Watermark="Einstellungs-Passwort" />
|
||||
<TextBox Name="txtConfirm" PasswordChar="•" Watermark="Passwort wiederholen" IsVisible="False" />
|
||||
|
||||
<TextBlock Name="lblError" Classes="negative" TextWrapping="Wrap" IsVisible="False" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,6,0,0">
|
||||
<Button Name="btnCancel" Content="Abbrechen" Padding="16,6" IsCancel="True" />
|
||||
<Button Name="btnOk" Content="Entsperren" Padding="16,6" IsDefault="True" Classes="primary" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</Window>
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using PolyTrader.Core.Security;
|
||||
|
||||
namespace PolyTrader.App.Avalonia.Views
|
||||
{
|
||||
/// <summary>
|
||||
/// Passwortabfrage vor geschützten Einstellungen (#567). Layout in PasswordDialog.axaml.
|
||||
/// Der Dialog prüft nichts selbst – das tut <see cref="SettingsLock"/>. Er nimmt nur die Eingabe entgegen
|
||||
/// und zeigt, was beim letzten Versuch schiefging.
|
||||
/// </summary>
|
||||
public partial class PasswordDialog : Window
|
||||
{
|
||||
public PasswordDialog() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
private void Setup(string title, string message, bool withConfirmation, string okText, string? error)
|
||||
{
|
||||
Title = title;
|
||||
this.FindControl<TextBlock>("lblTitle")!.Text = title;
|
||||
this.FindControl<TextBlock>("lblMessage")!.Text = message;
|
||||
this.FindControl<TextBox>("txtConfirm")!.IsVisible = withConfirmation;
|
||||
this.FindControl<Button>("btnOk")!.Content = okText;
|
||||
|
||||
var lblError = this.FindControl<TextBlock>("lblError")!;
|
||||
lblError.Text = error ?? string.Empty;
|
||||
lblError.IsVisible = !string.IsNullOrEmpty(error);
|
||||
|
||||
this.FindControl<Button>("btnOk")!.Click += (_, _) => Close(true);
|
||||
this.FindControl<Button>("btnCancel")!.Click += (_, _) => Close(false);
|
||||
Opened += (_, _) => this.FindControl<TextBox>("txtPassword")!.Focus();
|
||||
}
|
||||
|
||||
private static Window? OwnerOf(Control owner)
|
||||
{
|
||||
if (TopLevel.GetTopLevel(owner) is Window window) return window;
|
||||
return (global::Avalonia.Application.Current?.ApplicationLifetime
|
||||
as IClassicDesktopStyleApplicationLifetime)?.MainWindow;
|
||||
}
|
||||
|
||||
/// <summary>Entsperren. <c>null</c> bei Abbruch.</summary>
|
||||
public static async Task<string?> Ask(Control owner, string purpose, string? error)
|
||||
{
|
||||
if (OwnerOf(owner) is not { } window) return null;
|
||||
var dlg = new PasswordDialog();
|
||||
dlg.Setup("Geschützte Einstellungen entsperren",
|
||||
$"„{purpose}“ ist geschützt. Bitte das Einstellungs-Passwort eingeben.\n\n" +
|
||||
$"Entsperrt bleibt es, bis die Einstellungen verlassen werden oder " +
|
||||
$"{SettingsLock.IdleTimeout.TotalMinutes:0} Minuten lang keine geschützte Aktion kommt.",
|
||||
withConfirmation: false, okText: "Entsperren", error);
|
||||
bool ok = await dlg.ShowDialog<bool>(window);
|
||||
return ok ? dlg.FindControl<TextBox>("txtPassword")!.Text ?? string.Empty : null;
|
||||
}
|
||||
|
||||
/// <summary>Festlegen beim ersten Zugriff. <c>null</c> bei Abbruch.</summary>
|
||||
public static async Task<(string Password, string Confirmation)?> SetNew(Control owner, string purpose, string? error)
|
||||
{
|
||||
if (OwnerOf(owner) is not { } window) return null;
|
||||
var dlg = new PasswordDialog();
|
||||
dlg.Setup("Einstellungs-Passwort festlegen",
|
||||
$"„{purpose}“ ist geschützt, und es ist noch kein Einstellungs-Passwort festgelegt.\n\n" +
|
||||
"Es schützt in dieser Oberfläche die Polymarket-Konten, die Geheimnisse (Master-Key, OpenRouter-, " +
|
||||
"Watchdog- und Lizenzschlüssel) und den Zugang zum WebUI. " +
|
||||
$"Mindestens {SettingsPasswordStore.MinLength} Zeichen.\n\n" +
|
||||
"Einen Weg „Passwort vergessen“ gibt es bewusst nicht: Wer die Datei settings-password.json im " +
|
||||
"Programmordner löschen kann, legt beim nächsten Zugriff ein neues fest – und wer das kann, " +
|
||||
"liest die Schlüsseldateien ohnehin selbst.",
|
||||
withConfirmation: true, okText: "Festlegen", error);
|
||||
bool ok = await dlg.ShowDialog<bool>(window);
|
||||
if (!ok) return null;
|
||||
return (dlg.FindControl<TextBox>("txtPassword")!.Text ?? string.Empty,
|
||||
dlg.FindControl<TextBox>("txtConfirm")!.Text ?? string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,15 @@
|
||||
<TextBlock Name="lblStatus" Text="Bereit." />
|
||||
</Border>
|
||||
|
||||
<!-- #567: Sperrzustand der geschuetzten Einstellungen (Konten, Geheimnisse, WebUI-Zugang). -->
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Button Name="btnLockToggle" Content="🔒 Entsperren" />
|
||||
<TextBlock Name="lblLockState" VerticalAlignment="Center" Classes="muted"
|
||||
Text="Konten, Geheimnisse und WebUI-Zugang sind gesperrt." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TabControl>
|
||||
|
||||
<TabItem Header="Allgemeine Einstellungen">
|
||||
@@ -44,7 +53,18 @@
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="Polymarket Accounts">
|
||||
<DockPanel>
|
||||
<Panel>
|
||||
<!-- #567: Gesperrt stehen die Konten gar nicht im visuellen Baum - das Raster bleibt leer,
|
||||
nicht nur verdeckt. -->
|
||||
<Border Name="pnlAccountsLocked" Padding="32" HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<StackPanel Spacing="12" MaxWidth="480">
|
||||
<TextBlock Text="🔒 Polymarket-Konten sind gesperrt" FontSize="16" FontWeight="SemiBold" />
|
||||
<TextBlock TextWrapping="Wrap" Classes="muted"
|
||||
Text="Die Konten enthalten Private Keys und API-Schlüssel – also Zugriff auf Geld. Sie sind erst nach Eingabe des Einstellungs-Passworts sichtbar und sperren sich nach 5 Minuten ohne geschützte Aktion wieder." />
|
||||
<Button Name="btnAccUnlock" Content="Entsperren …" HorizontalAlignment="Left" Classes="primary" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<DockPanel Name="pnlAccounts" IsVisible="False">
|
||||
<Border Classes="toolbar" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Name="btnAccNew" Content="Neuer Account" />
|
||||
@@ -68,6 +88,7 @@
|
||||
<controls:SettingsEditor Name="editorAccount" Grid.Column="2" />
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Panel>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="WebUI">
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Avalonia.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
@@ -44,12 +46,17 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
private DateTime? _loadedStamp;
|
||||
private bool _settingsReadable = true;
|
||||
|
||||
// #567: Passwortschutz. Vorbelegt, damit auch ein ohne Dienste gebautes Fenster nie ungeschuetzt ist.
|
||||
private SettingsLock _lock = new(new SettingsPasswordStore(
|
||||
System.IO.Path.Combine(AppContext.BaseDirectory, "settings-password.json")));
|
||||
private readonly DispatcherTimer _idleTimer = new() { Interval = TimeSpan.FromSeconds(15) };
|
||||
|
||||
public SettingsWindow() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
public SettingsWindow(IModuleUiHost host, IAccountRepository accountRepo, TradingState state,
|
||||
TerminalLogger logger, WatchdogHeartbeatService? watchdog = null,
|
||||
MullvadVpnService? vpn = null, DeploymentcenterOptions? dcOptions = null,
|
||||
WebUiOptions? webOptions = null) : this()
|
||||
WebUiOptions? webOptions = null, SettingsLock? settingsLock = null) : this()
|
||||
{
|
||||
_accountRepo = accountRepo;
|
||||
_state = state;
|
||||
@@ -58,6 +65,19 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
_vpn = vpn;
|
||||
_dcOptions = dcOptions;
|
||||
_webOptions = webOptions ?? Program.LoadWebUiOptions();
|
||||
_lock = settingsLock ?? _lock;
|
||||
|
||||
// Zustandswechsel kommen auch aus dem Hintergrund (PBKDF2-Pruefung) - neu gezeichnet wird im UI-Thread.
|
||||
_lock.Changed += () => Dispatcher.UIThread.Post(RefreshLockUi);
|
||||
_idleTimer.Tick += (_, _) => _lock.ExpireIfIdle();
|
||||
_idleTimer.Start();
|
||||
|
||||
// Wer die Einstellungen verlaesst, laesst sie nicht entsperrt zurueck.
|
||||
host.OpenStateChanged += () =>
|
||||
{
|
||||
if (host is Shell.AvaloniaUiHost shellHost && shellHost.ActiveViewId != "core.settings")
|
||||
_lock.Lock("Einstellungen verlassen");
|
||||
};
|
||||
|
||||
var grid = this.FindControl<DataGrid>("gridAccounts")!;
|
||||
grid.ItemsSource = _accounts;
|
||||
@@ -66,30 +86,139 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
|
||||
this.FindControl<Button>("btnSave")!.Click += (_, _) => SaveServerSettings();
|
||||
this.FindControl<Button>("btnReload")!.Click += (_, _) => ReloadServerSettings();
|
||||
this.FindControl<Button>("btnGenMasterKey")!.Click += async (_, _) => await GenerateMasterKeyAsync();
|
||||
this.FindControl<Button>("btnOpenRouterKey")!.Click += async (_, _) => await SetOpenRouterKeyAsync();
|
||||
this.FindControl<Button>("btnWatchdogToken")!.Click += async (_, _) => await SetWatchdogTokenAsync();
|
||||
// #567: Geheimnis-Aktionen nur entsperrt. Lizenzpruefung und Test-Heartbeat zeigen keinen
|
||||
// Schluessel und bleiben frei.
|
||||
this.FindControl<Button>("btnGenMasterKey")!.Click += async (_, _) => await Gate("Master-Key erzeugen", GenerateMasterKeyAsync);
|
||||
this.FindControl<Button>("btnOpenRouterKey")!.Click += async (_, _) => await Gate("OpenRouter-Key setzen", SetOpenRouterKeyAsync);
|
||||
this.FindControl<Button>("btnWatchdogToken")!.Click += async (_, _) => await Gate("Watchdog-Token setzen", SetWatchdogTokenAsync);
|
||||
this.FindControl<Button>("btnTestWatchdog")!.Click += async (_, _) => await TestWatchdogAsync();
|
||||
this.FindControl<Button>("btnLicenseKey")!.Click += async (_, _) => await SetLicenseKeyAsync();
|
||||
this.FindControl<Button>("btnLicenseKey")!.Click += async (_, _) => await Gate("Lizenzschlüssel setzen", SetLicenseKeyAsync);
|
||||
this.FindControl<Button>("btnCheckLicense")!.Click += async (_, _) => await CheckLicenseAsync();
|
||||
|
||||
this.FindControl<Button>("btnAccNew")!.Click += (_, _) => AddAccount();
|
||||
this.FindControl<Button>("btnAccDelete")!.Click += async (_, _) => await DeleteAccountAsync();
|
||||
this.FindControl<Button>("btnAccSave")!.Click += (_, _) => SaveSelectedAccount();
|
||||
this.FindControl<Button>("btnAccNew")!.Click += async (_, _) => await Gate("Polymarket-Konto anlegen", () => { AddAccount(); return Task.CompletedTask; });
|
||||
this.FindControl<Button>("btnAccDelete")!.Click += async (_, _) => await Gate("Polymarket-Konto löschen", DeleteAccountAsync);
|
||||
this.FindControl<Button>("btnAccSave")!.Click += async (_, _) => await Gate("Polymarket-Konto speichern", () => { SaveSelectedAccount(); return Task.CompletedTask; });
|
||||
this.FindControl<Button>("btnAccUnlock")!.Click += async (_, _) => await EnsureUnlockedAsync("Polymarket-Konten");
|
||||
|
||||
this.FindControl<Button>("btnWebUiGenToken")!.Click += (_, _) => GenerateWebUiToken();
|
||||
this.FindControl<Button>("btnWebUiToggleShowToken")!.Click += (_, _) => ToggleWebUiTokenVisibility();
|
||||
this.FindControl<Button>("btnWebUiCopyToken")!.Click += async (_, _) => await CopyWebUiTokenAsync();
|
||||
this.FindControl<Button>("btnSaveWebUi")!.Click += (_, _) => SaveWebUiSettings();
|
||||
// Der WebUI-Zugang ist ein Geheimnis: "Speichern" schreibt den Feldinhalt als Token - gesperrt und
|
||||
// leer haette es das Token geloescht.
|
||||
this.FindControl<Button>("btnWebUiGenToken")!.Click += async (_, _) => await Gate("Neues WebUI-Token", () => { GenerateWebUiToken(); return Task.CompletedTask; });
|
||||
this.FindControl<Button>("btnWebUiToggleShowToken")!.Click += async (_, _) => await Gate("WebUI-Token anzeigen", () => { ToggleWebUiTokenVisibility(); return Task.CompletedTask; });
|
||||
this.FindControl<Button>("btnWebUiCopyToken")!.Click += async (_, _) => await Gate("WebUI-Token kopieren", CopyWebUiTokenAsync);
|
||||
this.FindControl<Button>("btnSaveWebUi")!.Click += async (_, _) => await Gate("WebUI-Zugang speichern", () => { SaveWebUiSettings(); return Task.CompletedTask; });
|
||||
|
||||
this.FindControl<Button>("btnLockToggle")!.Click += async (_, _) =>
|
||||
{
|
||||
if (_lock.IsUnlocked) _lock.Lock("von Hand");
|
||||
else await EnsureUnlockedAsync("Geschützte Einstellungen");
|
||||
};
|
||||
|
||||
ReloadServerSettings();
|
||||
LoadAccounts();
|
||||
LoadWebUiSettings();
|
||||
RefreshLockUi();
|
||||
UpdateMasterKeyButtonState();
|
||||
}
|
||||
|
||||
private void Status(string text) => this.FindControl<TextBlock>("lblStatus")!.Text = text;
|
||||
|
||||
// ===== Passwortschutz (#567) =====
|
||||
|
||||
private void ShowServerEditor() =>
|
||||
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings, includeSensitive: _lock.IsUnlocked);
|
||||
|
||||
/// <summary>Fuehrt eine geschuetzte Aktion aus, nach Entsperren falls noetig.</summary>
|
||||
private async Task Gate(string purpose, Func<Task> action)
|
||||
{
|
||||
if (!await EnsureUnlockedAsync(purpose)) return;
|
||||
_lock.Touch();
|
||||
await action();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Entsperrt, falls noetig. Beim allerersten Zugriff wird das Passwort festgelegt. Die PBKDF2-Pruefung
|
||||
/// dauert rund eine halbe Sekunde und laeuft deshalb nicht im UI-Thread.
|
||||
/// </summary>
|
||||
private async Task<bool> EnsureUnlockedAsync(string purpose)
|
||||
{
|
||||
if (_lock.IsUnlocked)
|
||||
{
|
||||
_lock.Touch();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!_lock.PasswordIsSet)
|
||||
{
|
||||
string? problem = null;
|
||||
while (true)
|
||||
{
|
||||
var entry = await PasswordDialog.SetNew(this, purpose, problem);
|
||||
if (entry == null) return false;
|
||||
|
||||
var (password, confirmation) = entry.Value;
|
||||
var result = await Task.Run(() => _lock.SetInitialAndUnlock(password, confirmation));
|
||||
if (result.Succeeded) return true;
|
||||
|
||||
problem = result.Outcome switch
|
||||
{
|
||||
UnlockOutcome.TooShort => $"Mindestens {SettingsPasswordStore.MinLength} Zeichen.",
|
||||
UnlockOutcome.Mismatch => "Die beiden Eingaben stimmen nicht überein.",
|
||||
UnlockOutcome.PasswordAlreadySet => "Inzwischen ist ein Passwort festgelegt – bitte damit entsperren.",
|
||||
_ => "Das Passwort konnte nicht festgelegt werden."
|
||||
};
|
||||
if (result.Outcome == UnlockOutcome.PasswordAlreadySet) break;
|
||||
}
|
||||
}
|
||||
|
||||
string? message = null;
|
||||
while (true)
|
||||
{
|
||||
string? password = await PasswordDialog.Ask(this, purpose, message);
|
||||
if (password == null) return false;
|
||||
|
||||
var result = await Task.Run(() => _lock.TryUnlock(password));
|
||||
switch (result.Outcome)
|
||||
{
|
||||
case UnlockOutcome.Success:
|
||||
return true;
|
||||
case UnlockOutcome.WrongPassword:
|
||||
message = result.RetryAfter is { } wait
|
||||
? $"Falsches Passwort. Nächster Versuch in {wait.TotalSeconds:0} s."
|
||||
: "Falsches Passwort.";
|
||||
break;
|
||||
case UnlockOutcome.Blocked:
|
||||
message = $"Zu viele Fehlversuche. Nächster Versuch in {Math.Ceiling(result.RetryAfter?.TotalSeconds ?? 0):0} s.";
|
||||
break;
|
||||
case UnlockOutcome.StoreUnreadable:
|
||||
await DialogWindow.Info(this, "Passwortdatei nicht lesbar",
|
||||
$"{result.Detail}\n\nDie geschützten Einstellungen bleiben gesperrt. Betroffen ist die Datei " +
|
||||
"settings-password.json im Programmordner.");
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshLockUi()
|
||||
{
|
||||
bool unlocked = _lock.IsUnlocked;
|
||||
|
||||
this.FindControl<Button>("btnLockToggle")!.Content = unlocked ? "🔓 Sperren" : "🔒 Entsperren";
|
||||
this.FindControl<TextBlock>("lblLockState")!.Text = unlocked
|
||||
? $"Geschützte Einstellungen entsperrt – sperren sich nach {SettingsLock.IdleTimeout.TotalMinutes:0} Minuten ohne geschützte Aktion und beim Verlassen."
|
||||
: "Konten, Geheimnisse und WebUI-Zugang sind gesperrt.";
|
||||
|
||||
this.FindControl<Border>("pnlAccountsLocked")!.IsVisible = !unlocked;
|
||||
this.FindControl<DockPanel>("pnlAccounts")!.IsVisible = unlocked;
|
||||
if (!unlocked)
|
||||
this.FindControl<SettingsEditor>("editorAccount")!.Show(null);
|
||||
LoadAccounts();
|
||||
|
||||
if (_settingsReadable)
|
||||
ShowServerEditor();
|
||||
|
||||
LoadWebUiSettings();
|
||||
}
|
||||
|
||||
// ===== Server-Settings =====
|
||||
|
||||
private void ReloadServerSettings()
|
||||
@@ -101,7 +230,7 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
_settings = new ServerSettingsFile(SettingsPath).Read();
|
||||
_settingsReadable = true;
|
||||
_loadedStamp = SettingsStamp();
|
||||
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
|
||||
ShowServerEditor();
|
||||
Status("Einstellungen geladen.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -183,7 +312,12 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
|
||||
private void LoadAccounts()
|
||||
{
|
||||
if (_state == null) return;
|
||||
// #567: Gesperrt bleiben die Konten aus dem Raster - samt Private Keys im Editor daneben.
|
||||
if (_state == null || !_lock.IsUnlocked)
|
||||
{
|
||||
_accounts.Clear();
|
||||
return;
|
||||
}
|
||||
_accounts.Clear();
|
||||
foreach (var a in _state.Accounts.Values.OrderBy(a => a.AccountId)) _accounts.Add(a);
|
||||
|
||||
@@ -341,7 +475,7 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
WriteSingleField(s => s.WatchdogToken = newToken);
|
||||
_settings.WatchdogToken = newToken;
|
||||
_watchdog?.ReloadSettings();
|
||||
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
|
||||
ShowServerEditor();
|
||||
|
||||
if (_settings.WatchdogToken.Length == 0)
|
||||
{
|
||||
@@ -416,7 +550,7 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
: SecretProtection.Protect(key.Trim());
|
||||
WriteSingleField(s => s.LicenseKey = newKey);
|
||||
_settings.LicenseKey = newKey;
|
||||
this.FindControl<SettingsEditor>("editorServer")!.Show(_settings);
|
||||
ShowServerEditor();
|
||||
|
||||
if (_settings.LicenseKey.Length == 0)
|
||||
{
|
||||
@@ -519,7 +653,16 @@ namespace PolyTrader.App.Avalonia.Views
|
||||
chkEnabled.IsChecked = _webOptions.Enabled;
|
||||
numPort.Value = _webOptions.Port;
|
||||
chkLocal.IsChecked = _webOptions.LocalOnly;
|
||||
txtToken.Text = _webOptions.AuthToken ?? string.Empty;
|
||||
// #567: Gesperrt steht das Token nicht einmal maskiert im Feld.
|
||||
bool unlocked = _lock.IsUnlocked;
|
||||
txtToken.Text = unlocked ? _webOptions.AuthToken ?? string.Empty : string.Empty;
|
||||
txtToken.Watermark = unlocked ? string.Empty : "gesperrt";
|
||||
txtToken.IsEnabled = unlocked;
|
||||
if (!unlocked)
|
||||
{
|
||||
txtToken.PasswordChar = '●';
|
||||
this.FindControl<Button>("btnWebUiToggleShowToken")!.Content = "Anzeigen";
|
||||
}
|
||||
|
||||
if (_webOptions.Enabled)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user