From 52cc8651409cd5bb1a539bf9590492b048cb3614 Mon Sep 17 00:00:00 2001 From: Sascha Nesterovic Date: Sun, 25 Oct 2026 10:36:15 +0100 Subject: [PATCH] feat: add admin authenticator 2FA and configurable login email alerts --- .env.example | 3 + README.md | 1 + docker-compose.yml | 2 + docs/DEPLOYMENT.md | 12 + docs/VERIFICATION.md | 9 + migrations/002_admin_security.sql | 31 ++ package-lock.json | 408 ++++++++++++++++++++++++ package.json | 7 + playwright.config.ts | 32 ++ src/app/api/[...path]/route.ts | 27 +- src/components/admin.tsx | 70 ++++- src/components/security-settings.css | 71 +++++ src/components/security-settings.tsx | 444 +++++++++++++++++++++++++++ src/lib/admin-security.ts | 333 ++++++++++++++++++++ src/lib/auth-crypto.ts | 65 ++++ src/lib/login-context.ts | 15 + src/lib/mail.ts | 48 +++ tests/e2e/admin.spec.ts | 5 + tests/e2e/mail.spec.ts | 118 +++++++ tests/e2e/security.spec.ts | 203 ++++++++++++ tests/unit/auth-crypto.test.ts | 77 +++++ 21 files changed, 1961 insertions(+), 20 deletions(-) create mode 100644 migrations/002_admin_security.sql create mode 100644 src/components/security-settings.css create mode 100644 src/components/security-settings.tsx create mode 100644 src/lib/admin-security.ts create mode 100644 src/lib/auth-crypto.ts create mode 100644 src/lib/login-context.ts create mode 100644 src/lib/mail.ts create mode 100644 tests/e2e/mail.spec.ts create mode 100644 tests/e2e/security.spec.ts create mode 100644 tests/unit/auth-crypto.test.ts 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.'} +

+ {secondFactor ? ( + + ) : ( +
+ + +
+ )} {error && (

{error} @@ -82,6 +107,19 @@ export function Login() { {busy ? 'Wird angemeldet …' : 'Anmelden'} + {secondFactor && ( + + )}

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.

+ {error && (

@@ -512,6 +559,7 @@ function SettingsPanel() {

)} +
); } diff --git a/src/components/security-settings.css b/src/components/security-settings.css new file mode 100644 index 0000000..b971a38 --- /dev/null +++ b/src/components/security-settings.css @@ -0,0 +1,71 @@ +.settings-grid > .panel { + min-width: 0; +} +.security-panel { + overflow-wrap: anywhere; +} +.security-panel h2 { + display: flex; + align-items: center; + gap: 10px; +} +.security-panel .button-row { + flex-wrap: wrap; +} +.security-panel label small { + display: block; + color: var(--muted); + font-weight: normal; + line-height: 1.6; + margin-top: 8px; +} +.authenticator-setup { + margin: 20px 0; +} +.authenticator-setup > img { + display: block; + max-width: 100%; + height: auto; + margin: 12px 0; + border-radius: 8px; +} +.authenticator-setup code { + display: block; + overflow-wrap: anywhere; + margin: 10px 0; +} +.recovery-codes { + margin-top: 24px; + padding: 18px; + background: var(--soft-green); + border-radius: 12px; +} +.recovery-codes ul { + list-style: none; + padding: 0; + line-height: 2; + font-size: 12px; +} +.recovery-codes > button { + margin: 6px 8px 0 0; +} +.security-events { + grid-column: 1 / -1; + overflow-wrap: anywhere; +} +.security-events details { + padding: 14px 0; + border-bottom: 1px solid var(--line); +} +.security-events summary { + cursor: pointer; +} +.security-events dt { + color: var(--muted); + font-size: 11px; + margin-top: 12px; +} +.security-events dd { + margin: 4px 0; + font-size: 13px; +} diff --git a/src/components/security-settings.tsx b/src/components/security-settings.tsx new file mode 100644 index 0000000..d1442b8 --- /dev/null +++ b/src/components/security-settings.tsx @@ -0,0 +1,444 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Download, Mail, ShieldCheck } from 'lucide-react'; +import { api, toast } from '@/lib/client'; +import './security-settings.css'; + +type Security = { + enabled: boolean; + recoveryRemaining: number; + encryptionReady: boolean; + email: string; + loginNotifications: boolean; + mail: { + host: string; + port: number; + secure: boolean; + username: string; + from: string; + passwordConfigured: boolean; + }; + events: { + id: number; + created_at: string; + context: { ip: string | null; ipSource: string; agent: string }; + factor: string; + mail_status: string; + }[]; +}; +export function SecuritySettings() { + const [data, setData] = useState(null); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + const [password, setPassword] = useState(''); + const [code, setCode] = useState(''); + const [setup, setSetup] = useState<{ secret: string; qr: string } | null>(null); + const [codes, setCodes] = useState([]); + async function refresh() { + setData(await api('auth/security')); + } + useEffect(() => { + void refresh().catch((e) => setError(e.message)); + }, []); + async function action(name: string) { + setBusy(true); + setError(''); + try { + const result = await api<{ secret: string; qr: string; codes?: string[] }>( + `auth/2fa/${name}`, + 'POST', + { password, code }, + ); + if (name === 'setup') setSetup(result); + else { + setSetup(null); + setPassword(''); + setCode(''); + setCodes(result.codes ?? []); + await refresh(); + toast( + name === 'disable' + ? '2FA deaktiviert. Andere Sitzungen beendet.' + : '2FA gespeichert. Wiederherstellungscodes jetzt sichern.', + ); + } + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + } + if (!data) + return ( +
+

Admin-Sicherheit

+

{error || 'Einstellungen werden geladen …'}

+
+ ); + return ( + <> +
+

+ Zwei-Faktor-Anmeldung +

+

+ {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. +

+ )} + + {setup && ( +
+

1. Mit deiner Authenticator-App scannen

+ QR-Code für die HamHam-Authenticator-Einrichtung +
+ Schlüssel manuell eingeben + {setup.secret} +

TOTP · 6 Stellen · 30 Sekunden · SHA-1

+
+

2. Code bestätigen

+

+ Die Einrichtung ist 10 Minuten gültig. Erst nach Bestätigung ist 2FA aktiv. +

+
+ )} + {(data.enabled || setup) && ( + + )} +
+ {!data.enabled && !setup && ( + + )} + {setup && ( + + )} + {data.enabled && ( + <> + + + + )} +
+ {codes.length > 0 && ( +
+

Wiederherstellungscodes sichern

+

+ 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. +

+ {!data.events.length &&

Noch keine Anmeldungen erfasst.

} + {data.events.map((event) => ( +
+ + {new Date(event.created_at).toLocaleString('de-AT')} ·{' '} + {event.context.ip ?? 'IP nicht verfügbar'} + +
+
Anmeldeverfahren
+
{event.factor}
+
IP-Quelle
+
{event.context.ipSource}
+
Browser / Gerät
+
{event.context.agent}
+
Benachrichtigung
+
+ {{ + disabled: 'Deaktiviert', + pending: 'Wird gesendet', + sent: 'An SMTP-Server übergeben', + failed: 'Versand fehlgeschlagen – Mail-Einstellungen prüfen', + }[event.mail_status] ?? event.mail_status} +
+
+
+ ))} +
+ + ); +} +function MailSettings({ data, refresh }: { data: Security; refresh: () => Promise }) { + const [busy, setBusy] = useState(false), + [error, setError] = useState(''); + return ( +
{ + e.preventDefault(); + const form = e.currentTarget, + fd = new FormData(form); + setBusy(true); + setError(''); + try { + await api('settings/mail', 'PUT', { + email: fd.get('email'), + loginNotifications: fd.has('loginNotifications'), + password: fd.get('currentPassword'), + code: fd.get('code') ?? '', + mail: { + host: fd.get('host'), + port: Number(fd.get('port')), + secure: fd.get('tls') === 'implicit', + username: fd.get('username'), + password: fd.get('smtpPassword'), + clearPassword: fd.has('clearPassword'), + from: fd.get('from'), + }, + }); + for (const name of ['currentPassword', 'code', 'smtpPassword']) { + const input = form.elements.namedItem(name) as HTMLInputElement | null; + if (input) input.value = ''; + } + await refresh(); + toast('Mail-Einstellungen gespeichert.'); + } catch (e) { + setError((e as Error).message); + } finally { + setBusy(false); + } + }} + > +

+ Mail & Benachrichtigungen +

+ + +

+ Mit Zeitpunkt, gemeldeter IP und Browser/Gerät. Nicht verifizierte Proxy-Angaben werden + entsprechend gekennzeichnet. +

+ +
+ + +
+ + + {data.mail.passwordConfigured && ( + + )} + + + {data.enabled && ( + + )} +
+ + +
+

+ Die Testmail verwendet die zuletzt gespeicherten Einstellungen. SMTP-Passwörter werden + verschlüsselt gespeichert und nicht zurückgegeben. +

+ {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/src/lib/admin-security.ts b/src/lib/admin-security.ts new file mode 100644 index 0000000..038c3a4 --- /dev/null +++ b/src/lib/admin-security.ts @@ -0,0 +1,333 @@ +import { randomBytes } from 'node:crypto'; +import { cookies } from 'next/headers'; +import { after } from 'next/server'; +import { verify } from '@node-rs/argon2'; +import QRCode from 'qrcode'; +import { z } from 'zod'; +import type { TransactionSql } from 'postgres'; +import { sql } from '@/db'; +import { ApiError, digest, jsonBody, newSession, rateLimit } from './server'; +import { + createAuthenticator, + createRecoveryCodes, + decryptSecret, + encryptSecret, + encryptionReady, + recoveryIndex, + totpCounter, +} from './auth-crypto'; +import { loginContext, type LoginContext } from './login-context'; +import { getMailConfig, mailSchema, sendMail } from './mail'; + +type User = { id: string; username: string }; +type SecurityRow = { + admin_id: string; + email: string; + login_notifications: boolean; + totp_secret: string | null; + pending_secret: string | null; + pending_expires: Date | null; + last_counter: string | number; + recovery_hashes: string[]; +}; +const challengeCookie = 'hamham_login_challenge'; +const credentials = z.object({ + password: z.string().min(1).max(200), + code: z.string().trim().max(64).default(''), +}); +const response = (data: unknown) => + Response.json(data, { headers: { 'Cache-Control': 'no-store' } }); +export async function ensureSecurity(id: string) { + await sql`INSERT INTO admin_security (admin_id, email) VALUES (${id}, ${process.env.ADMIN_EMAIL ?? ''}) ON CONFLICT DO NOTHING`; +} +async function passwordCheck(id: string, password: string) { + await rateLimit(`security:${id}`, 15); + const [row] = await sql`SELECT password FROM admins WHERE id = ${id}`; + if (!row || !(await verify(row.password, password))) + throw new ApiError(401, 'Aktuelles Passwort ist falsch.'); +} +async function consumeFactor(tx: TransactionSql, row: SecurityRow, code: string) { + if (!row.totp_secret) return false; + const counter = totpCounter( + decryptSecret(row.totp_secret, `totp:${row.admin_id}`), + code, + Number(row.last_counter), + ); + if (counter !== null) { + await tx`UPDATE admin_security SET last_counter = ${counter} WHERE admin_id = ${row.admin_id}`; + return true; + } + const index = recoveryIndex(row.recovery_hashes, code); + if (index < 0) return false; + await tx`UPDATE admin_security SET recovery_hashes = ${JSON.stringify(row.recovery_hashes.filter((_, n) => n !== index))}::jsonb WHERE admin_id = ${row.admin_id}`; + return true; +} +export async function reauthenticate(id: string, password: string, code: string) { + await ensureSecurity(id); + await passwordCheck(id, password); + await sql.begin(async (tx) => { + const [row] = await tx< + SecurityRow[] + >`SELECT * FROM admin_security WHERE admin_id = ${id} FOR UPDATE`; + if (row.totp_secret && !(await consumeFactor(tx, row, code))) + throw new ApiError( + 401, + 'Authenticator- oder Wiederherstellungscode ist ungültig oder bereits verwendet.', + ); + }); +} +async function recordLogin(user: User, context: LoginContext, factor: string) { + await sql`DELETE FROM login_limits WHERE key = ${`user:${digest(user.username)}`}`; + const [security] = + await sql`SELECT email, login_notifications FROM admin_security WHERE admin_id = ${user.id}`; + const notify = Boolean(security?.login_notifications && security.email); + const [event] = + await sql`INSERT INTO login_events (admin_id, context, factor, mail_status) VALUES (${user.id}, ${JSON.stringify(context)}::jsonb, ${factor}, ${notify ? 'pending' : 'disabled'}) RETURNING id, created_at`; + await sql`DELETE FROM login_events WHERE created_at < now() - interval '90 days'`; + if (notify) + after(async () => { + try { + await sendMail( + security.email, + 'HamHam: Neue Admin-Anmeldung erkannt', + [ + `Hallo ${user.username},`, + '', + 'eine erfolgreiche Anmeldung bei HamHam wurde erkannt.', + `Zeitpunkt: ${new Date(event.created_at).toISOString()} (UTC)`, + `IP-Adresse: ${context.ip ?? 'Nicht verfügbar'}`, + `IP-Quelle: ${context.ip ? context.ipSource : 'Nicht verfügbar'}`, + `Browser / Gerät (User-Agent): ${context.agent}`, + `Anmeldeverfahren: ${factor}`, + '', + 'Die IP-Adresse ist kein verlässlicher genauer Standort. VPNs und Proxys können die Herkunft verändern.', + '', + 'Falls du das nicht warst: Ändere dein HamHam-Passwort; dadurch werden andere Sitzungen beendet.', + ].join('\n'), + ); + await sql`UPDATE login_events SET mail_status = 'sent' WHERE id = ${event.id}`; + } catch { + await sql`UPDATE login_events SET mail_status = 'failed' WHERE id = ${event.id}`; + console.error( + 'Login-Benachrichtigung konnte nicht versendet werden. SMTP-Einstellungen prüfen.', + ); + } + }); +} +export async function beginLogin(user: User, req: Request) { + await ensureSecurity(user.id); + const [security] = await sql`SELECT totp_secret FROM admin_security WHERE admin_id = ${user.id}`; + const jar = await cookies(); + const old = jar.get(challengeCookie)?.value; + if (old) await sql`DELETE FROM login_challenges WHERE token = ${digest(old)}`; + await sql`DELETE FROM login_challenges WHERE expires < now()`; + if (security.totp_secret) { + const token = randomBytes(32).toString('hex'); + await sql`INSERT INTO login_challenges (token, admin_id, expires, context) VALUES (${digest(token)}, ${user.id}, now() + interval '5 minutes', ${JSON.stringify(loginContext(req))}::jsonb)`; + jar.set(challengeCookie, token, { + httpOnly: true, + secure: process.env.COOKIE_SECURE === 'true', + sameSite: 'strict', + path: '/', + maxAge: 300, + }); + return response({ twoFactorRequired: true }); + } + jar.delete(challengeCookie); + await newSession(user.id); + await recordLogin(user, loginContext(req), 'Passwort'); + return response({ username: user.username }); +} +export async function finishLogin(req: Request) { + const { code } = z.object({ code: z.string().trim().min(1).max(64) }).parse(await jsonBody(req)); + const jar = await cookies(), + token = jar.get(challengeCookie)?.value; + if (!token) throw new ApiError(401, 'Anmeldung abgelaufen. Bitte erneut mit Passwort anmelden.'); + const result = await sql.begin(async (tx) => { + const [challenge] = + await tx`SELECT * FROM login_challenges WHERE token = ${digest(token)} FOR UPDATE`; + if ( + !challenge || + new Date(challenge.expires).getTime() <= Date.now() || + challenge.attempts >= 5 + ) + return null; + await tx`UPDATE login_challenges SET attempts = attempts + 1 WHERE token = ${digest(token)}`; + const [security] = await tx< + SecurityRow[] + >`SELECT * FROM admin_security WHERE admin_id = ${challenge.admin_id} FOR UPDATE`; + if (!security || !(await consumeFactor(tx, security, code))) return null; + await tx`DELETE FROM login_challenges WHERE token = ${digest(token)}`; + const [user] = await tx`SELECT id, username FROM admins WHERE id = ${challenge.admin_id}`; + return { user: user as User, context: challenge.context as LoginContext }; + }); + if (!result) + throw new ApiError( + 401, + 'Code ungültig, bereits verwendet oder Anmeldung abgelaufen. Nach fünf Fehlversuchen bitte erneut mit Passwort anmelden.', + ); + jar.delete(challengeCookie); + await newSession(result.user.id); + await recordLogin( + result.user, + result.context, + /^\d{6}$/.test(code) ? 'Passwort + Authenticator' : 'Passwort + Wiederherstellungscode', + ); + return response({ username: result.user.username }); +} +async function revokeSessions(id: string) { + await sql.begin(async (tx) => { + await tx`DELETE FROM sessions WHERE admin_id = ${id}`; + await tx`DELETE FROM login_challenges WHERE admin_id = ${id}`; + }); + await newSession(id); +} +export async function securityRoute( + req: Request, + route: string, + user: User, +): Promise { + if ( + ![ + 'auth/security', + 'auth/2fa/setup', + 'auth/2fa/enable', + 'auth/2fa/disable', + 'auth/2fa/recovery', + 'settings/mail', + 'settings/mail/test', + ].includes(route) + ) + return null; + await ensureSecurity(user.id); + if (route === 'auth/security' && req.method === 'GET') { + const [row] = await sql< + SecurityRow[] + >`SELECT * FROM admin_security WHERE admin_id = ${user.id}`; + const mail = await getMailConfig(); + const events = + await sql`SELECT id, created_at, context, factor, mail_status FROM login_events WHERE admin_id = ${user.id} ORDER BY created_at DESC LIMIT 20`; + return response({ + enabled: Boolean(row.totp_secret), + recoveryRemaining: row.recovery_hashes.length, + encryptionReady: encryptionReady(), + email: row.email, + loginNotifications: row.login_notifications, + mail: { + host: mail.host, + port: mail.port, + secure: mail.secure, + username: mail.username, + from: mail.from, + passwordConfigured: Boolean(mail.password), + }, + events, + }); + } + if (route === 'settings/mail/test' && req.method === 'POST') { + await rateLimit(`mail-test:${user.id}`, 3); + const [row] = await sql`SELECT email FROM admin_security WHERE admin_id = ${user.id}`; + if (!row.email) throw new ApiError(400, 'Bitte zuerst die Admin-Mailadresse speichern.'); + try { + await sendMail( + row.email, + 'HamHam: Testmail', + 'Die SMTP-Einstellungen deiner HamHam-Installation funktionieren.', + ); + } catch { + throw new ApiError( + 502, + 'Testmail konnte nicht versendet werden. SMTP-Host, Port, TLS, Zugang und Absender prüfen.', + ); + } + return response({ ok: true }); + } + if (req.method !== 'POST' && !(route === 'settings/mail' && req.method === 'PUT')) + throw new ApiError(405, 'Methode nicht erlaubt.'); + const body = await jsonBody(req), + auth = credentials.parse(body); + if (route === 'auth/2fa/setup') { + if (!encryptionReady()) + throw new ApiError(503, 'Der Server benötigt zuerst einen AUTH_ENCRYPTION_KEY.'); + await passwordCheck(user.id, auth.password); + const setup = createAuthenticator(user.username); + const rows = + await sql`UPDATE admin_security SET pending_secret = ${encryptSecret(setup.secret, `totp:${user.id}`)}, pending_expires = now() + interval '10 minutes' WHERE admin_id = ${user.id} AND totp_secret IS NULL RETURNING admin_id`; + if (!rows.length) throw new ApiError(409, '2FA ist bereits aktiviert.'); + return response({ + secret: setup.secret, + qr: await QRCode.toDataURL(setup.uri), + expiresIn: 600, + }); + } + if (route === 'auth/2fa/enable') { + await passwordCheck(user.id, auth.password); + const recovery = createRecoveryCodes(); + await sql.begin(async (tx) => { + const [row] = await tx< + SecurityRow[] + >`SELECT * FROM admin_security WHERE admin_id = ${user.id} FOR UPDATE`; + if ( + row.totp_secret || + !row.pending_secret || + !row.pending_expires || + new Date(row.pending_expires).getTime() <= Date.now() + ) + throw new ApiError(400, 'Bitte die 2FA-Einrichtung erneut starten.'); + const counter = totpCounter(decryptSecret(row.pending_secret, `totp:${user.id}`), auth.code); + if (counter === null) throw new ApiError(401, 'Der Authenticator-Code ist ungültig.'); + await tx`UPDATE admin_security SET totp_secret = pending_secret, pending_secret = NULL, pending_expires = NULL, last_counter = ${counter}, recovery_hashes = ${JSON.stringify(recovery.hashes)}::jsonb WHERE admin_id = ${user.id}`; + }); + await revokeSessions(user.id); + return response({ codes: recovery.codes }); + } + if (route === 'auth/2fa/disable' || route === 'auth/2fa/recovery') { + await passwordCheck(user.id, auth.password); + const recovery = createRecoveryCodes(); + await sql.begin(async (tx) => { + const [row] = await tx< + SecurityRow[] + >`SELECT * FROM admin_security WHERE admin_id = ${user.id} FOR UPDATE`; + if (!(await consumeFactor(tx, row, auth.code))) + throw new ApiError( + 401, + 'Authenticator- oder Wiederherstellungscode ungültig oder bereits verwendet.', + ); + if (route.endsWith('/disable')) + await tx`UPDATE admin_security SET totp_secret = NULL, pending_secret = NULL, pending_expires = NULL, last_counter = -1, recovery_hashes = '[]'::jsonb WHERE admin_id = ${user.id}`; + else + await tx`UPDATE admin_security SET recovery_hashes = ${JSON.stringify(recovery.hashes)}::jsonb WHERE admin_id = ${user.id}`; + }); + await revokeSessions(user.id); + return response(route.endsWith('/disable') ? { ok: true } : { codes: recovery.codes }); + } + if (route === 'settings/mail') { + const input = z + .object({ + email: z.email().max(320), + loginNotifications: z.boolean(), + mail: mailSchema.extend({ + password: z.string().max(2048).default(''), + clearPassword: z.boolean().default(false), + }), + }) + .parse(body); + if (input.loginNotifications && (!input.mail.host || !input.mail.from)) + throw new ApiError(400, 'Für Benachrichtigungen bitte SMTP-Host und Absender eintragen.'); + if (input.mail.password && !encryptionReady()) + throw new ApiError(503, 'Der Server benötigt zuerst einen AUTH_ENCRYPTION_KEY.'); + await reauthenticate(user.id, auth.password, auth.code); + const previous = await getMailConfig(); + const { password, clearPassword, ...fields } = input.mail; + const config = { + ...fields, + password: clearPassword ? '' : password ? encryptSecret(password, 'smtp') : previous.password, + }; + await sql.begin(async (tx) => { + await tx`INSERT INTO mail_config (id, data) VALUES (1, ${JSON.stringify(config)}::jsonb) ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data`; + await tx`UPDATE admin_security SET email = ${input.email}, login_notifications = ${input.loginNotifications} WHERE admin_id = ${user.id}`; + }); + return response({ ok: true }); + } + throw new ApiError(404, 'Endpunkt nicht gefunden.'); +} diff --git a/src/lib/auth-crypto.ts b/src/lib/auth-crypto.ts new file mode 100644 index 0000000..7d6753a --- /dev/null +++ b/src/lib/auth-crypto.ts @@ -0,0 +1,65 @@ +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, + timingSafeEqual, +} from 'node:crypto'; +import { Secret, TOTP } from 'otpauth'; + +function key() { + const value = process.env.AUTH_ENCRYPTION_KEY; + if (!value || !/^[a-f0-9]{64}$/i.test(value)) + throw new Error('AUTH_ENCRYPTION_KEY fehlt oder ist ungültig.'); + return Buffer.from(value, 'hex'); +} +export function encryptionReady() { + return /^[a-f0-9]{64}$/i.test(process.env.AUTH_ENCRYPTION_KEY ?? ''); +} +export function encryptSecret(value: string, purpose: string) { + const iv = randomBytes(12), + cipher = createCipheriv('aes-256-gcm', key(), iv); + cipher.setAAD(Buffer.from(purpose)); + const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); + return [iv, cipher.getAuthTag(), ciphertext].map((v) => v.toString('base64url')).join('.'); +} +export function decryptSecret(value: string, purpose: string) { + const [iv, tag, data] = value.split('.').map((v) => Buffer.from(v, 'base64url')); + const decipher = createDecipheriv('aes-256-gcm', key(), iv); + decipher.setAAD(Buffer.from(purpose)); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8'); +} +export function authenticator(secret: string, label = 'Admin') { + return new TOTP({ + issuer: 'HamHam', + label, + algorithm: 'SHA1', + digits: 6, + period: 30, + secret: Secret.fromBase32(secret), + }); +} +export function createAuthenticator(label: string) { + const secret = new Secret({ size: 20 }).base32; + return { secret, uri: authenticator(secret, label).toString() }; +} +export function totpCounter(secret: string, code: string, last = -1, now = Date.now()) { + if (!/^\d{6}$/.test(code)) return null; + const delta = authenticator(secret).validate({ token: code, timestamp: now, window: 1 }); + const counter = delta === null ? null : Math.floor(now / 30000) + delta; + return counter !== null && counter > last ? counter : null; +} +export const recoveryHash = (value: string) => + createHash('sha256').update(value.replaceAll('-', '').toUpperCase()).digest('hex'); +export function recoveryIndex(hashes: string[], code: string) { + if (!/^[a-fA-F0-9-]{20,24}$/.test(code)) return -1; + const hash = Buffer.from(recoveryHash(code), 'hex'); + return hashes.findIndex((h) => h.length === 64 && timingSafeEqual(Buffer.from(h, 'hex'), hash)); +} +export function createRecoveryCodes() { + const codes = Array.from({ length: 10 }, () => + randomBytes(10).toString('hex').toUpperCase().match(/.{4}/g)!.join('-'), + ); + return { codes, hashes: codes.map(recoveryHash) }; +} diff --git a/src/lib/login-context.ts b/src/lib/login-context.ts new file mode 100644 index 0000000..f9b697a --- /dev/null +++ b/src/lib/login-context.ts @@ -0,0 +1,15 @@ +import { isIP } from 'node:net'; + +export type LoginContext = { ip: string | null; ipSource: string; agent: string }; +export function loginContext(req: Request): LoginContext { + // With one reverse proxy, use the rightmost appended address, never a client-supplied first entry. + const candidate = req.headers.get('x-forwarded-for')?.split(',').at(-1)?.trim() ?? ''; + const trusted = process.env.TRUST_PROXY === 'true'; + return { + ip: isIP(candidate) ? candidate : null, + ipSource: trusted ? 'Vertrauenswürdiger Proxy' : 'Proxy-Header (nicht verifiziert)', + agent: (req.headers.get('user-agent') ?? 'Unbekannt') + .replace(/[\r\n\x00-\x1f]/g, ' ') + .slice(0, 500), + }; +} diff --git a/src/lib/mail.ts b/src/lib/mail.ts new file mode 100644 index 0000000..220a932 --- /dev/null +++ b/src/lib/mail.ts @@ -0,0 +1,48 @@ +import nodemailer from 'nodemailer'; +import { z } from 'zod'; +import { sql } from '@/db'; +import { decryptSecret } from './auth-crypto'; + +export const mailSchema = z.object({ + host: z + .string() + .trim() + .max(253) + .regex(/^[a-zA-Z0-9.-]*$/), + port: z.number().int().min(1).max(65535), + secure: z.boolean(), + username: z.string().trim().max(320), + from: z.email().max(320).or(z.literal('')), +}); +export type MailConfig = z.infer & { password: string }; +export async function getMailConfig(): Promise { + const [row] = await sql`SELECT data FROM mail_config WHERE id = 1`; + return row?.data ?? { host: '', port: 587, secure: false, username: '', password: '', from: '' }; +} +export async function sendMail(to: string, subject: string, text: string) { + const config = await getMailConfig(); + if (!config.host || !config.from) throw new Error('SMTP ist noch nicht eingerichtet.'); + const transport = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: config.secure, + requireTLS: !config.secure, + auth: config.username + ? { + user: config.username, + pass: config.password ? decryptSecret(config.password, 'smtp') : '', + } + : undefined, + connectionTimeout: 10000, + greetingTimeout: 10000, + socketTimeout: 15000, + disableFileAccess: true, + disableUrlAccess: true, + }); + try { + const result = await transport.sendMail({ from: config.from, to, subject, text }); + if (!result.accepted.length) throw new Error('Empfänger wurde nicht angenommen.'); + } finally { + transport.close(); + } +} diff --git a/tests/e2e/admin.spec.ts b/tests/e2e/admin.spec.ts index feb3207..78708c5 100644 --- a/tests/e2e/admin.spec.ts +++ b/tests/e2e/admin.spec.ts @@ -48,7 +48,11 @@ test('admin creates and edits recipes with recoverable drafts and image upload', await page .getByRole('textbox', { name: 'Rezepttitel', exact: true }) .fill('Browser-Küchenrezept verbessert'); + const saved = page.waitForResponse( + (r) => r.url().includes('/api/recipes/') && r.request().method() === 'PUT', + ); await page.getByRole('button', { name: 'Rezept speichern' }).click(); + expect((await saved).status()).toBe(200); await expect(page.getByRole('status')).toContainText('erfolgreich gespeichert'); await page.goto('/admin'); await expect( @@ -109,6 +113,7 @@ test('JSON and XML import with a real preview, duplicate prevention and export', await expect(page.locator('.import-preview')).toContainText('200 g Pasta'); await page.getByRole('button', { name: '1 Rezept(e) jetzt importieren' }).click(); await expect(page.getByRole('heading', { name: recipe.title })).toBeVisible(); + await page.reload(); } await page.getByRole('button', { name: 'Importieren', exact: true }).click(); await page.getByLabel('Rezeptdaten').fill(exportRecipes([pasta], 'json')); diff --git a/tests/e2e/mail.spec.ts b/tests/e2e/mail.spec.ts new file mode 100644 index 0000000..44138ad --- /dev/null +++ b/tests/e2e/mail.spec.ts @@ -0,0 +1,118 @@ +import { test, expect } from '@playwright/test'; +import { SMTPServer } from 'smtp-server'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import postgres from 'postgres'; + +test('encrypted SMTP credentials, real TLS delivery and optional login alerts', async ({ + request, +}) => { + const messages: string[] = []; + const recipients: string[] = []; + const server = new SMTPServer({ + secure: true, + key: readFileSync(join(process.env.TEST_TLS_DIR!, 'key.pem')), + cert: readFileSync(join(process.env.TEST_TLS_DIR!, 'cert.pem')), + onAuth(auth, _session, done) { + if (auth.username === 'testmailer' && auth.password === 'smtp-test-password') + done(null, { user: 'testmailer' }); + else done(new Error('Invalid credentials')); + }, + onData(stream, session, done) { + let message = ''; + stream.on('data', (chunk) => { + message += chunk.toString(); + }); + stream.on('end', () => { + messages.push(message); + recipients.push(...session.envelope.rcptTo.map((r) => r.address)); + done(); + }); + }, + }); + await new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(15440, '127.0.0.1', resolve); + }); + const db = postgres(process.env.TEST_DATABASE_URL!, { max: 1 }); + const password = process.env.TEST_PASSWORD!; + const headers = { + origin: 'http://localhost:3100', + 'x-forwarded-for': '1.2.3.4, 2001:db8::123', + 'user-agent': 'HamHam-TestBrowser', + }; + const mail = { + host: '127.0.0.1', + port: 15440, + secure: true, + username: 'testmailer', + password: 'smtp-test-password', + from: 'hamham@example.org', + }; + try { + expect( + ( + await request.post('/api/auth/login', { + headers, + data: { username: 'testadmin', password }, + }) + ).status(), + ).toBe(200); + expect( + ( + await request.put('/api/settings/mail', { + headers, + data: { password, email: 'admin@example.org', loginNotifications: true, mail }, + }) + ).status(), + ).toBe(200); + expect((await request.post('/api/settings/mail/test', { headers })).status()).toBe(200); + expect(messages).toHaveLength(1); + expect(recipients).toEqual(['admin@example.org']); + await request.post('/api/auth/logout', { headers }); + expect( + ( + await request.post('/api/auth/login', { + headers, + data: { username: 'testadmin', password }, + }) + ).status(), + ).toBe(200); + await expect + .poll( + async () => (await (await request.get('/api/auth/security')).json()).events[0].mail_status, + ) + .toBe('sent'); + expect(messages).toHaveLength(2); + expect(messages[1]).toContain('HamHam-TestBrowser'); + expect(messages[1]).toContain('2001:db8::123'); + expect(messages[1]).not.toContain('1.2.3.4'); + expect(messages[1]).not.toContain(password); + expect( + ( + await request.put('/api/settings/mail', { + headers, + data: { + password, + email: 'admin@example.org', + loginNotifications: false, + mail: { ...mail, password: '' }, + }, + }) + ).status(), + ).toBe(200); + expect((await request.post('/api/settings/mail/test', { headers })).status()).toBe(200); + expect(messages).toHaveLength(3); // Blank password preserved the encrypted credential. + await request.post('/api/auth/logout', { headers }); + await request.post('/api/auth/login', { headers, data: { username: 'testadmin', password } }); + expect((await (await request.get('/api/auth/security')).json()).events[0].mail_status).toBe( + 'disabled', + ); + expect(messages).toHaveLength(3); + } finally { + await db`UPDATE admin_security SET login_notifications = false`; + await db`DELETE FROM mail_config`; + await db.end(); + await new Promise((resolve) => server.close(resolve)); + } +}); diff --git a/tests/e2e/security.spec.ts b/tests/e2e/security.spec.ts new file mode 100644 index 0000000..e29694e --- /dev/null +++ b/tests/e2e/security.spec.ts @@ -0,0 +1,203 @@ +import { test, expect } from '@playwright/test'; +import { authenticator } from '../../src/lib/auth-crypto'; +import postgres from 'postgres'; + +const headers = { origin: 'http://localhost:3100' }; +test('authenticator enrollment, challenge isolation, replay protection, recovery and private mail settings', async ({ + page, + playwright, +}) => { + const other = await playwright.request.newContext({ baseURL: 'http://localhost:3100' }); + const db = postgres(process.env.TEST_DATABASE_URL!, { max: 1 }); + const password = process.env.TEST_PASSWORD!; + await other.post('/api/auth/login', { headers, data: { username: 'testadmin', password } }); + await page.goto('/admin'); + await page.getByLabel('Benutzername', { exact: true }).fill('testadmin'); + await page.getByLabel('Passwort', { exact: true }).fill(password); + await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); + await page.getByRole('button', { name: 'Einstellungen', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Mail & Benachrichtigungen' })).toBeVisible(); + await page.getByLabel('Aktuelles Passwort für 2FA').fill(password); + await page.getByRole('button', { name: '2FA einrichten', exact: true }).click(); + await expect( + page.getByRole('img', { name: 'QR-Code für die HamHam-Authenticator-Einrichtung' }), + ).toBeVisible(); + const secret = await page.locator('.authenticator-setup code').textContent(); + const oldCode = authenticator(secret!).generate({ timestamp: Date.now() - 30000 }); + await page + .getByLabel('Authenticator- oder Wiederherstellungscode', { exact: true }) + .fill(oldCode); + await page.getByRole('button', { name: '2FA aktivieren', exact: true }).click(); + await expect(page.locator('.recovery-codes li')).toHaveCount(10); + const recovery = await page.locator('.recovery-codes li code').allTextContents(); + try { + expect((await other.get('/api/auth/security')).status()).toBe(401); // Enrollment revoked the old session. + // Password alone never grants an admin session when 2FA is enabled. + const login = await other.post('/api/auth/login', { + headers, + data: { username: 'testadmin', password }, + }); + expect(await login.json()).toEqual({ twoFactorRequired: true }); + expect(login.headers()['set-cookie']).not.toContain('hamham_session'); + expect((await other.get('/api/export')).status()).toBe(401); + expect((await other.get('/api/auth/security')).status()).toBe(401); + expect( + ( + await other.post('/api/auth/verify', { + headers: { origin: 'https://foreign.example' }, + data: { code: recovery[0] }, + }) + ).status(), + ).toBe(403); + expect( + (await other.post('/api/auth/verify', { headers, data: { code: 'invalid' } })).status(), + ).toBe(401); + const code = authenticator(secret!).generate(); + expect((await other.post('/api/auth/verify', { headers, data: { code } })).status()).toBe(200); + expect((await other.post('/api/auth/verify', { headers, data: { code } })).status()).toBe(401); + await other.post('/api/auth/logout', { headers }); + await other.post('/api/auth/login', { headers, data: { username: 'testadmin', password } }); + expect((await other.post('/api/auth/verify', { headers, data: { code } })).status()).toBe(401); + expect( + (await other.post('/api/auth/verify', { headers, data: { code: recovery[0] } })).status(), + ).toBe(200); + const security = await (await other.get('/api/auth/security')).json(); + expect(security.recoveryRemaining).toBe(9); + expect(JSON.stringify(security)).not.toContain(secret!); + expect(security.events[0].factor).toContain('Wiederherstellung'); + const mail = { + host: '127.0.0.1', + port: 1, + secure: true, + username: 'test', + password: 'smtp-secret-for-test', + from: 'hamham@example.org', + }; + expect( + ( + await other.put('/api/settings/mail', { + headers, + data: { + email: 'admin@example.org', + loginNotifications: true, + mail, + password, + code: recovery[1], + }, + }) + ).status(), + ).toBe(200); + const settings = await (await other.get('/api/auth/security')).json(); + expect(settings.mail.passwordConfigured).toBe(true); + expect(JSON.stringify(settings)).not.toContain(mail.password); + expect(JSON.stringify(await (await other.get('/api/settings')).json())).not.toContain( + mail.password, + ); + const [stored] = await db`SELECT data FROM mail_config WHERE id = 1`; + expect(stored.data.password).not.toContain(mail.password); + expect((await other.post('/api/settings/mail/test', { headers })).status()).toBe(502); + await other.post('/api/auth/logout', { headers }); + // A challenge cannot be retried indefinitely, even with a valid recovery code afterwards. + await other.post('/api/auth/login', { headers, data: { username: 'testadmin', password } }); + for (let n = 0; n < 5; n++) + expect( + (await other.post('/api/auth/verify', { headers, data: { code: 'invalid' } })).status(), + ).toBe(401); + expect( + (await other.post('/api/auth/verify', { headers, data: { code: recovery[2] } })).status(), + ).toBe(401); + await other.post('/api/auth/logout', { headers }); + // Real browser login via the second screen, without asking for the password again. + await page.getByRole('button', { name: 'Abmelden', exact: true }).click(); + await page.getByLabel('Benutzername', { exact: true }).fill('testadmin'); + await page.getByLabel('Passwort', { exact: true }).fill(password); + await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); + await expect(page.getByLabel('Passwort', { exact: true })).toHaveCount(0); + await page + .getByLabel('Authenticator- oder Wiederherstellungscode', { exact: true }) + .fill(recovery[2]); + await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Hallo, testadmin.' })).toBeVisible(); + await page.getByRole('button', { name: 'Einstellungen', exact: true }).click(); + // Failed notification delivery must not undo a successful authenticated login. + await expect + .poll( + async () => + (await (await page.request.get('/api/auth/security')).json()).events[0].mail_status, + ) + .toBe('failed'); + await page.screenshot({ + path: test.info().outputPath('security-settings.png'), + fullPage: true, + }); + await page.setViewportSize({ width: 390, height: 844 }); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= document.documentElement.clientWidth, + ), + ).toBe(true); + await page.screenshot({ + path: test.info().outputPath('security-settings-mobile.png'), + fullPage: true, + }); + await db`UPDATE admin_security SET login_notifications = false`; + // Expired challenges cannot consume an otherwise valid recovery code. + await other.post('/api/auth/login', { headers, data: { username: 'testadmin', password } }); + await db`UPDATE login_challenges SET expires = now() - interval '1 second'`; + expect( + (await other.post('/api/auth/verify', { headers, data: { code: recovery[4] } })).status(), + ).toBe(401); + // Two independent challenges racing for one recovery code yield only one session. + const concurrent = await playwright.request.newContext({ baseURL: 'http://localhost:3100' }); + try { + for (const client of [other, concurrent]) { + expect( + ( + await client.post('/api/auth/login', { + headers, + data: { username: 'testadmin', password }, + }) + ).status(), + ).toBe(200); + } + const results = await Promise.all( + [other, concurrent].map((client) => + client.post('/api/auth/verify', { headers, data: { code: recovery[4] } }), + ), + ); + expect(results.map((result) => result.status()).sort()).toEqual([200, 401]); + // Regeneration revokes other sessions and replaces all previous codes. + const regenerated = await page.request.post('/api/auth/2fa/recovery', { + headers, + data: { password, code: recovery[5] }, + }); + expect(regenerated.status()).toBe(200); + const newCodes = (await regenerated.json()).codes as string[]; + expect(newCodes).toHaveLength(10); + for (const client of [other, concurrent]) + expect((await client.get('/api/auth/security')).status()).toBe(401); + await other.post('/api/auth/login', { headers, data: { username: 'testadmin', password } }); + expect( + (await other.post('/api/auth/verify', { headers, data: { code: recovery[6] } })).status(), + ).toBe(401); + recovery[3] = newCodes[0]; + } finally { + await concurrent.dispose(); + } + expect( + ( + await page.request.post('/api/auth/2fa/disable', { + headers, + data: { password, code: recovery[3] }, + }) + ).status(), + ).toBe(200); + expect((await (await page.request.get('/api/auth/security')).json()).enabled).toBe(false); + } finally { + await db`DELETE FROM admin_security WHERE admin_id = (SELECT id FROM admins WHERE username = 'testadmin')`; + await db`DELETE FROM login_challenges`; + await db`DELETE FROM mail_config`; + await db.end(); + await other.dispose(); + } +}); diff --git a/tests/unit/auth-crypto.test.ts b/tests/unit/auth-crypto.test.ts new file mode 100644 index 0000000..45d2d4e --- /dev/null +++ b/tests/unit/auth-crypto.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + authenticator, + createRecoveryCodes, + decryptSecret, + encryptSecret, + recoveryIndex, + totpCounter, +} from '../../src/lib/auth-crypto'; +import { loginContext } from '../../src/lib/login-context'; + +afterEach(() => vi.unstubAllEnvs()); +describe('Admin two-factor primitives', () => { + const secret = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'; + it('matches RFC 6238 SHA1 vectors truncated to six digits', () => { + expect(authenticator(secret).generate({ timestamp: 59000 })).toBe('287082'); + expect(authenticator(secret).generate({ timestamp: 1111111109000 })).toBe('081804'); + }); + it('accepts only the adjacent time window and prevents replay', () => { + const time = 1700000000000, + counter = Math.floor(time / 30000); + const code = authenticator(secret).generate({ timestamp: time }); + expect(totpCounter(secret, code, -1, time)).toBe(counter); + expect(totpCounter(secret, code, counter, time)).toBeNull(); + expect( + totpCounter(secret, authenticator(secret).generate({ timestamp: time - 90000 }), -1, time), + ).toBeNull(); + expect(totpCounter(secret, 'abcdef', -1, time)).toBeNull(); + }); + it('encrypts secrets with authenticated purpose binding and randomized IVs', () => { + vi.stubEnv('AUTH_ENCRYPTION_KEY', 'ab'.repeat(32)); + const encrypted = encryptSecret(secret, 'totp:admin'); + expect(encrypted).not.toContain(secret); + expect(encryptSecret(secret, 'totp:admin')).not.toBe(encrypted); + expect(decryptSecret(encrypted, 'totp:admin')).toBe(secret); + expect(() => decryptSecret(encrypted, 'smtp')).toThrow(); + const parts = encrypted.split('.'); + const data = Buffer.from(parts[2], 'base64url'); + data[0] ^= 1; + parts[2] = data.toString('base64url'); + expect(() => decryptSecret(parts.join('.'), 'totp:admin')).toThrow(); + }); + it('requires a persistent 256-bit encryption key', () => { + vi.stubEnv('AUTH_ENCRYPTION_KEY', ''); + expect(() => encryptSecret(secret, 'test')).toThrow(/AUTH_ENCRYPTION_KEY/); + }); + it('generates independent recovery codes and checks their hashes', () => { + const { codes, hashes } = createRecoveryCodes(); + expect(new Set(codes).size).toBe(10); + expect(recoveryIndex(hashes, codes[2].toLowerCase())).toBe(2); + expect( + recoveryIndex( + hashes.filter((_, n) => n !== 2), + codes[2], + ), + ).toBe(-1); + expect(recoveryIndex(hashes, '000000')).toBe(-1); + expect(hashes).not.toContain(codes[0]); + }); +}); +describe('Reported login origin', () => { + it('does not trust the client-controlled first forwarded address', () => { + vi.stubEnv('TRUST_PROXY', 'false'); + const context = loginContext( + new Request('https://hamham.test', { + headers: { 'x-forwarded-for': '1.2.3.4, 2001:db8::1', 'user-agent': 'Test browser' }, + }), + ); + expect(context.ip).toBe('2001:db8::1'); + expect(context.ipSource).toContain('nicht verifiziert'); + expect( + loginContext( + new Request('https://hamham.test', { headers: { 'x-forwarded-for': 'not-an-ip' } }), + ).ip, + ).toBeNull(); + }); +});