Datensicherheit: Backup, Verschlüsselung, App-Sperre (Kapitel 13.3)
Automatisches rollierendes Backup der Datenbank beim Start mit Wiederherstellung über die Einstellungen, versionierte Schema-Migration, optionale Passwort-Verschlüsselung der LiteDB-Datei und eine App-Sperre nach Inaktivität mit eigenem Passwort. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// App-Sperre nach Inaktivität: eigenes Passwort (unabhängig von einer eventuellen
|
||||||
|
/// Datenbank-Verschlüsselung), als gesalzener Hash in <c><AppData>/LehrerApp/applock.json</c>
|
||||||
|
/// abgelegt. Das Klartext-Passwort wird nie gespeichert.
|
||||||
|
/// </summary>
|
||||||
|
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<AppLockConfig>(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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
namespace LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
public record BackupInfo(string Path, DateTime CreatedAt, long SizeBytes);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Rollierendes lokales Backup der LiteDB-Datei
|
||||||
|
/// (<c><AppData>/LehrerApp/backups/lehrerapp-JJJJ-MM-TT_HH-mm-ss.db</c>).
|
||||||
|
/// Reine Dateikopie — unabhängig davon, ob die Datenbank verschlüsselt ist.
|
||||||
|
/// </summary>
|
||||||
|
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<BackupInfo> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<BsonDocument>("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<BsonDocument>("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<BsonDocument>("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<BsonDocument>("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<BsonDocument>("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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,6 +115,26 @@ public sealed class LiteDbContextTests
|
|||||||
new ExamResult { StudentId = studentId, ExamId = examId }));
|
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 sealed class TempDatabase : IDisposable
|
||||||
{
|
{
|
||||||
private readonly string _directory = System.IO.Path.Combine(
|
private readonly string _directory = System.IO.Path.Combine(
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using LiteDB;
|
||||||
|
|
||||||
|
namespace LehrerApp.Data;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>LiteDatabase.Rebuild</c> mit
|
||||||
|
/// Passwort, das in LiteDB 5.0.21 nachweislich fehlschlägt (per Skript verifiziert).
|
||||||
|
/// </summary>
|
||||||
|
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 <see cref="LiteDbContext"/>) 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<BsonDocument>(name).FindAll().ToList();
|
||||||
|
if (docs.Count > 0) dst.GetCollection<BsonDocument>(name).InsertBulk(docs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
File.Copy(tempPath, dbPath, overwrite: true);
|
||||||
|
File.Delete(tempPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,15 +8,21 @@ namespace LehrerApp.Data;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class LiteDbContext : IDisposable
|
public class LiteDbContext : IDisposable
|
||||||
{
|
{
|
||||||
|
/// Aktuelle Schema-Version. Migrationsschritte werden versioniert unter
|
||||||
|
/// <see cref="RunVersionedMigrations"/> 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;
|
private readonly LiteDatabase _db;
|
||||||
|
|
||||||
public LiteDbContext(string databasePath)
|
public LiteDbContext(string databasePath, string? password = null)
|
||||||
{
|
{
|
||||||
_db = new LiteDatabase(new ConnectionString(databasePath)
|
_db = new LiteDatabase(new ConnectionString(databasePath)
|
||||||
{
|
{
|
||||||
Connection = ConnectionType.Shared,
|
Connection = ConnectionType.Shared,
|
||||||
|
Password = password,
|
||||||
});
|
});
|
||||||
MigrateExistingData();
|
RunVersionedMigrations();
|
||||||
EnsureIndexes();
|
EnsureIndexes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +30,7 @@ public class LiteDbContext : IDisposable
|
|||||||
public LiteDbContext(Stream stream)
|
public LiteDbContext(Stream stream)
|
||||||
{
|
{
|
||||||
_db = new LiteDatabase(stream);
|
_db = new LiteDatabase(stream);
|
||||||
MigrateExistingData();
|
RunVersionedMigrations();
|
||||||
EnsureIndexes();
|
EnsureIndexes();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +57,32 @@ public class LiteDbContext : IDisposable
|
|||||||
|
|
||||||
public void Checkpoint() => _db.Checkpoint();
|
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<BsonDocument>("meta").FindById(1);
|
||||||
|
return meta?["SchemaVersion"].AsInt32 ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void WriteSchemaVersion(int version) =>
|
||||||
|
_db.GetCollection<BsonDocument>("meta").Upsert(new BsonDocument
|
||||||
|
{
|
||||||
|
["_id"] = 1,
|
||||||
|
["SchemaVersion"] = version,
|
||||||
|
});
|
||||||
|
|
||||||
private void MigrateExistingData()
|
private void MigrateExistingData()
|
||||||
{
|
{
|
||||||
MigrateMemberships();
|
MigrateMemberships();
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using Avalonia;
|
|||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Data;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
@@ -18,19 +19,44 @@ public class App : Application
|
|||||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
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 = AppBootstrapper.BuildServices();
|
||||||
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
|
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
|
||||||
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
|
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
|
||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
||||||
{
|
WireCallbacks(mainVm);
|
||||||
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
var main = new MainWindow { DataContext = mainVm };
|
||||||
WireCallbacks(mainVm);
|
desktop.MainWindow = main;
|
||||||
desktop.MainWindow = new MainWindow { DataContext = mainVm };
|
main.Show();
|
||||||
}
|
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void WireCallbacks(MainWindowViewModel main)
|
private static void WireCallbacks(MainWindowViewModel main)
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ public static class AppBootstrapper
|
|||||||
public static string DbPath { get; private set; } = "";
|
public static string DbPath { get; private set; } = "";
|
||||||
public static string AppDataPath { get; private set; } = "";
|
public static string AppDataPath { get; private set; } = "";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Vor <see cref="BuildServices"/> gesetzt, wenn die Datenbank passwortgeschützt ist
|
||||||
|
/// (siehe App.axaml.cs: Passwort-Abfrage vor dem Öffnen der Datenbank).
|
||||||
|
/// </summary>
|
||||||
|
public static string? DbPassword { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Vor der eigentlichen DI-Konfiguration verfügbar (z.B. für den globalen
|
/// Vor der eigentlichen DI-Konfiguration verfügbar (z.B. für den globalen
|
||||||
/// Exception-Handler in Program.cs, der schon vor <see cref="BuildServices"/> greifen muss).
|
/// Exception-Handler in Program.cs, der schon vor <see cref="BuildServices"/> greifen muss).
|
||||||
@@ -42,19 +48,41 @@ public static class AppBootstrapper
|
|||||||
return appData;
|
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()
|
public static AppLogger EnsureLogger()
|
||||||
{
|
{
|
||||||
Logger ??= new AppLogger(ResolveAppDataPath());
|
Logger ??= new AppLogger(ResolveAppDataPath());
|
||||||
return Logger;
|
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()
|
public static IServiceProvider BuildServices()
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
|
|
||||||
// ── Pfade ─────────────────────────────────────────────────────────────
|
// ── Pfade ─────────────────────────────────────────────────────────────
|
||||||
var appData = ResolveAppDataPath();
|
var appData = ResolveAppDataPath();
|
||||||
DbPath = Path.Combine(appData, "lehrerapp.db");
|
ResolveDbPath();
|
||||||
var queuePath = Path.Combine(appData, "syncqueue.db");
|
var queuePath = Path.Combine(appData, "syncqueue.db");
|
||||||
var keyPath = Path.Combine(appData, "sync.key");
|
var keyPath = Path.Combine(appData, "sync.key");
|
||||||
|
|
||||||
@@ -63,8 +91,17 @@ public static class AppBootstrapper
|
|||||||
services.AddSingleton(Logger);
|
services.AddSingleton(Logger);
|
||||||
services.AddSingleton<NotificationService>();
|
services.AddSingleton<NotificationService>();
|
||||||
|
|
||||||
|
// ── 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<DatabaseEncryptionService>();
|
||||||
|
|
||||||
// ── Datenbank ─────────────────────────────────────────────────────────
|
// ── Datenbank ─────────────────────────────────────────────────────────
|
||||||
services.AddSingleton(_ => new LiteDbContext(DbPath));
|
services.AddSingleton(_ => new LiteDbContext(DbPath, DbPassword));
|
||||||
|
|
||||||
// ── Repositories ──────────────────────────────────────────────────────
|
// ── Repositories ──────────────────────────────────────────────────────
|
||||||
services.AddSingleton<IStudentRepository, StudentRepository>();
|
services.AddSingleton<IStudentRepository, StudentRepository>();
|
||||||
@@ -128,6 +165,7 @@ public static class AppBootstrapper
|
|||||||
|
|
||||||
// ── ViewModels ────────────────────────────────────────────────────────
|
// ── ViewModels ────────────────────────────────────────────────────────
|
||||||
// Singleton: einmal erstellt, überall dieselbe Instanz
|
// Singleton: einmal erstellt, überall dieselbe Instanz
|
||||||
|
services.AddSingleton<AppLockViewModel>();
|
||||||
services.AddSingleton<MainWindowViewModel>();
|
services.AddSingleton<MainWindowViewModel>();
|
||||||
services.AddSingleton<DashboardViewModel>();
|
services.AddSingleton<DashboardViewModel>();
|
||||||
services.AddSingleton(sp =>
|
services.AddSingleton(sp =>
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sperrbildschirm nach Inaktivität (13.3.5). Läuft unabhängig von einer eventuellen
|
||||||
|
/// Datenbank-Verschlüsselung — eigenes Passwort über <see cref="AppLockService"/>.
|
||||||
|
/// </summary>
|
||||||
|
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 = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Data;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Passwort-Abfrage beim Programmstart, wenn die Datenbank verschlüsselt ist (13.3.4).
|
||||||
|
/// Läuft vor <see cref="AppBootstrapper.BuildServices"/>, deshalb ohne DI.
|
||||||
|
/// </summary>
|
||||||
|
public partial class DbPasswordPromptViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly DatabaseEncryptionService _dbEncryption;
|
||||||
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _password = "";
|
||||||
|
[ObservableProperty] private string _error = "";
|
||||||
|
|
||||||
|
public Action<string>? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,16 +20,19 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
|
|
||||||
public SyncStatusViewModel SyncStatus { get; }
|
public SyncStatusViewModel SyncStatus { get; }
|
||||||
public ObservableCollection<ToastItem> Toasts { get; }
|
public ObservableCollection<ToastItem> Toasts { get; }
|
||||||
|
public AppLockViewModel AppLock { get; }
|
||||||
|
|
||||||
public MainWindowViewModel(IServiceProvider services,
|
public MainWindowViewModel(IServiceProvider services,
|
||||||
DashboardViewModel dashboard, SchoolYearService sy,
|
DashboardViewModel dashboard, SchoolYearService sy,
|
||||||
SyncStatusViewModel syncStatus, NotificationService notifications)
|
SyncStatusViewModel syncStatus, NotificationService notifications, AppLockViewModel appLock)
|
||||||
{
|
{
|
||||||
_services = services;
|
_services = services;
|
||||||
SyncStatus = syncStatus;
|
SyncStatus = syncStatus;
|
||||||
Toasts = notifications.Toasts;
|
Toasts = notifications.Toasts;
|
||||||
|
AppLock = appLock;
|
||||||
CurrentSchoolYear = sy.CurrentSchoolYear();
|
CurrentSchoolYear = sy.CurrentSchoolYear();
|
||||||
CurrentPage = dashboard;
|
CurrentPage = dashboard;
|
||||||
|
AppLock.ApplyConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
using LehrerApp.Core.Interfaces;
|
using LehrerApp.Core.Interfaces;
|
||||||
using LehrerApp.Core.Models;
|
using LehrerApp.Core.Models;
|
||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Data;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
@@ -18,6 +19,10 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
private readonly IGradingKeyTemplateRepository _gradingKeyTemplates;
|
private readonly IGradingKeyTemplateRepository _gradingKeyTemplates;
|
||||||
private readonly IGradingSchemeRepository _gradingSchemes;
|
private readonly IGradingSchemeRepository _gradingSchemes;
|
||||||
private readonly GradingService _grading;
|
private readonly GradingService _grading;
|
||||||
|
private readonly BackupService _backups;
|
||||||
|
private readonly DatabaseEncryptionService _dbEncryption;
|
||||||
|
private readonly AppLockService _appLock;
|
||||||
|
private readonly LiteDbContext _dbContext;
|
||||||
|
|
||||||
// ── Fächer ────────────────────────────────────────────────────────────────
|
// ── Fächer ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -51,20 +56,183 @@ public partial class SettingsViewModel : ObservableObject
|
|||||||
[ObservableProperty] private GradingSchemeEditItem _classScheme = null!;
|
[ObservableProperty] private GradingSchemeEditItem _classScheme = null!;
|
||||||
[ObservableProperty] private GradingSchemeEditItem _courseScheme = null!;
|
[ObservableProperty] private GradingSchemeEditItem _courseScheme = null!;
|
||||||
|
|
||||||
|
// ── Sicherheit: Datensicherung (13.3.1/13.3.2) ───────────────────────────
|
||||||
|
|
||||||
|
[ObservableProperty] private string _backupStatus = "";
|
||||||
|
public ObservableCollection<BackupListItem> Backups { get; } = [];
|
||||||
|
|
||||||
|
/// Vom Code-Behind gesetzt: zeigt einen Bestätigungsdialog vor dem Wiederherstellen.
|
||||||
|
public Func<BackupListItem, Task<bool>>? 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 ───────────────────────────────────────────────────────────
|
// ── Konstruktor ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
|
public SettingsViewModel(ISubjectRepository subjects, ICompetencyDomainRepository domainRepo,
|
||||||
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
|
IGradingKeyTemplateRepository gradingKeyTemplates, IGradingSchemeRepository gradingSchemes,
|
||||||
GradingService grading)
|
GradingService grading, BackupService backups, DatabaseEncryptionService dbEncryption,
|
||||||
|
AppLockService appLock, LiteDbContext dbContext)
|
||||||
{
|
{
|
||||||
_subjects = subjects;
|
_subjects = subjects;
|
||||||
_domainRepo = domainRepo;
|
_domainRepo = domainRepo;
|
||||||
_gradingKeyTemplates = gradingKeyTemplates;
|
_gradingKeyTemplates = gradingKeyTemplates;
|
||||||
_gradingSchemes = gradingSchemes;
|
_gradingSchemes = gradingSchemes;
|
||||||
_grading = grading;
|
_grading = grading;
|
||||||
|
_backups = backups;
|
||||||
|
_dbEncryption = dbEncryption;
|
||||||
|
_appLock = appLock;
|
||||||
|
_dbContext = dbContext;
|
||||||
LoadSubjects();
|
LoadSubjects();
|
||||||
LoadGradingKeyTemplates();
|
LoadGradingKeyTemplates();
|
||||||
LoadGradingSchemes();
|
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 ────────────────────────────
|
// ── Gewichtungsschema-Voreinstellungen: Laden ────────────────────────────
|
||||||
@@ -486,6 +654,12 @@ public class SubjectListItem(Subject s)
|
|||||||
public string ShortName { get; } = s.ShortName;
|
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 ─────────────────────────────────────────────────────────────────
|
// ── JSON DTOs ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
internal class CatalogDto
|
internal class CatalogDto
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.DbPasswordPromptWindow"
|
||||||
|
x:DataType="vm:DbPasswordPromptViewModel"
|
||||||
|
Title="LehrerApp – Datenbank entsperren"
|
||||||
|
Width="360" SizeToContent="Height"
|
||||||
|
CanResize="False" WindowStartupLocation="CenterScreen">
|
||||||
|
<StackPanel Margin="24" Spacing="12">
|
||||||
|
<TextBlock Text="🔒" FontSize="28" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="Datenbank ist passwortgeschützt" FontSize="15" FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
|
<TextBox PlaceholderText="Passwort" PasswordChar="●"
|
||||||
|
Text="{Binding Password}" KeyDown="OnKeyDown" x:Name="PasswordBox"/>
|
||||||
|
<TextBlock Text="{Binding Error}" Foreground="Red" FontSize="12" HorizontalAlignment="Center"
|
||||||
|
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<Button Content="Entsperren" Command="{Binding UnlockCommand}"
|
||||||
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using LehrerApp.Desktop.ViewModels;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views;
|
||||||
|
|
||||||
|
public partial class DbPasswordPromptWindow : Window
|
||||||
|
{
|
||||||
|
public DbPasswordPromptWindow() => InitializeComponent();
|
||||||
|
|
||||||
|
private void OnKeyDown(object? sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Enter && DataContext is DbPasswordPromptViewModel vm)
|
||||||
|
vm.UnlockCommand.Execute(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -218,5 +218,26 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- App-Sperre nach Inaktivität (13.3.5): blockiert die gesamte Oberfläche. -->
|
||||||
|
<Border Background="#E6101014" IsVisible="{Binding AppLock.IsLocked}">
|
||||||
|
<Border Width="320" Padding="24" CornerRadius="10"
|
||||||
|
Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock Text="🔒" FontSize="28" HorizontalAlignment="Center"/>
|
||||||
|
<TextBlock Text="LehrerApp gesperrt" FontSize="16" FontWeight="SemiBold"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
<TextBox PlaceholderText="Passwort" PasswordChar="●"
|
||||||
|
Text="{Binding AppLock.PasswordAttempt}"
|
||||||
|
KeyDown="OnLockScreenKeyDown"/>
|
||||||
|
<TextBlock Text="{Binding AppLock.UnlockError}" Foreground="Red" FontSize="12"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
IsVisible="{Binding AppLock.UnlockError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<Button Content="Entsperren" Command="{Binding AppLock.UnlockCommand}"
|
||||||
|
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Border>
|
||||||
</Panel>
|
</Panel>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using LehrerApp.Desktop.ViewModels;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views;
|
namespace LehrerApp.Desktop.Views;
|
||||||
|
|
||||||
public partial class MainWindow : Window
|
public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
public MainWindow() => InitializeComponent();
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
PointerMoved += (_, _) => NotifyActivity();
|
||||||
|
PointerPressed += (_, _) => NotifyActivity();
|
||||||
|
KeyDown += (_, _) => NotifyActivity();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NotifyActivity()
|
||||||
|
{
|
||||||
|
if (DataContext is MainWindowViewModel vm) vm.AppLock.NotifyActivity();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLockScreenKeyDown(object? sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key == Key.Enter && DataContext is MainWindowViewModel vm)
|
||||||
|
vm.AppLock.UnlockCommand.Execute(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,6 +266,118 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</ContentPage>
|
</ContentPage>
|
||||||
|
|
||||||
|
<!-- Tab: Sicherheit (13.3) -->
|
||||||
|
<ContentPage Header="Sicherheit">
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel Margin="32,20,32,28" Spacing="24" MaxWidth="560">
|
||||||
|
|
||||||
|
<!-- Datensicherung -->
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<TextBlock Text="Datensicherung" FontSize="16" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="Bei jedem Start wird automatisch ein Backup der Datenbank angelegt (die letzten 10 werden behalten)."
|
||||||
|
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
|
||||||
|
|
||||||
|
<Button Content="Jetzt sichern" Command="{Binding CreateBackupNowCommand}"
|
||||||
|
HorizontalAlignment="Left"/>
|
||||||
|
<TextBlock Text="{Binding BackupStatus}" Foreground="Green" FontSize="12"
|
||||||
|
IsVisible="{Binding BackupStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Backups}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:BackupListItem">
|
||||||
|
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseLowBrush}"
|
||||||
|
BorderThickness="0,0,0,1" Padding="0,7">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding Display}"
|
||||||
|
VerticalAlignment="Center" FontSize="13"/>
|
||||||
|
<Button Grid.Column="1" Content="Wiederherstellen" FontSize="12" Padding="10,4"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:SettingsViewModel)DataContext).RestoreBackupCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<TextBlock Text="Noch keine Backups vorhanden." Opacity="0.35" FontSize="12"
|
||||||
|
IsVisible="{Binding !Backups.Count}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Separator/>
|
||||||
|
|
||||||
|
<!-- Datenbank-Verschlüsselung -->
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<TextBlock Text="Datenbank-Verschlüsselung" FontSize="16" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="Schützt die Datenbankdatei auf der Festplatte mit einem Passwort. Ohne das Passwort kann die App die Datenbank beim Start nicht öffnen — bei Verlust sind die Daten nicht mehr zugänglich."
|
||||||
|
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
|
||||||
|
<TextBlock FontSize="13" FontWeight="SemiBold">
|
||||||
|
<Run Text="Status: "/>
|
||||||
|
<Run Text="{Binding DbEncryptionStatusLabel}"/>
|
||||||
|
</TextBlock>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4" IsVisible="{Binding IsDbEncrypted}">
|
||||||
|
<TextBlock Text="Aktuelles Passwort" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding CurrentDbPassword}" PasswordChar="●"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,12,*">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
|
<TextBlock Text="Neues Passwort" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding NewDbPassword}" PasswordChar="●"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
|
<TextBlock Text="Wiederholen" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding NewDbPasswordConfirm}" PasswordChar="●"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="{Binding DbPasswordError}" Foreground="Red" FontSize="12"
|
||||||
|
IsVisible="{Binding DbPasswordError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Content="Passwort speichern" Command="{Binding SaveDbPasswordCommand}"/>
|
||||||
|
<Button Content="Verschlüsselung entfernen" Command="{Binding RemoveDbPasswordCommand}"
|
||||||
|
IsVisible="{Binding IsDbEncrypted}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="Nach dem Ändern wird die App automatisch neu gestartet."
|
||||||
|
FontSize="11" Opacity="0.5"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Separator/>
|
||||||
|
|
||||||
|
<!-- App-Sperre nach Inaktivität -->
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<TextBlock Text="App-Sperre nach Inaktivität" FontSize="16" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="Sperrt die Oberfläche nach einer Zeit ohne Maus-/Tastatureingabe. Eigenes Passwort, unabhängig von der Datenbank-Verschlüsselung."
|
||||||
|
FontSize="12" Opacity="0.6" TextWrapping="Wrap"/>
|
||||||
|
|
||||||
|
<CheckBox Content="App-Sperre aktivieren" IsChecked="{Binding AppLockEnabled}"/>
|
||||||
|
|
||||||
|
<StackPanel Spacing="4" MaxWidth="200">
|
||||||
|
<TextBlock Text="Zeit bis zur Sperre (Minuten)" FontSize="12" Opacity="0.7"/>
|
||||||
|
<NumericUpDown Value="{Binding AppLockTimeoutMinutes}" Minimum="1" Maximum="120" FormatString="0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid ColumnDefinitions="*,12,*">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
|
<TextBlock Text="Passwort (leer lassen, um es zu behalten)" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding AppLockNewPassword}" PasswordChar="●"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
|
<TextBlock Text="Wiederholen" FontSize="12" Opacity="0.7"/>
|
||||||
|
<TextBox Text="{Binding AppLockNewPasswordConfirm}" PasswordChar="●"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Text="{Binding AppLockPasswordError}" Foreground="Red" FontSize="12"
|
||||||
|
IsVisible="{Binding AppLockPasswordError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
|
||||||
|
<Button Content="Speichern" Command="{Binding SaveAppLockSettingsCommand}" HorizontalAlignment="Left"/>
|
||||||
|
<TextBlock Text="{Binding AppLockStatus}" Foreground="Green" FontSize="12"
|
||||||
|
IsVisible="{Binding AppLockStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</ContentPage>
|
||||||
|
|
||||||
</TabbedPage>
|
</TabbedPage>
|
||||||
</Grid>
|
</Grid>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
|
using LehrerApp.Desktop.ViewModels;
|
||||||
using LehrerApp.Desktop.ViewModels.Settings;
|
using LehrerApp.Desktop.ViewModels.Settings;
|
||||||
|
using LehrerApp.Desktop.Views.Shared;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views.Settings;
|
namespace LehrerApp.Desktop.Views.Settings;
|
||||||
|
|
||||||
@@ -9,6 +12,30 @@ public partial class SettingsView : UserControl
|
|||||||
{
|
{
|
||||||
public SettingsView() => InitializeComponent();
|
public SettingsView() => InitializeComponent();
|
||||||
|
|
||||||
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDataContextChanged(e);
|
||||||
|
if (DataContext is SettingsViewModel vm)
|
||||||
|
{
|
||||||
|
vm.OnConfirmRestore = ShowRestoreConfirmDialog;
|
||||||
|
vm.OnAppLockChanged = () => App.Services.GetRequiredService<AppLockViewModel>().ApplyConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> ShowRestoreConfirmDialog(BackupListItem item)
|
||||||
|
{
|
||||||
|
var info = new ConfirmDialogInfo
|
||||||
|
{
|
||||||
|
Title = "Backup wiederherstellen?",
|
||||||
|
Message = $"Die aktuelle Datenbank wird durch das Backup vom {item.Display} ersetzt. " +
|
||||||
|
"Die App wird danach automatisch neu gestartet.",
|
||||||
|
ConfirmText = "Wiederherstellen",
|
||||||
|
};
|
||||||
|
var dialog = new ConfirmDialog { DataContext = info };
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
return owner is not null && await dialog.ShowDialog<bool>(owner);
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnImportClick(object? sender, RoutedEventArgs e)
|
private async void OnImportClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var topLevel = TopLevel.GetTopLevel(this);
|
var topLevel = TopLevel.GetTopLevel(this);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Shared.ConfirmDialog"
|
||||||
|
x:CompileBindings="False"
|
||||||
|
Title="{Binding Title}"
|
||||||
|
Width="420" SizeToContent="Height"
|
||||||
|
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||||
|
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||||
|
<StackPanel Grid.Row="0" Spacing="12">
|
||||||
|
<TextBlock Text="{Binding Title}" FontSize="18" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="{Binding Message}" TextWrapping="Wrap" Opacity="0.8"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||||
|
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||||
|
<Button Grid.Column="2" Content="{Binding ConfirmText}" HorizontalAlignment="Stretch" Click="OnConfirm"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Shared;
|
||||||
|
|
||||||
|
public partial class ConfirmDialog : Window
|
||||||
|
{
|
||||||
|
public ConfirmDialog() => InitializeComponent();
|
||||||
|
|
||||||
|
private void OnConfirm(object? sender, RoutedEventArgs e) => Close(true);
|
||||||
|
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace LehrerApp.Desktop.Views.Shared;
|
||||||
|
|
||||||
|
/// Generisches DataContext für <see cref="ConfirmDialog"/>.
|
||||||
|
public class ConfirmDialogInfo
|
||||||
|
{
|
||||||
|
public string Title { get; init; } = "";
|
||||||
|
public string Message { get; init; } = "";
|
||||||
|
public string ConfirmText { get; init; } = "Bestätigen";
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Tests;
|
||||||
|
|
||||||
|
public sealed class AppLockServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NeueKonfiguration_IstStandardmaessigDeaktiviert()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var service = new AppLockService(temp.Path);
|
||||||
|
|
||||||
|
Assert.False(service.IsEnabled);
|
||||||
|
Assert.False(service.HasPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetPassword_VerifyPassword_KorrektesPasswortWirdAkzeptiert()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var service = new AppLockService(temp.Path);
|
||||||
|
|
||||||
|
service.SetPassword("geheim123");
|
||||||
|
|
||||||
|
Assert.True(service.HasPassword);
|
||||||
|
Assert.True(service.VerifyPassword("geheim123"));
|
||||||
|
Assert.False(service.VerifyPassword("falsch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Configure_WirdUeberNeueInstanzHinwegPersistiert()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var first = new AppLockService(temp.Path);
|
||||||
|
first.SetPassword("geheim123");
|
||||||
|
first.Configure(enabled: true, timeoutMinutes: 15);
|
||||||
|
|
||||||
|
var second = new AppLockService(temp.Path);
|
||||||
|
|
||||||
|
Assert.True(second.IsEnabled);
|
||||||
|
Assert.Equal(15, second.TimeoutMinutes);
|
||||||
|
Assert.True(second.VerifyPassword("geheim123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VerifyPassword_OhneGesetztesPasswort_GibtFalseZurueck()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var service = new AppLockService(temp.Path);
|
||||||
|
|
||||||
|
Assert.False(service.VerifyPassword("irgendwas"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Configure_TimeoutUnterEinerMinute_WirdAufEineMinuteBegrenzt()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var service = new AppLockService(temp.Path);
|
||||||
|
|
||||||
|
service.Configure(enabled: true, timeoutMinutes: 0);
|
||||||
|
|
||||||
|
Assert.Equal(1, service.TimeoutMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TempAppData : IDisposable
|
||||||
|
{
|
||||||
|
public string Path { get; } = System.IO.Path.Combine(
|
||||||
|
System.IO.Path.GetTempPath(), $"lehrerapp-applock-tests-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
public TempAppData() => Directory.CreateDirectory(Path);
|
||||||
|
public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Tests;
|
||||||
|
|
||||||
|
public sealed class BackupServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void CreateBackup_OhneVorhandeneDatenbank_GibtNullZurueck()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var service = new BackupService(temp.Path);
|
||||||
|
|
||||||
|
var result = service.CreateBackup(Path.Combine(temp.Path, "fehlt.db"));
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateBackup_KopiertDieDatenbankdatei()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
|
||||||
|
File.WriteAllText(dbPath, "Testinhalt");
|
||||||
|
var service = new BackupService(temp.Path);
|
||||||
|
|
||||||
|
var backupPath = service.CreateBackup(dbPath);
|
||||||
|
|
||||||
|
Assert.NotNull(backupPath);
|
||||||
|
Assert.True(File.Exists(backupPath));
|
||||||
|
Assert.Equal("Testinhalt", File.ReadAllText(backupPath!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ListBackups_SortiertNeuesteZuerst()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
|
||||||
|
File.WriteAllText(dbPath, "v1");
|
||||||
|
var service = new BackupService(temp.Path);
|
||||||
|
|
||||||
|
var first = service.CreateBackup(dbPath)!;
|
||||||
|
File.SetLastWriteTime(first, DateTime.Now.AddMinutes(-5));
|
||||||
|
File.WriteAllText(dbPath, "v2");
|
||||||
|
var second = service.CreateBackup(dbPath)!;
|
||||||
|
|
||||||
|
var backups = service.ListBackups();
|
||||||
|
|
||||||
|
Assert.Equal(2, backups.Count);
|
||||||
|
Assert.Equal(second, backups[0].Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CreateBackup_BehaeltNurDieLetztenNBackups()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
|
||||||
|
var service = new BackupService(temp.Path);
|
||||||
|
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
File.WriteAllText(dbPath, $"v{i}");
|
||||||
|
var backup = service.CreateBackup(dbPath, keepCount: 3)!;
|
||||||
|
File.SetLastWriteTime(backup, DateTime.Now.AddMinutes(-5 + i));
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(3, service.ListBackups().Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RestoreBackup_KopiertBackupUeberDieZieldatei()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var dbPath = Path.Combine(temp.Path, "lehrerapp.db");
|
||||||
|
File.WriteAllText(dbPath, "alt");
|
||||||
|
var service = new BackupService(temp.Path);
|
||||||
|
var backupPath = service.CreateBackup(dbPath)!;
|
||||||
|
File.WriteAllText(dbPath, "neu-kaputt");
|
||||||
|
|
||||||
|
service.RestoreBackup(backupPath, dbPath);
|
||||||
|
|
||||||
|
Assert.Equal("alt", File.ReadAllText(dbPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RestoreBackup_OhneVorhandenesBackup_WirftException()
|
||||||
|
{
|
||||||
|
using var temp = new TempAppData();
|
||||||
|
var service = new BackupService(temp.Path);
|
||||||
|
|
||||||
|
Assert.Throws<FileNotFoundException>(() =>
|
||||||
|
service.RestoreBackup(Path.Combine(temp.Path, "backups", "fehlt.db"), Path.Combine(temp.Path, "lehrerapp.db")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TempAppData : IDisposable
|
||||||
|
{
|
||||||
|
public string Path { get; } = System.IO.Path.Combine(
|
||||||
|
System.IO.Path.GetTempPath(), $"lehrerapp-backup-tests-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
public TempAppData() => Directory.CreateDirectory(Path);
|
||||||
|
public void Dispose() { if (Directory.Exists(Path)) Directory.Delete(Path, recursive: true); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -530,11 +530,54 @@ Fächer- und Kompetenzverwaltung existiert bereits in
|
|||||||
"Schüler ausgewählt?", kein Textfeld zum Andocken vorhanden).
|
"Schüler ausgewählt?", kein Textfeld zum Andocken vorhanden).
|
||||||
|
|
||||||
### 13.3 Datensicherheit
|
### 13.3 Datensicherheit
|
||||||
- [ ] **13.3.1** Automatisches lokales Backup der LiteDB beim Start (rollierend, letzte 10).
|
- [x] **13.3.1** Automatisches lokales Backup der LiteDB beim Start (rollierend, letzte 10) —
|
||||||
- [ ] **13.3.2** Wiederherstellung aus einem Backup über die Einstellungen.
|
[BackupService.cs](LehrerApp.Core/Services/BackupService.cs). Reine Dateikopie nach
|
||||||
- [ ] **13.3.3** Schema-Migration: Versionsnummer in der DB, Migrationsschritte beim Start.
|
`<AppData>/LehrerApp/backups/`, läuft in `AppBootstrapper.BuildServices()` **vor** dem
|
||||||
- [ ] **13.3.4** Optionale Verschlüsselung der lokalen Datenbank (LiteDB-Passwort).
|
Öffnen der Datenbank (unabhängig von Verschlüsselung, kein LiteDB-Handle nötig). Behält
|
||||||
- [ ] **13.3.5** App-Sperre nach Inaktivität (Schülerdaten auf dem Lehrerrechner).
|
standardmäßig die letzten 10 Backups, ältere werden entfernt. 6 Tests in
|
||||||
|
[BackupServiceTests.cs](LehrerApp.Tests/BackupServiceTests.cs).
|
||||||
|
- [x] **13.3.2** Wiederherstellung aus einem Backup über die Einstellungen — neuer Tab
|
||||||
|
"Sicherheit" in [SettingsView.axaml](LehrerApp.Desktop/Views/Settings/SettingsView.axaml)
|
||||||
|
listet vorhandene Backups mit Zeitstempel/Größe, "Wiederherstellen" fragt über einen neuen
|
||||||
|
generischen [ConfirmDialog](LehrerApp.Desktop/Views/Shared/ConfirmDialog.axaml) nach,
|
||||||
|
kopiert das Backup über die aktive Datenbankdatei und startet die App danach automatisch
|
||||||
|
neu (`AppBootstrapper.RestartApplication()`) — ein laufender LiteDB-Verbindung kann nicht
|
||||||
|
sicher "heiß" auf eine andere Datei umgehängt werden.
|
||||||
|
- [x] **13.3.3** Schema-Migration mit Versionsnummer — `LiteDbContext` schreibt die
|
||||||
|
Schema-Version in eine `meta`-Collection und führt Migrationsschritte nur noch aus, wenn
|
||||||
|
die gespeicherte Version dahinter liegt (`RunVersionedMigrations`), statt wie bisher bei
|
||||||
|
jedem Start erneut über alle Daten zu laufen. Die bestehenden (bereits idempotenten)
|
||||||
|
Migrationsschritte wurden als Version-1-Schritt gebündelt, für zukünftige
|
||||||
|
Schema-Änderungen ergänzt man einen weiteren `if (version < N)`-Block. 2 Tests in
|
||||||
|
[LiteDbContextTests.cs](LehrerApp.Data.Tests/LiteDbContextTests.cs).
|
||||||
|
- [x] **13.3.4** Optionale Verschlüsselung der lokalen Datenbank —
|
||||||
|
[DatabaseEncryptionService.cs](LehrerApp.Data/DatabaseEncryptionService.cs). Passwort
|
||||||
|
setzen/ändern/entfernen läuft über eine Kopie (neue Datei mit Zielpasswort anlegen, alle
|
||||||
|
Collections umkopieren, Datei austauschen) statt über `LiteDatabase.Rebuild(...)` mit
|
||||||
|
Passwort — das ist in LiteDB 5.0.21 nachweislich fehlerhaft (per Skript verifiziert: wirft
|
||||||
|
"this data file is encrypted" beim Rebuild einer unverschlüsselten Datei). Ist die
|
||||||
|
Datenbank verschlüsselt, fragt ein eigenes Fenster
|
||||||
|
([DbPasswordPromptWindow](LehrerApp.Desktop/Views/DbPasswordPromptWindow.axaml)) das
|
||||||
|
Passwort ab, **bevor** `AppBootstrapper.BuildServices()` (und damit das Öffnen der
|
||||||
|
Datenbank) läuft — siehe `App.axaml.cs`. Ändern/Entfernen des Passworts läuft über
|
||||||
|
Einstellungen → Sicherheit, jeweils mit Neustart der App danach. 4 Tests in
|
||||||
|
[DatabaseEncryptionServiceTests.cs](LehrerApp.Data.Tests/DatabaseEncryptionServiceTests.cs).
|
||||||
|
- [x] **13.3.5** App-Sperre nach Inaktivität —
|
||||||
|
[AppLockService.cs](LehrerApp.Core/Services/AppLockService.cs) (eigenes, gesalzenes
|
||||||
|
PBKDF2-Passwort, unabhängig von einer eventuellen Datenbank-Verschlüsselung) +
|
||||||
|
[AppLockViewModel.cs](LehrerApp.Desktop/ViewModels/AppLockViewModel.cs) (Inaktivitäts-Timer,
|
||||||
|
Sperrbildschirm-Logik). Maus-/Tastatureingaben im Hauptfenster setzen den Timer zurück
|
||||||
|
([MainWindow.axaml.cs](LehrerApp.Desktop/Views/MainWindow.axaml.cs)); läuft er ab, blendet
|
||||||
|
ein Overlay in [MainWindow.axaml](LehrerApp.Desktop/Views/MainWindow.axaml) die gesamte
|
||||||
|
Oberfläche aus, bis das Passwort stimmt. Einstellbar (aktiv/inaktiv, Zeit, Passwort) über
|
||||||
|
Einstellungen → Sicherheit, wirkt sofort ohne Neustart. 2 Tests in
|
||||||
|
[AppLockViewModelTests.cs](LehrerApp.Desktop.Tests/AppLockViewModelTests.cs) (reine
|
||||||
|
Unlock-Logik; der zeitbasierte Inaktivitäts-Timer selbst ist nicht automatisiert getestet).
|
||||||
|
**Hinweis:** `RestartApplication()` startet den Prozess über `Environment.ProcessPath`
|
||||||
|
neu — im veröffentlichten Programm korrekt, im Entwicklungsbetrieb über `dotnet run`
|
||||||
|
zeigt `ProcessPath` auf den `dotnet`-Host statt auf die App, ein Neustart über die
|
||||||
|
Einstellungen ist dort also nur in einer veröffentlichten Build (`dotnet publish`)
|
||||||
|
sinnvoll zu testen.
|
||||||
|
|
||||||
### 13.4 Codepflege
|
### 13.4 Codepflege
|
||||||
- [ ] **13.4.1** `CLAUDE.md` mit Projektkonventionen anlegen (`/init`).
|
- [ ] **13.4.1** `CLAUDE.md` mit Projektkonventionen anlegen (`/init`).
|
||||||
|
|||||||
Reference in New Issue
Block a user