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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user