feat: 2FA, Passwortrichtlinie und wiederherstellbares JSON-Backup

Zwei-Faktor per TOTP mit zehn Einmal-Wiederherstellungscodes. Der
Zwischenzustand "Passwort stimmt, Code fehlt" haengt an der vorhandenen
Sitzungstabelle (pending_2fa, zehn Minuten Laufzeit) und gilt nirgends als
angemeldet; nach dem Code wird das Cookie getauscht statt weiterverwendet.
Der Wiederverwendungsschutz merkt sich das tatsaechlich getroffene
Zeitfenster, nicht die aktuelle Uhrzeit - sonst liesse sich ein Code aus dem
naechsten Fenster gleich darauf nochmal einloesen.

Passwortrichtlinie (Laenge, Zeichenklassen, MFA-Pflicht) in der neuen
globalen Tabelle app_settings, durchgesetzt beim Anlegen, Zuruecksetzen und
Aendern von Passwoertern. Bestehende Passwoerter lassen sich nicht gegen eine
neue Richtlinie pruefen - der Hash gibt nichts her. Beim Anmelden liegt das
Passwort aber kurz im Klartext vor: dort wird geprueft und bei Verstoss ein
Wechsel verlangt, mit passendem Grund fuer den Hinweistext.

Nebenbei geschlossen: must_change_password war bisher nur im Frontend
erzwungen, wer die API direkt ansprach kam vorbei. enforce_account_state
haengt jetzt am /api-Sammelrouter und laesst nur noch durch, was man zum
Erfuellen der Pflicht braucht.

Das JSON-Backup liess sich bisher grundsaetzlich nicht zurueckladen: keine
Version, Kategorien nur ueber mehrdeutige Namen, sieben Tabellen fehlten.
Daneben steht nun ein Format v2, das Beziehungen ueber lokale IDs abbildet,
je Benutzer oder systemweit. Zurueckgeladen wird nach einer Pruefansicht,
wahlweise ersetzend oder ergaenzend; vor dem Ersetzen legt der Server einen
Schnappschuss der Datenbank an. Der alte Ansichts-Export bleibt fuer
Tabellenkalkulationen bestehen und wird beim Hochladen ausdruecklich
abgewiesen, statt halb zu raten.

Frontend: wiederverwendbares Passwortfeld mit Augensymbol, animierte
Anforderungsliste, 2FA-Seite, Admin-Karte fuer die Richtlinie und Karte zum
Zurueckladen. Neue Bewegung ist in prefers-reduced-motion hinterlegt; Secret
und Codes sind markierbar, sonst verhinderte das globale user-select das
Kopieren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-20 10:11:22 +02:00
co-authored by Claude Opus 5
parent 1d5b23c127
commit 7fc8763d57
40 changed files with 3539 additions and 117 deletions
+93 -3
View File
@@ -40,7 +40,8 @@
"repeat": "Repeat new password",
"submit": "Save password",
"mismatch": "The new passwords do not match.",
"saved": "Password changed."
"saved": "Password changed.",
"policyHint": "The password policy has changed. Your current password no longer meets it – please choose a new one."
},
"users": {
"title": "Users",
@@ -56,7 +57,12 @@
"resetPassword": "Reset password",
"deleteConfirm": "Really delete this user?",
"created": "User created.",
"you": "(you)"
"you": "(you)",
"twoFactor": "Two-factor",
"resetTwoFactor": "Reset two-factor",
"resetTwoFactorConfirm": "Turn off two-factor for “{{name}}”? Only needed if both device and recovery codes are lost.",
"twoFactorReset": "Two-factor reset.",
"generatePassword": "Generate password"
},
"settings": {
"title": "Settings",
@@ -84,7 +90,14 @@
"keywordReviewHint": "These keywords keep getting corrected away — maybe they no longer fit?",
"keywordCorrected": "{{overridden}} of {{applied}} transactions corrected",
"keep": "Keep",
"remove": "Remove"
"remove": "Remove",
"security": "Security",
"securityHint": "Two-factor authentication for your own account.",
"twoFactor": "Manage two-factor",
"backupHint": "The full backup contains all of your data and can be restored below.",
"exportBackup": "Full backup (JSON)",
"systemBackup": "System-wide full backup (JSON)",
"systemBackupWarning": "The system-wide backup contains password hashes and two-factor secrets of every user. Treat the file like a password."
},
"dashboard": {
"title": "Overview",
@@ -280,5 +293,82 @@
"undone": "undone",
"needFile": "Please choose a CSV file first.",
"needFileAccount": "Please choose an account and a file."
},
"password": {
"show": "Show password",
"hide": "Hide password",
"met": "(met)",
"unmet": "(not met)",
"ruleLength": "At least {{n}} characters",
"ruleUpper": "At least one uppercase letter",
"ruleLower": "At least one lowercase letter",
"ruleDigit": "At least one digit",
"ruleSpecial": "At least one special character"
},
"twofactor": {
"title": "Two-factor authentication",
"intro": "A one-time code from an authenticator app on top of your password. Knowing your password alone is no longer enough.",
"on": "Active",
"off": "Not set up",
"start": "Set up",
"scan": "Scan the code with your authenticator app (e.g. Aegis, 1Password, Google Authenticator).",
"qrAlt": "QR code for setting up the authenticator app",
"manual": "If scanning does not work, enter this key manually:",
"code": "Code from the app",
"codeHint": "Enter the six-digit code from your authenticator app.",
"activate": "Activate",
"verify": "Confirm",
"useRecovery": "Use a recovery code",
"useApp": "Use the app code instead",
"recoveryCode": "Recovery code",
"recoveryHint": "Enter one of your recovery codes. Each one works only once.",
"codesTitle": "Recovery codes",
"codesHint": "Keep these codes somewhere safe – printed, for example. They are the only way back in if you lose your device. Each code works exactly once and they are shown here only this one time.",
"codesSaved": "I have saved them",
"codesLeft": "{{n}} unused recovery codes left.",
"copy": "Copy",
"copied": "Copied to clipboard.",
"download": "Save as file",
"newCodes": "Generate new codes",
"newCodesTitle": "New recovery codes",
"newCodesHint": "The previous codes stop working. Enter your password to confirm.",
"disable": "Turn off two-factor",
"disableTitle": "Turn off two-factor",
"disableHint": "Your password alone will be enough again. Enter your password to confirm.",
"disabled": "Two-factor turned off.",
"requiredHint": "The policy requires two-factor. The rest of the application stays locked until you set it up."
},
"policy": {
"title": "Password policy",
"hint": "Applies to every user of this instance.",
"minLength": "Minimum length",
"require_upper": "Require an uppercase letter",
"require_lower": "Require a lowercase letter",
"require_digit": "Require a digit",
"require_special": "Require a special character",
"mfaRequired": "Require two-factor for everyone",
"mfaHint": "Users without a second factor must set one up at their next sign-in.",
"tightenWarning": "Tightening: users whose password no longer meets the policy will have to change it at their next sign-in.",
"mfaWarning": "Every user without two-factor will be taken to the setup at their next sign-in – including you."
},
"restore": {
"title": "Restore a backup",
"hint": "Pick a backup file. It is only inspected first; nothing is written until you confirm.",
"file": "Backup file",
"empty": "The file contains no data.",
"scopeUser": "Backup of the data of “{{name}}”.",
"scopeSystem": "System-wide backup with {{n}} users.",
"exportedAt": "Created on {{when}}",
"mode": "How to proceed",
"modeReplace": "Replace",
"modeMerge": "Add missing",
"modeReplaceHint": "Existing data is deleted and replaced by the backup. The server writes a database snapshot beforehand.",
"modeMergeHint": "Nothing is deleted. Only missing entries are added; budget, rules and settings stay untouched if anything is already there.",
"start": "Restore",
"confirmTitle": "Really restore?",
"confirmReplace": "All your accounts, transactions, categories and settings will be deleted and replaced by the backup.",
"confirmSystem": "All users and their data will be deleted and replaced by the backup. Every session ends afterwards.",
"done": "Restore finished.",
"doneSystem": "Restore finished. Please sign in again."
}
}