diff --git a/LehrerApp.Core/Services/AppLockService.cs b/LehrerApp.Core/Services/AppLockService.cs new file mode 100644 index 0000000..8a9a5ab --- /dev/null +++ b/LehrerApp.Core/Services/AppLockService.cs @@ -0,0 +1,73 @@ +using System.Security.Cryptography; +using System.Text.Json; + +namespace LehrerApp.Core.Services; + +internal class AppLockConfig +{ + public bool Enabled { get; set; } + public string? Salt { get; set; } + public string? PasswordHash { get; set; } + public int TimeoutMinutes { get; set; } = 10; +} + +/// +/// App-Sperre nach Inaktivität: eigenes Passwort (unabhängig von einer eventuellen +/// Datenbank-Verschlüsselung), als gesalzener Hash in <AppData>/LehrerApp/applock.json +/// abgelegt. Das Klartext-Passwort wird nie gespeichert. +/// +public class AppLockService +{ + private const int Pbkdf2Iterations = 100_000; + private readonly string _configPath; + private AppLockConfig _config; + + public bool IsEnabled => _config.Enabled; + public int TimeoutMinutes => _config.TimeoutMinutes; + public bool HasPassword => !string.IsNullOrEmpty(_config.PasswordHash); + + public AppLockService(string appDataPath) + { + _configPath = Path.Combine(appDataPath, "applock.json"); + _config = Load(); + } + + public void Configure(bool enabled, int timeoutMinutes) + { + _config.Enabled = enabled; + _config.TimeoutMinutes = Math.Max(1, timeoutMinutes); + Save(); + } + + public void SetPassword(string password) + { + var salt = RandomNumberGenerator.GetBytes(16); + _config.Salt = Convert.ToBase64String(salt); + _config.PasswordHash = Hash(password, salt); + Save(); + } + + public bool VerifyPassword(string password) + { + if (string.IsNullOrEmpty(_config.PasswordHash) || string.IsNullOrEmpty(_config.Salt)) return false; + var salt = Convert.FromBase64String(_config.Salt); + return _config.PasswordHash == Hash(password, salt); + } + + private static string Hash(string password, byte[] salt) => + Convert.ToBase64String(Rfc2898DeriveBytes.Pbkdf2(password, salt, Pbkdf2Iterations, HashAlgorithmName.SHA256, 32)); + + private AppLockConfig Load() + { + try + { + if (File.Exists(_configPath)) + return JsonSerializer.Deserialize(File.ReadAllText(_configPath)) ?? new AppLockConfig(); + } + catch { /* beschädigte Konfiguration -> Standardwerte, Sperre bleibt deaktiviert */ } + return new AppLockConfig(); + } + + private void Save() => + File.WriteAllText(_configPath, JsonSerializer.Serialize(_config)); +} diff --git a/LehrerApp.Core/Services/BackupService.cs b/LehrerApp.Core/Services/BackupService.cs new file mode 100644 index 0000000..8cd9e79 --- /dev/null +++ b/LehrerApp.Core/Services/BackupService.cs @@ -0,0 +1,63 @@ +namespace LehrerApp.Core.Services; + +public record BackupInfo(string Path, DateTime CreatedAt, long SizeBytes); + +/// +/// Rollierendes lokales Backup der LiteDB-Datei +/// (<AppData>/LehrerApp/backups/lehrerapp-JJJJ-MM-TT_HH-mm-ss.db). +/// Reine Dateikopie — unabhängig davon, ob die Datenbank verschlüsselt ist. +/// +public class BackupService +{ + private const int DefaultKeepCount = 10; + private readonly string _backupDirectory; + + public string BackupDirectory => _backupDirectory; + + public BackupService(string appDataPath) + { + _backupDirectory = Path.Combine(appDataPath, "backups"); + Directory.CreateDirectory(_backupDirectory); + } + + /// Erstellt ein Backup der übergebenen Datenbankdatei, falls sie existiert. + /// Gibt den Pfad des neuen Backups zurück, oder null, wenn keine Datei vorhanden war. + public string? CreateBackup(string databasePath, int keepCount = DefaultKeepCount) + { + if (!File.Exists(databasePath)) return null; + + // Millisekunden + Zufallssuffix, damit mehrere Backups innerhalb derselben Sekunde + // (z.B. mehrfaches Klicken auf "Jetzt sichern") sich nicht gegenseitig überschreiben. + var suffix = Guid.NewGuid().ToString("N")[..8]; + var fileName = $"lehrerapp-{DateTime.Now:yyyy-MM-dd_HH-mm-ss-fff}-{suffix}.db"; + var target = Path.Combine(_backupDirectory, fileName); + File.Copy(databasePath, target, overwrite: true); + + PruneOldBackups(keepCount); + return target; + } + + public List ListBackups() => + Directory.GetFiles(_backupDirectory, "lehrerapp-*.db") + .Select(p => new BackupInfo(p, File.GetLastWriteTime(p), new FileInfo(p).Length)) + .OrderByDescending(b => b.CreatedAt) + .ToList(); + + public void PruneOldBackups(int keepCount = DefaultKeepCount) + { + var backups = ListBackups(); + foreach (var stale in backups.Skip(keepCount)) + { + try { File.Delete(stale.Path); } + catch { /* nächster Lauf versucht es erneut */ } + } + } + + /// Kopiert ein Backup an die Zielposition (z.B. über die aktive Datenbankdatei). + /// Der Aufrufer ist dafür verantwortlich, alle offenen Verbindungen vorher zu schließen. + public void RestoreBackup(string backupPath, string databasePath) + { + if (!File.Exists(backupPath)) throw new FileNotFoundException("Backup nicht gefunden.", backupPath); + File.Copy(backupPath, databasePath, overwrite: true); + } +} diff --git a/LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs b/LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs new file mode 100644 index 0000000..124cb3c --- /dev/null +++ b/LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs @@ -0,0 +1,80 @@ +using LiteDB; +using Xunit; + +namespace LehrerApp.Data.Tests; + +public sealed class DatabaseEncryptionServiceTests +{ + [Fact] + public void IsEncrypted_OhneVorhandeneDatei_GibtFalseZurueck() + { + using var temp = new TempDatabase(); + var service = new DatabaseEncryptionService(); + + Assert.False(service.IsEncrypted(temp.Path)); + } + + [Fact] + public void IsEncrypted_UnverschluesselteDatenbank_GibtFalseZurueck() + { + using var temp = new TempDatabase(); + using (var db = new LiteDatabase(temp.Path)) + db.GetCollection("t").Insert(new BsonDocument { ["x"] = 1 }); + + var service = new DatabaseEncryptionService(); + + Assert.False(service.IsEncrypted(temp.Path)); + } + + [Fact] + public void SetPassword_VerschluesseltDieDatenbankUndBehaeltDieDaten() + { + using var temp = new TempDatabase(); + using (var db = new LiteDatabase(temp.Path)) + db.GetCollection("t").Insert(new BsonDocument { ["Name"] = "Hallo" }); + + var service = new DatabaseEncryptionService(); + service.SetPassword(temp.Path, currentPassword: null, newPassword: "geheim123"); + + Assert.True(service.IsEncrypted(temp.Path)); + Assert.True(service.VerifyPassword(temp.Path, "geheim123")); + Assert.False(service.VerifyPassword(temp.Path, "falsch")); + + using var reopened = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" }); + var doc = reopened.GetCollection("t").FindAll().First(); + Assert.Equal("Hallo", doc["Name"].AsString); + } + + [Fact] + public void SetPassword_EntferntDasPasswortWiederWennNeuesPasswortNullIst() + { + using var temp = new TempDatabase(); + var service = new DatabaseEncryptionService(); + using (var db = new LiteDatabase(new ConnectionString(temp.Path) { Password = "geheim123" })) + db.GetCollection("t").Insert(new BsonDocument { ["x"] = 1 }); + + service.SetPassword(temp.Path, currentPassword: "geheim123", newPassword: null); + + Assert.False(service.IsEncrypted(temp.Path)); + using var reopened = new LiteDatabase(temp.Path); + Assert.Single(reopened.GetCollection("t").FindAll()); + } + + private sealed class TempDatabase : IDisposable + { + private readonly string _directory = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"lehrerapp-dbenc-tests-{Guid.NewGuid():N}"); + public string Path { get; } + + public TempDatabase() + { + Directory.CreateDirectory(_directory); + Path = System.IO.Path.Combine(_directory, "test.db"); + } + + public void Dispose() + { + if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); + } + } +} diff --git a/LehrerApp.Data.Tests/LiteDbContextTests.cs b/LehrerApp.Data.Tests/LiteDbContextTests.cs index 3948d06..6c3faf5 100644 --- a/LehrerApp.Data.Tests/LiteDbContextTests.cs +++ b/LehrerApp.Data.Tests/LiteDbContextTests.cs @@ -115,6 +115,26 @@ public sealed class LiteDbContextTests new ExamResult { StudentId = studentId, ExamId = examId })); } + [Fact] + public void NeueDatenbank_HatDieAktuelleSchemaVersionNachDemErstenOeffnen() + { + using var temp = new TempDatabase(); + using var context = new LiteDbContext(temp.Path); + + Assert.Equal(1, context.SchemaVersion); + } + + [Fact] + public void SchemaVersion_WirdUeberEinenNeustartHinwegBeibehalten() + { + using var temp = new TempDatabase(); + using (var first = new LiteDbContext(temp.Path)) { } + + using var second = new LiteDbContext(temp.Path); + + Assert.Equal(1, second.SchemaVersion); + } + private sealed class TempDatabase : IDisposable { private readonly string _directory = System.IO.Path.Combine( diff --git a/LehrerApp.Data/DatabaseEncryptionService.cs b/LehrerApp.Data/DatabaseEncryptionService.cs new file mode 100644 index 0000000..90329eb --- /dev/null +++ b/LehrerApp.Data/DatabaseEncryptionService.cs @@ -0,0 +1,68 @@ +using LiteDB; + +namespace LehrerApp.Data; + +/// +/// Prüft und ändert den Passwortschutz einer LiteDB-Datei. +/// Passwort-Änderungen laufen über eine Kopie (neue Datei mit Zielpasswort, alle +/// Collections umkopiert, dann Austausch) statt über LiteDatabase.Rebuild mit +/// Passwort, das in LiteDB 5.0.21 nachweislich fehlschlägt (per Skript verifiziert). +/// +public class DatabaseEncryptionService +{ + public bool IsEncrypted(string dbPath) + { + if (!File.Exists(dbPath)) return false; + try + { + using var db = new LiteDatabase(dbPath); + return false; + } + catch (LiteException) + { + return true; + } + } + + public bool VerifyPassword(string dbPath, string password) + { + try + { + using var db = new LiteDatabase(new ConnectionString(dbPath) { Password = password }); + return true; + } + catch (LiteException) + { + return false; + } + } + + /// Setzt (newPassword != null), ändert oder entfernt (newPassword == null) das + /// Passwort der Datenbank. Der Aufrufer muss sicherstellen, dass keine andere + /// Verbindung (z.B. der laufende ) die Datei offen hält. + public void SetPassword(string dbPath, string? currentPassword, string? newPassword) + { + var tempPath = dbPath + ".reencrypt.tmp"; + if (File.Exists(tempPath)) File.Delete(tempPath); + + var srcConnection = currentPassword is null + ? new ConnectionString(dbPath) + : new ConnectionString(dbPath) { Password = currentPassword }; + var dstConnection = newPassword is null + ? new ConnectionString(tempPath) + : new ConnectionString(tempPath) { Password = newPassword }; + + using (var src = new LiteDatabase(srcConnection)) + using (var dst = new LiteDatabase(dstConnection)) + { + foreach (var name in src.GetCollectionNames()) + { + var docs = src.GetCollection(name).FindAll().ToList(); + if (docs.Count > 0) dst.GetCollection(name).InsertBulk(docs); + } + } + + File.Copy(tempPath, dbPath, overwrite: true); + File.Delete(tempPath); + } +} diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index 324cfb9..46df789 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -8,15 +8,21 @@ namespace LehrerApp.Data; /// public class LiteDbContext : IDisposable { + /// Aktuelle Schema-Version. Migrationsschritte werden versioniert unter + /// ergänzt, statt bei jedem Start erneut + /// (idempotent, aber unnötig) über alle Daten zu laufen. + private const int CurrentSchemaVersion = 1; + private readonly LiteDatabase _db; - public LiteDbContext(string databasePath) + public LiteDbContext(string databasePath, string? password = null) { _db = new LiteDatabase(new ConnectionString(databasePath) { Connection = ConnectionType.Shared, + Password = password, }); - MigrateExistingData(); + RunVersionedMigrations(); EnsureIndexes(); } @@ -24,7 +30,7 @@ public class LiteDbContext : IDisposable public LiteDbContext(Stream stream) { _db = new LiteDatabase(stream); - MigrateExistingData(); + RunVersionedMigrations(); EnsureIndexes(); } @@ -51,6 +57,32 @@ public class LiteDbContext : IDisposable public void Checkpoint() => _db.Checkpoint(); + public int SchemaVersion => ReadSchemaVersion(); + + private void RunVersionedMigrations() + { + var version = ReadSchemaVersion(); + if (version < 1) + { + MigrateExistingData(); + version = 1; + } + WriteSchemaVersion(version); + } + + private int ReadSchemaVersion() + { + var meta = _db.GetCollection("meta").FindById(1); + return meta?["SchemaVersion"].AsInt32 ?? 0; + } + + private void WriteSchemaVersion(int version) => + _db.GetCollection("meta").Upsert(new BsonDocument + { + ["_id"] = 1, + ["SchemaVersion"] = version, + }); + private void MigrateExistingData() { MigrateMemberships(); diff --git a/LehrerApp.Desktop.Tests/AppLockViewModelTests.cs b/LehrerApp.Desktop.Tests/AppLockViewModelTests.cs new file mode 100644 index 0000000..5a50166 --- /dev/null +++ b/LehrerApp.Desktop.Tests/AppLockViewModelTests.cs @@ -0,0 +1,46 @@ +using LehrerApp.Core.Services; +using LehrerApp.Desktop.ViewModels; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class AppLockViewModelTests +{ + [Fact] + public void Unlock_KorrektesPasswort_HebtDieSperreAuf() + { + using var temp = new TempAppData(); + var appLock = new AppLockService(temp.Path); + appLock.SetPassword("geheim123"); + var vm = new AppLockViewModel(appLock) { IsLocked = true, PasswordAttempt = "geheim123" }; + + vm.UnlockCommand.Execute(null); + + Assert.False(vm.IsLocked); + Assert.Equal("", vm.UnlockError); + } + + [Fact] + public void Unlock_FalschesPasswort_BleibtGesperrtUndZeigtFehler() + { + using var temp = new TempAppData(); + var appLock = new AppLockService(temp.Path); + appLock.SetPassword("geheim123"); + var vm = new AppLockViewModel(appLock) { IsLocked = true, PasswordAttempt = "falsch" }; + + vm.UnlockCommand.Execute(null); + + Assert.True(vm.IsLocked); + Assert.NotEqual("", vm.UnlockError); + Assert.Equal("", vm.PasswordAttempt); + } + + private sealed class TempAppData : IDisposable + { + public string Path { get; } = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), $"lehrerapp-applockvm-tests-{Guid.NewGuid():N}"); + + public TempAppData() => Directory.CreateDirectory(Path); + public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); } + } +} diff --git a/LehrerApp.Desktop/App.axaml.cs b/LehrerApp.Desktop/App.axaml.cs index 7bf84b7..7808a45 100644 --- a/LehrerApp.Desktop/App.axaml.cs +++ b/LehrerApp.Desktop/App.axaml.cs @@ -2,6 +2,7 @@ using Avalonia; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using LehrerApp.Core.Services; +using LehrerApp.Data; using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels; using LehrerApp.Desktop.ViewModels.Groups; @@ -18,19 +19,44 @@ public class App : Application public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + // Verschlüsselte Datenbank (13.3.4): Passwort abfragen, bevor die Datenbank + // (und damit BuildServices, das sie öffnet) angefasst wird. + if (AppBootstrapper.IsDbEncrypted()) + { + var promptVm = new DbPasswordPromptViewModel( + new DatabaseEncryptionService(), AppBootstrapper.ResolveDbPath()); + var promptWindow = new DbPasswordPromptWindow { DataContext = promptVm }; + promptVm.OnUnlocked = password => + { + AppBootstrapper.DbPassword = password; + StartMainApp(desktop); + promptWindow.Close(); + }; + desktop.MainWindow = promptWindow; + } + else + { + StartMainApp(desktop); + } + } + + base.OnFrameworkInitializationCompleted(); + } + + private static void StartMainApp(IClassicDesktopStyleApplicationLifetime desktop) { Services = AppBootstrapper.BuildServices(); Services.GetRequiredService().Info("Anwendung gestartet."); GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService()); - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - var mainVm = Services.GetRequiredService(); - WireCallbacks(mainVm); - desktop.MainWindow = new MainWindow { DataContext = mainVm }; - } - - base.OnFrameworkInitializationCompleted(); + var mainVm = Services.GetRequiredService(); + WireCallbacks(mainVm); + var main = new MainWindow { DataContext = mainVm }; + desktop.MainWindow = main; + main.Show(); } private static void WireCallbacks(MainWindowViewModel main) diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 31b58c1..51c1f26 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -25,6 +25,12 @@ public static class AppBootstrapper public static string DbPath { get; private set; } = ""; public static string AppDataPath { get; private set; } = ""; + /// + /// Vor gesetzt, wenn die Datenbank passwortgeschützt ist + /// (siehe App.axaml.cs: Passwort-Abfrage vor dem Öffnen der Datenbank). + /// + public static string? DbPassword { get; set; } + /// /// Vor der eigentlichen DI-Konfiguration verfügbar (z.B. für den globalen /// Exception-Handler in Program.cs, der schon vor greifen muss). @@ -42,19 +48,41 @@ public static class AppBootstrapper return appData; } + /// Vor dem Öffnen der Datenbank verfügbar (z.B. für die Passwort-Abfrage beim Start). + public static string ResolveDbPath() + { + if (string.IsNullOrEmpty(DbPath)) + DbPath = Path.Combine(ResolveAppDataPath(), "lehrerapp.db"); + return DbPath; + } + public static AppLogger EnsureLogger() { Logger ??= new AppLogger(ResolveAppDataPath()); return Logger; } + public static bool IsDbEncrypted() => + new DatabaseEncryptionService().IsEncrypted(ResolveDbPath()); + + /// Beendet den Prozess und startet ihn neu (z.B. nach Wiederherstellung eines Backups + /// oder einer Änderung der Datenbank-Verschlüsselung — die laufende LiteDB-Verbindung + /// kann nicht sicher "heiß" auf eine andere Datei umgehängt werden). + public static void RestartApplication() + { + var exePath = Environment.ProcessPath; + if (!string.IsNullOrEmpty(exePath)) + System.Diagnostics.Process.Start(exePath); + Environment.Exit(0); + } + public static IServiceProvider BuildServices() { var services = new ServiceCollection(); // ── Pfade ───────────────────────────────────────────────────────────── var appData = ResolveAppDataPath(); - DbPath = Path.Combine(appData, "lehrerapp.db"); + ResolveDbPath(); var queuePath = Path.Combine(appData, "syncqueue.db"); var keyPath = Path.Combine(appData, "sync.key"); @@ -63,8 +91,17 @@ public static class AppBootstrapper services.AddSingleton(Logger); services.AddSingleton(); + // ── Datensicherheit (13.3) ─────────────────────────────────────────── + // Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von + // Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig. + var backup = new BackupService(appData); + backup.CreateBackup(DbPath); + services.AddSingleton(backup); + services.AddSingleton(_ => new AppLockService(appData)); + services.AddSingleton(); + // ── Datenbank ───────────────────────────────────────────────────────── - services.AddSingleton(_ => new LiteDbContext(DbPath)); + services.AddSingleton(_ => new LiteDbContext(DbPath, DbPassword)); // ── Repositories ────────────────────────────────────────────────────── services.AddSingleton(); @@ -128,6 +165,7 @@ public static class AppBootstrapper // ── ViewModels ──────────────────────────────────────────────────────── // Singleton: einmal erstellt, überall dieselbe Instanz + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => diff --git a/LehrerApp.Desktop/ViewModels/AppLockViewModel.cs b/LehrerApp.Desktop/ViewModels/AppLockViewModel.cs new file mode 100644 index 0000000..80a08ea --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/AppLockViewModel.cs @@ -0,0 +1,71 @@ +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Services; + +namespace LehrerApp.Desktop.ViewModels; + +/// +/// Sperrbildschirm nach Inaktivität (13.3.5). Läuft unabhängig von einer eventuellen +/// Datenbank-Verschlüsselung — eigenes Passwort über . +/// +public partial class AppLockViewModel : ObservableObject +{ + private readonly AppLockService _appLock; + private System.Timers.Timer? _idleTimer; + + [ObservableProperty] private bool _isLocked; + [ObservableProperty] private string _passwordAttempt = ""; + [ObservableProperty] private string _unlockError = ""; + + public AppLockViewModel(AppLockService appLock) => _appLock = appLock; + + /// Wird bei jeder Nutzereingabe (Maus/Tastatur) im Hauptfenster aufgerufen. + public void NotifyActivity() + { + if (!IsLocked) ResetIdleTimer(); + } + + /// Nach dem Start bzw. nach Änderungen in den Einstellungen aufrufen. + public void ApplyConfig() + { + _idleTimer?.Stop(); + _idleTimer = null; + if (!_appLock.IsEnabled || !_appLock.HasPassword) return; + + _idleTimer = new System.Timers.Timer(_appLock.TimeoutMinutes * 60_000) { AutoReset = false }; + _idleTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(Lock); + _idleTimer.Start(); + } + + private void ResetIdleTimer() + { + if (_idleTimer is null) return; + _idleTimer.Stop(); + _idleTimer.Start(); + } + + private void Lock() + { + if (!_appLock.IsEnabled || !_appLock.HasPassword) return; + PasswordAttempt = ""; + UnlockError = ""; + IsLocked = true; + } + + [RelayCommand] + private void Unlock() + { + if (_appLock.VerifyPassword(PasswordAttempt)) + { + IsLocked = false; + UnlockError = ""; + ResetIdleTimer(); + } + else + { + UnlockError = "Falsches Passwort."; + } + PasswordAttempt = ""; + } +} diff --git a/LehrerApp.Desktop/ViewModels/DbPasswordPromptViewModel.cs b/LehrerApp.Desktop/ViewModels/DbPasswordPromptViewModel.cs new file mode 100644 index 0000000..cfe9b4e --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/DbPasswordPromptViewModel.cs @@ -0,0 +1,39 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Data; + +namespace LehrerApp.Desktop.ViewModels; + +/// +/// Passwort-Abfrage beim Programmstart, wenn die Datenbank verschlüsselt ist (13.3.4). +/// Läuft vor , deshalb ohne DI. +/// +public partial class DbPasswordPromptViewModel : ObservableObject +{ + private readonly DatabaseEncryptionService _dbEncryption; + private readonly string _dbPath; + + [ObservableProperty] private string _password = ""; + [ObservableProperty] private string _error = ""; + + public Action? OnUnlocked { get; set; } + + public DbPasswordPromptViewModel(DatabaseEncryptionService dbEncryption, string dbPath) + { + _dbEncryption = dbEncryption; + _dbPath = dbPath; + } + + [RelayCommand] + private void Unlock() + { + if (string.IsNullOrEmpty(Password)) { Error = "Bitte Passwort eingeben."; return; } + if (!_dbEncryption.VerifyPassword(_dbPath, Password)) + { + Error = "Falsches Passwort."; + Password = ""; + return; + } + OnUnlocked?.Invoke(Password); + } +} diff --git a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs index e33ceba..6d90991 100644 --- a/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/MainWindowViewModel.cs @@ -20,16 +20,19 @@ public partial class MainWindowViewModel : ObservableObject public SyncStatusViewModel SyncStatus { get; } public ObservableCollection Toasts { get; } + public AppLockViewModel AppLock { get; } public MainWindowViewModel(IServiceProvider services, DashboardViewModel dashboard, SchoolYearService sy, - SyncStatusViewModel syncStatus, NotificationService notifications) + SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock) { _services = services; SyncStatus = syncStatus; Toasts = notifications.Toasts; + AppLock = appLock; CurrentSchoolYear = sy.CurrentSchoolYear(); CurrentPage = dashboard; + AppLock.ApplyConfig(); } [RelayCommand] diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index 601d8c7..a67b333 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; +using LehrerApp.Data; using System.Collections.ObjectModel; using System.Text.Json; using System.Text.Json.Serialization; @@ -18,6 +19,10 @@ public partial class SettingsViewModel : ObservableObject private readonly IGradingKeyTemplateRepository _gradingKeyTemplates; private readonly IGradingSchemeRepository _gradingSchemes; private readonly GradingService _grading; + private readonly BackupService _backups; + private readonly DatabaseEncryptionService _dbEncryption; + private readonly AppLockService _appLock; + private readonly LiteDbContext _dbContext; // ── Fächer ──────────────────────────────────────────────────────────────── @@ -51,20 +56,183 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private GradingSchemeEditItem _classScheme = null!; [ObservableProperty] private GradingSchemeEditItem _courseScheme = null!; + // ── Sicherheit: Datensicherung (13.3.1/13.3.2) ─────────────────────────── + + [ObservableProperty] private string _backupStatus = ""; + public ObservableCollection Backups { get; } = []; + + /// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem Wiederherstellen. + public Func>? OnConfirmRestore { get; set; } + + // ── Sicherheit: Datenbank-Verschlüsselung (13.3.4) ─────────────────────── + + [ObservableProperty] private bool _isDbEncrypted; + [ObservableProperty] private string _currentDbPassword = ""; + [ObservableProperty] private string _newDbPassword = ""; + [ObservableProperty] private string _newDbPasswordConfirm = ""; + [ObservableProperty] private string _dbPasswordError = ""; + + public string DbEncryptionStatusLabel => IsDbEncrypted ? "verschlüsselt" : "nicht verschlüsselt"; + + partial void OnIsDbEncryptedChanged(bool value) => OnPropertyChanged(nameof(DbEncryptionStatusLabel)); + + // ── Sicherheit: App-Sperre nach Inaktivität (13.3.5) ───────────────────── + + [ObservableProperty] private bool _appLockEnabled; + [ObservableProperty] private int _appLockTimeoutMinutes = 10; + [ObservableProperty] private string _appLockNewPassword = ""; + [ObservableProperty] private string _appLockNewPasswordConfirm = ""; + [ObservableProperty] private string _appLockPasswordError = ""; + [ObservableProperty] private string _appLockStatus = ""; + + public bool AppLockHasPassword => _appLock.HasPassword; + + /// Vom Code-Behind gesetzt: aktualisiert den laufenden Inaktivitäts-Timer sofort, + /// ohne dass die App neu gestartet werden muss. + public Action? OnAppLockChanged { get; set; } + // ── Konstruktor ─────────────────────────────────────────────────────────── public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo, IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes, - GradingService grading) + GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption, + AppLockService appLock, LiteDbContext dbContext) { _subjects = subjects; _domainRepo = domainRepo; _gradingKeyTemplates = gradingKeyTemplates; _gradingSchemes = gradingSchemes; _grading = grading; + _backups = backups; + _dbEncryption = dbEncryption; + _appLock = appLock; + _dbContext = dbContext; LoadSubjects(); LoadGradingKeyTemplates(); LoadGradingSchemes(); + LoadBackups(); + IsDbEncrypted = _dbEncryption.IsEncrypted(AppBootstrapper.DbPath); + AppLockEnabled = _appLock.IsEnabled; + AppLockTimeoutMinutes = _appLock.TimeoutMinutes; + } + + // ── Datensicherung: Laden / Erstellen / Wiederherstellen ───────────────── + + private void LoadBackups() + { + Backups.Clear(); + foreach (var b in _backups.ListBackups()) Backups.Add(new BackupListItem(b)); + } + + [RelayCommand] + private void CreateBackupNow() + { + _backups.CreateBackup(AppBootstrapper.DbPath); + LoadBackups(); + BackupStatus = "Backup erstellt."; + } + + [RelayCommand] + private async Task RestoreBackup(BackupListItem? item) + { + if (item is null) return; + if (OnConfirmRestore is not null && !await OnConfirmRestore(item)) return; + + _dbContext.Dispose(); + _backups.RestoreBackup(item.Path, AppBootstrapper.DbPath); + AppBootstrapper.RestartApplication(); + } + + // ── Datenbank-Verschlüsselung: Setzen / Ändern / Entfernen ─────────────── + + [RelayCommand] + private void SaveDbPassword() + { + DbPasswordError = ""; + var valid = true; + var wantsPassword = !string.IsNullOrWhiteSpace(NewDbPassword) || !string.IsNullOrWhiteSpace(NewDbPasswordConfirm); + + if (IsDbEncrypted && !_dbEncryption.VerifyPassword(AppBootstrapper.DbPath, CurrentDbPassword)) + { + DbPasswordError = "Aktuelles Passwort ist falsch."; + valid = false; + } + + if (!wantsPassword) + { + DbPasswordError = "Bitte ein neues Passwort eingeben."; + valid = false; + } + else if (NewDbPassword != NewDbPasswordConfirm) + { + DbPasswordError = "Neue Passwörter stimmen nicht überein."; + valid = false; + } + else if (NewDbPassword.Length < 4) + { + DbPasswordError = "Mindestens 4 Zeichen."; + valid = false; + } + + if (!valid) return; + + _dbContext.Dispose(); + _dbEncryption.SetPassword(AppBootstrapper.DbPath, IsDbEncrypted ? CurrentDbPassword : null, NewDbPassword.Trim()); + AppBootstrapper.RestartApplication(); + } + + [RelayCommand] + private void RemoveDbPassword() + { + DbPasswordError = ""; + if (!_dbEncryption.VerifyPassword(AppBootstrapper.DbPath, CurrentDbPassword)) + { + DbPasswordError = "Aktuelles Passwort ist falsch."; + return; + } + + _dbContext.Dispose(); + _dbEncryption.SetPassword(AppBootstrapper.DbPath, CurrentDbPassword, null); + AppBootstrapper.RestartApplication(); + } + + // ── App-Sperre: Speichern ───────────────────────────────────────────────── + + [RelayCommand] + private void SaveAppLockSettings() + { + AppLockPasswordError = ""; + var valid = true; + var wantsNewPassword = !string.IsNullOrWhiteSpace(AppLockNewPassword) || !string.IsNullOrWhiteSpace(AppLockNewPasswordConfirm); + + if (AppLockEnabled && !_appLock.HasPassword && !wantsNewPassword) + { + AppLockPasswordError = "Bitte ein Passwort für die App-Sperre festlegen."; + valid = false; + } + else if (wantsNewPassword) + { + if (AppLockNewPassword != AppLockNewPasswordConfirm) + { + AppLockPasswordError = "Passwörter stimmen nicht überein."; + valid = false; + } + else if (AppLockNewPassword.Length < 4) + { + AppLockPasswordError = "Mindestens 4 Zeichen."; + valid = false; + } + } + + if (!valid) return; + + if (wantsNewPassword) _appLock.SetPassword(AppLockNewPassword.Trim()); + _appLock.Configure(AppLockEnabled, AppLockTimeoutMinutes); + + AppLockNewPassword = ""; AppLockNewPasswordConfirm = ""; + OnPropertyChanged(nameof(AppLockHasPassword)); + AppLockStatus = "Gespeichert."; + OnAppLockChanged?.Invoke(); } // ── Gewichtungsschema-Voreinstellungen: Laden ──────────────────────────── @@ -486,6 +654,12 @@ public class SubjectListItem(Subject s) public string ShortName { get; } = s.ShortName; } +public class BackupListItem(BackupInfo info) +{ + public string Path { get; } = info.Path; + public string Display { get; } = $"{info.CreatedAt:dd.MM.yyyy HH:mm} · {info.SizeBytes / 1024.0:0} KB"; +} + // ── JSON DTOs ───────────────────────────────────────────────────────────────── internal class CatalogDto diff --git a/LehrerApp.Desktop/Views/DbPasswordPromptWindow.axaml b/LehrerApp.Desktop/Views/DbPasswordPromptWindow.axaml new file mode 100644 index 0000000..76394c2 --- /dev/null +++ b/LehrerApp.Desktop/Views/DbPasswordPromptWindow.axaml @@ -0,0 +1,20 @@ + + + + + + +