diff --git a/.env.example b/.env.example
index f5458fb..6d56454 100644
--- a/.env.example
+++ b/.env.example
@@ -6,3 +6,6 @@ COOKIE_SECURE=false
UPLOAD_DIR=./uploads
# Only enable behind a trusted proxy that overwrites X-Forwarded-For.
TRUST_PROXY=false
+ADMIN_EMAIL=sascha@nesterovic.cc
+# Generate once with: openssl rand -hex 32. Keep this key with your backups.
+AUTH_ENCRYPTION_KEY=
diff --git a/README.md b/README.md
index 68df1df..b9f8942 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ Für HTTPS: `APP_ORIGIN=https://kochen.example.org` und `COOKIE_SECURE=true`. De
- Lokale Bilder inklusive Re-Encoding, Einkaufsliste mit Mengenzusammenführung, Favoriten und Drucklayout.
- Helles/dunkles Design, lokale Schriftdateien, installierbare PWA und ehrliches Offline-Fallback.
- Argon2id, zufällige serverseitige Sessions, HTTP-only-Cookies, Origin-CSRF-Prüfung, persistente Loginlimits und Sicherheitsheader.
+- Optionale Admin-2FA mit Authenticator-App und Wiederherstellungscodes; SMTP-Einstellungen, Testmail und zuschaltbare Login-Mails mit Zeitpunkt, gemeldeter IP und Browser/Gerät.
## Rezepte mit ChatGPT erstellen
diff --git a/docker-compose.yml b/docker-compose.yml
index 66abb06..9593374 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -21,6 +21,8 @@ services:
APP_ORIGIN: ${APP_ORIGIN:-http://localhost:3000}
COOKIE_SECURE: ${COOKIE_SECURE:-false}
TRUST_PROXY: ${TRUST_PROXY:-false}
+ AUTH_ENCRYPTION_KEY: ${AUTH_ENCRYPTION_KEY:-}
+ ADMIN_EMAIL: ${ADMIN_EMAIL:-}
UPLOAD_DIR: /app/uploads
ports:
- '${HOST_PORT:-3000}:3000'
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index 8c9dd42..adea6c5 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -33,6 +33,18 @@ App läuft als unprivilegierter Benutzer `node`, PID-1-Verwaltung über `tini`.
Session-Cookie: HTTP-only, SameSite Strict, 7 Tage absolute Gültigkeit, optional Secure. Datenbank enthält ausschließlich den SHA-256-Hash des zufälligen 256-Bit-Tokens. Passwortwechsel widerruft alle bisherigen Sessions. Origin-Prüfung gilt auch für Login und Uploads.
+## Admin-2FA und Mail
+
+In `.env` `ADMIN_EMAIL` auf die Admin-Adresse setzen. Mit `openssl rand -hex 32` einen **separaten, dauerhaften** `AUTH_ENCRYPTION_KEY` erzeugen und ebenfalls eintragen. Nach Änderungen `docker compose up -d app` ausführen. Der Schlüssel verschlüsselt Authenticator-Geheimnisse und SMTP-Passwörter mit AES-256-GCM; er darf bei Updates nicht neu erzeugt werden und gehört in das geschützte Konfigurationsbackup. Ohne den ursprünglichen Schlüssel lassen sich vorhandene Geheimnisse nicht entschlüsseln.
+
+Unter **Administration → Einstellungen → Zwei-Faktor-Anmeldung** das aktuelle Passwort eingeben, QR-Code mit einer TOTP-App scannen und den sechsstelligen Code bestätigen. Erst dann wird 2FA aktiviert. Die zehn Wiederherstellungscodes werden nur einmal angezeigt; herunterladen und sicher aufbewahren. Jeder Code ersetzt einmal den App-Code. Neue Codes machen die bisherigen ungültig. Aktivierung, Deaktivierung und Code-Neugenerierung beenden andere Sitzungen. Für sicherheitsrelevante Änderungen werden Passwort und bei aktiver 2FA ein frischer App- oder Wiederherstellungscode benötigt.
+
+Nach der Passwort-Anmeldung gilt die zweite Stufe fünf Minuten und erlaubt höchstens fünf Fehlversuche. App-Codes gelten im 30-Sekunden-Takt mit einer Periode Toleranz und können nur einmal verwendet werden. Server und Smartphone sollten die Uhrzeit automatisch synchronisieren. Bei Verlust des Telefons einen Wiederherstellungscode verwenden; sind beide verloren, kann der Serverbetreiber über das bestehende Admin-CLI einen weiteren Zugang anlegen.
+
+Unter **Mail & Benachrichtigungen** Admin-Adresse, SMTP-Host, Port, TLS-Verfahren, Benutzername, Passwort und Absender hinterlegen. Unterstützt werden implizites TLS (üblicherweise 465) und verpflichtendes STARTTLS (üblicherweise 587). Leeres SMTP-Passwort behält das gespeicherte Passwort bei; zum Löschen dient die eigene Checkbox. Anschließend speichern und **Testmail senden**. Die Option **E-Mail bei erfolgreicher Anmeldung** aktiviert Login-Benachrichtigungen. Ein Versandfehler sperrt die Anmeldung nicht; der Status ist im Loginverlauf sichtbar.
+
+Benachrichtigungen enthalten Zeitpunkt (UTC), gemeldete IP-Adresse und User-Agent, keine geografische Ortung. Ohne `TRUST_PROXY=true` werden Proxy-IP-Angaben ausdrücklich als nicht verifiziert gekennzeichnet. Die rechte Adresse aus `X-Forwarded-For` wird verwendet; die oben beschriebenen Proxy-Voraussetzungen bleiben erforderlich. Der Loginverlauf speichert erfolgreiche Anmeldungen für bis zu 90 Tage (Bereinigung bei der nächsten Anmeldung), angezeigt werden die letzten 20. SMTP-Zugang und 2FA-Geheimnisse werden nicht über die Einstellungs-API zurückgegeben.
+
## Backup
Ein konsistentes Backup umfasst Datenbank **und** Bilder. Für ein kleines Haushaltskochbuch ist eine kurze Schreibpause am einfachsten:
diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md
index 5b8337a..acb8158 100644
--- a/docs/VERIFICATION.md
+++ b/docs/VERIFICATION.md
@@ -81,6 +81,15 @@ Nach der gestalterischen Überarbeitung wurden TypeScript-Prüfung und Produktio
Der mobile Überlauf wurde am veröffentlichten Pljeskavica-Rezept reproduziert: lange Bildquellen-URLs in den Tipps vergrößerten die minimale Grid-Spaltenbreite und damit auch die Zutatenliste. Die Spalte verwendet nun `minmax(0, 1fr)`, ihre Kinder dürfen schrumpfen und lange Texte umbrechen. Mit dem echten Rezept und dem korrigierten Stylesheet wurden 320, 360, 390, 430 und 768 Pixel Breite ohne Überlauf geprüft. Produktionsbuild und alle 15 öffentlichen Browserabläufe bestanden. Der Regressionstest enthält eine lange Quellen-URL und vergleicht die Inhaltsbreite mit `document.documentElement.clientWidth`; `innerWidth` kann sich auf mobilen Browsern selbst mit dem Überlauf vergrößern und ist dafür ungeeignet.
+### Nachprüfung: Admin-2FA und Mail (25. Oktober 2026)
+
+- Formatprüfung, TypeScript und Produktionsbuild erfolgreich; **55/55 Unit-Tests** bestanden, darunter RFC-6238-Vektoren, Zeitfenster, Replay-Schutz, verschlüsselte Geheimnisse und Wiederherstellungscodes.
+- **6/6 betroffene Browser-/API-Abläufe** gegen eine isolierte PostgreSQL-17-Testdatenbank auf dem Server bestanden. Docker und PostgreSQL auf dem Entwicklungs-Mac blieben ausgeschaltet.
+- Authenticator-Einrichtung per QR-Code, Passwort-/2FA-Stufen, CSRF, Sitzungswiderruf, fünf Fehlversuche, abgelaufene Challenges, gleichzeitige Verwendung desselben Wiederherstellungscodes und Code-Neugenerierung geprüft.
+- Echte SMTP-Zustellung mit TLS und Authentifizierung an einen Testserver; Testmail, Login-Herkunft, Opt-out, Passwortbeibehaltung, Geheimnisverschlüsselung und fehlende Geheimnisse in API-Antworten geprüft. Ein absichtlicher SMTP-Verbindungsfehler verhindert die Anmeldung nicht.
+- Einstellungen auf Desktop und bei 390 Pixel Breite visuell kontrolliert; kein horizontaler Überlauf. Screenshots liegen in den ignorierten Playwright-Artefakten.
+- Die Ziel-Mailbox benötigt noch vom Betreiber eingetragene SMTP-Zugangsdaten. 2FA wird vom Admin persönlich durch Scannen und Codebestätigung aktiviert.
+
## Präzise Grenzen
- Kein Test auf physischem Smartphone, Safari oder Firefox; keine tatsächliche iOS-/Android-Installation über das Betriebssystem-Installationsmenü.
diff --git a/migrations/002_admin_security.sql b/migrations/002_admin_security.sql
new file mode 100644
index 0000000..8dc2f65
--- /dev/null
+++ b/migrations/002_admin_security.sql
@@ -0,0 +1,31 @@
+CREATE TABLE admin_security (
+ admin_id uuid PRIMARY KEY REFERENCES admins(id) ON DELETE CASCADE,
+ email text NOT NULL DEFAULT '',
+ login_notifications boolean NOT NULL DEFAULT false,
+ totp_secret text,
+ pending_secret text,
+ pending_expires timestamptz,
+ last_counter bigint NOT NULL DEFAULT -1,
+ recovery_hashes jsonb NOT NULL DEFAULT '[]'
+);
+CREATE TABLE login_challenges (
+ token text PRIMARY KEY,
+ admin_id uuid NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
+ expires timestamptz NOT NULL,
+ attempts integer NOT NULL DEFAULT 0,
+ context jsonb NOT NULL
+);
+CREATE TABLE mail_config (
+ id integer PRIMARY KEY CHECK (id = 1),
+ data jsonb NOT NULL
+);
+CREATE TABLE login_events (
+ id bigserial PRIMARY KEY,
+ admin_id uuid NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ context jsonb NOT NULL,
+ factor text NOT NULL,
+ mail_status text NOT NULL DEFAULT 'disabled'
+);
+CREATE INDEX login_events_admin_time ON login_events(admin_id, created_at DESC);
+CREATE INDEX login_challenges_expires ON login_challenges(expires);
diff --git a/package-lock.json b/package-lock.json
index 5002a1a..6f8ba25 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,10 @@
"lucide-react": "^1.61.0",
"motion": "^14.6.0",
"next": "^16.4.3",
+ "nodemailer": "^10.2.1",
+ "otpauth": "^9.5.2",
"postgres": "^3.4.9",
+ "qrcode": "^1.5.4",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"sharp": "^0.35.5",
@@ -28,9 +31,13 @@
"devDependencies": {
"@playwright/test": "^1.64.1",
"@types/node": "^26.6.5",
+ "@types/nodemailer": "^8.0.2",
+ "@types/qrcode": "^1.5.6",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
+ "@types/smtp-server": "^3.5.13",
"prettier": "^3.9.10",
+ "smtp-server": "^3.21.1",
"typescript": "^7.0.2",
"vitest": "^5.0.5"
}
@@ -1201,6 +1208,18 @@
"node": ">= 10"
}
},
+ "node_modules/@noble/hashes": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
+ "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@nodable/entities": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz",
@@ -1824,6 +1843,26 @@
"undici-types": "~8.9.0"
}
},
+ "node_modules/@types/nodemailer": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz",
+ "integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/qrcode": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
+ "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
@@ -1844,6 +1883,17 @@
"@types/react": "^19.3.0"
}
},
+ "node_modules/@types/smtp-server": {
+ "version": "3.5.13",
+ "resolved": "https://registry.npmjs.org/@types/smtp-server/-/smtp-server-3.5.13.tgz",
+ "integrity": "sha512-S3rGl2KbViH+98/CgHipPIWgtAFnjxLsppxIRbgJHNuZL7Y+py+7kZjT7xS+wOmCxYTn4O7IqLqkvekg99MDVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "@types/nodemailer": "*"
+ }
+ },
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
@@ -2222,6 +2272,30 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
@@ -2256,6 +2330,15 @@
"node": ">=6.0.0"
}
},
+ "node_modules/camelcase": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+ "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001819",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz",
@@ -2292,6 +2375,35 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
+ "node_modules/cliui": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+ "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^6.2.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2299,6 +2411,15 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/decamelize": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+ "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
@@ -2314,6 +2435,12 @@
"node": ">=8"
}
},
+ "node_modules/dijkstrajs": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+ "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+ "license": "MIT"
+ },
"node_modules/dotenv": {
"version": "18.0.9",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.9.tgz",
@@ -2458,6 +2585,12 @@
}
}
},
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
@@ -2583,6 +2716,19 @@
}
}
},
+ "node_modules/find-up": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+ "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^5.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/framer-motion": {
"version": "14.6.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.6.0.tgz",
@@ -2620,6 +2766,31 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/ipv6-normalize": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz",
+ "integrity": "sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/is-unsafe": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
@@ -2917,6 +3088,18 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/locate-path": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+ "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/lucide-react": {
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
@@ -3050,6 +3233,15 @@
}
}
},
+ "node_modules/nodemailer": {
+ "version": "10.2.1",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.1.tgz",
+ "integrity": "sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/obug": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz",
@@ -3064,6 +3256,63 @@
"node": ">=12.20.0"
}
},
+ "node_modules/otpauth": {
+ "version": "9.5.2",
+ "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.2.tgz",
+ "integrity": "sha512-GQ5emWR/x1tcExT62IBT0UfO95wZzJZyxYOJOGVeQF47SYEN9vmh0vISvDZaNMuFJRG+IaWCKtfm+t9Bfoal6w==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "2.4.0"
+ },
+ "funding": {
+ "url": "https://github.com/hectorm/otpauth?sponsor=1"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+ "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+ "license": "MIT",
+ "dependencies": {
+ "p-try": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+ "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-try": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
@@ -3127,6 +3376,15 @@
"node": ">=20"
}
},
+ "node_modules/pngjs": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+ "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
@@ -3184,6 +3442,33 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/qrcode": {
+ "version": "1.5.4",
+ "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+ "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+ "license": "MIT",
+ "dependencies": {
+ "dijkstrajs": "^1.0.1",
+ "pngjs": "^5.0.0",
+ "yargs": "^15.3.1"
+ },
+ "bin": {
+ "qrcode": "bin/qrcode"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/react": {
"version": "19.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
@@ -3205,6 +3490,21 @@
"react": "^19.3.1"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/require-main-filename": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+ "license": "ISC"
+ },
"node_modules/rolldown": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
@@ -3258,6 +3558,12 @@
"node": ">=10"
}
},
+ "node_modules/set-blocking": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+ "license": "ISC"
+ },
"node_modules/sharp": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
@@ -3307,6 +3613,21 @@
}
}
},
+ "node_modules/smtp-server": {
+ "version": "3.21.1",
+ "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.21.1.tgz",
+ "integrity": "sha512-gj95f6YhcL0uu3m3FvRnQ6LSotursugAuVjK5V4f0f2C/4OSVpn/+h28F4pPJFob/myli9V+F7/Hxx9DJVdwvg==",
+ "dev": true,
+ "license": "MIT-0",
+ "dependencies": {
+ "ipv6-normalize": "1.0.1",
+ "nodemailer": "10.2.1",
+ "punycode.js": "2.3.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
@@ -3323,6 +3644,32 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strnum": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz",
@@ -3656,6 +4003,12 @@
}
}
},
+ "node_modules/which-module": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+ "license": "ISC"
+ },
"node_modules/why-is-node-running": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
@@ -3669,6 +4022,20 @@
"node": ">=20.11"
}
},
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
@@ -3684,6 +4051,47 @@
"node": ">=16.0.0"
}
},
+ "node_modules/y18n": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+ "license": "ISC"
+ },
+ "node_modules/yargs": {
+ "version": "15.4.1",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+ "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^6.0.0",
+ "decamelize": "^1.2.0",
+ "find-up": "^4.1.0",
+ "get-caller-file": "^2.0.1",
+ "require-directory": "^2.1.1",
+ "require-main-filename": "^2.0.0",
+ "set-blocking": "^2.0.0",
+ "string-width": "^4.2.0",
+ "which-module": "^2.0.0",
+ "y18n": "^4.0.0",
+ "yargs-parser": "^18.1.2"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "18.1.3",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+ "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+ "license": "ISC",
+ "dependencies": {
+ "camelcase": "^5.0.0",
+ "decamelize": "^1.2.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/zod": {
"version": "4.6.7",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
diff --git a/package.json b/package.json
index 3ed7cd8..50da49a 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,10 @@
"lucide-react": "^1.61.0",
"motion": "^14.6.0",
"next": "^16.4.3",
+ "nodemailer": "^10.2.1",
+ "otpauth": "^9.5.2",
"postgres": "^3.4.9",
+ "qrcode": "^1.5.4",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"sharp": "^0.35.5",
@@ -38,9 +41,13 @@
"devDependencies": {
"@playwright/test": "^1.64.1",
"@types/node": "^26.6.5",
+ "@types/nodemailer": "^8.0.2",
+ "@types/qrcode": "^1.5.6",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
+ "@types/smtp-server": "^3.5.13",
"prettier": "^3.9.10",
+ "smtp-server": "^3.21.1",
"typescript": "^7.0.2",
"vitest": "^5.0.5"
}
diff --git a/playwright.config.ts b/playwright.config.ts
index 37fb5c3..cd80d28 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,5 +1,34 @@
import { defineConfig, devices } from '@playwright/test';
import { randomBytes } from 'node:crypto';
+import { execFileSync } from 'node:child_process';
+import { existsSync, mkdirSync, mkdtempSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+process.env.TEST_TLS_DIR ??= mkdtempSync(join(tmpdir(), 'hamham-tls-'));
+mkdirSync(process.env.TEST_TLS_DIR, { recursive: true });
+const testCert = join(process.env.TEST_TLS_DIR, 'cert.pem');
+if (!existsSync(testCert))
+ execFileSync(
+ 'openssl',
+ [
+ 'req',
+ '-x509',
+ '-newkey',
+ 'rsa:2048',
+ '-nodes',
+ '-days',
+ '1',
+ '-keyout',
+ join(process.env.TEST_TLS_DIR, 'key.pem'),
+ '-out',
+ testCert,
+ '-subj',
+ '/CN=localhost',
+ '-addext',
+ 'subjectAltName=DNS:localhost,IP:127.0.0.1',
+ ],
+ { stdio: 'ignore' },
+ );
process.env.TEST_PASSWORD ??= randomBytes(24).toString('base64url');
process.env.TEST_DATABASE_URL ??= `postgres://${process.env.USER ?? 'postgres'}@localhost:5432/hamham_test`;
export default defineConfig({
@@ -24,6 +53,9 @@ export default defineConfig({
DATABASE_URL: process.env.TEST_DATABASE_URL,
APP_ORIGIN: 'http://localhost:3100',
COOKIE_SECURE: 'false',
+ AUTH_ENCRYPTION_KEY: '12'.repeat(32),
+ ADMIN_EMAIL: 'admin@example.org',
+ NODE_EXTRA_CA_CERTS: testCert,
UPLOAD_DIR: './test-results/uploads',
NEXT_TELEMETRY_DISABLED: '1',
},
diff --git a/src/app/api/[...path]/route.ts b/src/app/api/[...path]/route.ts
index 61836a4..db667c8 100644
--- a/src/app/api/[...path]/route.ts
+++ b/src/app/api/[...path]/route.ts
@@ -21,6 +21,8 @@ import { parseImport, exportRecipes } from '@/lib/import';
import { mkdir, writeFile, unlink } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
+import { beginLogin, finishLogin, reauthenticate, securityRoute } from '@/lib/admin-security';
+import { loginContext } from '@/lib/login-context';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
type Context = { params: Promise<{ path: string[] }> };
@@ -43,9 +45,7 @@ async function handle(req: Request, ctx: Context) {
if (route === 'auth/login' && method === 'POST') {
const body = loginSchema.parse(await jsonBody(req));
const ip =
- process.env.TRUST_PROXY === 'true'
- ? (req.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? 'unknown')
- : 'direct';
+ process.env.TRUST_PROXY === 'true' ? (loginContext(req).ip ?? 'unknown') : 'direct';
await rateLimit(`ip:${digest(ip)}`, 50);
await rateLimit(`user:${digest(body.username)}`, 8);
const [user] = await db.select().from(admins).where(eq(admins.username, body.username));
@@ -56,15 +56,18 @@ async function handle(req: Request, ctx: Context) {
if (!user || !valid) throw new ApiError(401, 'Benutzername oder Passwort ist falsch.');
await sql`DELETE FROM sessions WHERE expires < now()`;
await sql`DELETE FROM login_limits WHERE reset < now()`;
- await newSession(user.id);
- return Response.json({ username: user.username });
+ return await beginLogin(user, req);
}
+ if (route === 'auth/verify' && method === 'POST') return await finishLogin(req);
if (route === 'auth/me' && method === 'GET') return Response.json({ user: await admin() });
if (route === 'auth/logout' && method === 'POST') {
const c = await cookies(),
token = c.get('hamham_session')?.value;
if (token) await db.delete(sessions).where(eq(sessions.token, digest(token)));
c.delete('hamham_session');
+ const challenge = c.get('hamham_login_challenge')?.value;
+ if (challenge) await sql`DELETE FROM login_challenges WHERE token = ${digest(challenge)}`;
+ c.delete('hamham_login_challenge');
return Response.json({ ok: true });
}
if (route === 'recipes' && method === 'GET')
@@ -77,14 +80,18 @@ async function handle(req: Request, ctx: Context) {
return Response.json(recipe);
}
const user = await requireAdmin();
+ const securityResponse = await securityRoute(req, route, user);
+ if (securityResponse) return securityResponse;
if (route === 'auth/password' && method === 'POST') {
const body = z
- .object({ current: z.string().max(200), password: z.string().min(12).max(200) })
+ .object({
+ current: z.string().max(200),
+ password: z.string().min(12).max(200),
+ code: z.string().trim().max(64).default(''),
+ })
.parse(await jsonBody(req));
await rateLimit(`password:${user.id}`, 8);
- const [row] = await db.select().from(admins).where(eq(admins.id, user.id));
- if (!(await verify(row.password, body.current)))
- throw new ApiError(400, 'Aktuelles Passwort ist falsch.');
+ await reauthenticate(user.id, body.current, body.code);
const password = await hash(body.password, {
algorithm: 2,
memoryCost: 65536,
@@ -95,6 +102,8 @@ async function handle(req: Request, ctx: Context) {
await tx.update(admins).set({ password }).where(eq(admins.id, user.id));
await tx.delete(sessions).where(eq(sessions.adminId, user.id));
});
+ await sql`DELETE FROM login_challenges WHERE admin_id = ${user.id}`;
+ await sql`UPDATE admin_security SET pending_secret = NULL, pending_expires = NULL WHERE admin_id = ${user.id}`;
await newSession(user.id);
return Response.json({ ok: true });
}
diff --git a/src/components/admin.tsx b/src/components/admin.tsx
index 06c0451..66c5bb6 100644
--- a/src/components/admin.tsx
+++ b/src/components/admin.tsx
@@ -22,9 +22,11 @@ import { ingredientLabel } from '@/lib/calculator';
import { RecipeImage } from './recipe-image';
import { StepInstructions, StepImages } from './step-content';
import { Dialog } from './dialog';
+import { SecuritySettings } from './security-settings';
export function Login() {
const router = useRouter(),
[error, setError] = useState(''),
+ [secondFactor, setSecondFactor] = useState(false),
[busy, setBusy] = useState(false);
return (
@@ -49,9 +51,19 @@ export function Login() {
e.preventDefault();
setBusy(true);
setError('');
- const data = new FormData(e.currentTarget);
+ const form = e.currentTarget;
+ const data = new FormData(form);
try {
- await api('auth/login', 'POST', Object.fromEntries(data));
+ const result = await api<{ twoFactorRequired?: boolean }>(
+ secondFactor ? 'auth/verify' : 'auth/login',
+ 'POST',
+ Object.fromEntries(data),
+ );
+ if (result.twoFactorRequired) {
+ form.reset();
+ setSecondFactor(true);
+ return;
+ }
router.refresh();
} catch (e) {
setError((e as Error).message);
@@ -64,15 +76,28 @@ export function Login() {
Willkommen zurück.
-
Melde dich an, um eure Sammlung zu pflegen.
-
-
+
+ {secondFactor
+ ? 'Gib den Code deiner Authenticator-App oder einen Wiederherstellungscode ein.'
+ : 'Melde dich an, um eure Sammlung zu pflegen.'}
+
Erster Besuch? Erstelle den Administrator mit der HamHam-CLI. Eine öffentliche
Registrierung gibt es nicht.
@@ -505,6 +543,15 @@ function SettingsPanel() {
Mindestens 12 Zeichen. Alle anderen aktiven Sitzungen werden abgemeldet.
+ {data.enabled
+ ? `Aktiv · ${data.recoveryRemaining} Wiederherstellungscodes übrig.`
+ : 'Noch nicht aktiviert. Verbinde deine Authenticator-App, z. B. Apple Passwörter, 1Password oder Google Authenticator.'}
+
+ {!data.encryptionReady && (
+
+ Der Server benötigt einen AUTH_ENCRYPTION_KEY, bevor 2FA eingerichtet werden kann.
+
+ Diese Codes werden nur jetzt angezeigt. Jeder ersetzt einmal einen App-Code. Bewahre
+ sie getrennt von deinem Passwort auf. Frühere Codes sind ungültig.
+
+
+ {codes.map((c) => (
+
+ {c}
+
+ ))}
+
+
+
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
Letzte Admin-Anmeldungen
+
+
+
+ Erfolgreiche Anmeldungen der letzten 90 Tage, maximal 20 Einträge. IP-Adressen können zu
+ einem VPN oder Proxy gehören; ein genauer Standort wird nicht ermittelt.
+