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:
2026-08-13 13:07:36 +02:00
co-authored by Claude Sonnet 5
parent db1afff7e4
commit 48d07a2b99
25 changed files with 1228 additions and 21 deletions
@@ -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 ObservableCollection<ToastItem> 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]
@@ -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<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 ───────────────────────────────────────────────────────────
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