feat: rate limiting, token hashing, remove mock login route
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run

Erste Abarbeitung des Repository-Audits (siehe TODO.md).

Sicherheit:
- Rate-Limiting für alle vier Auth-Endpunkte. Sie waren unbegrenzt aufrufbar,
  also beliebig viele Mails über den eigenen SMTP-Zugang und beliebig viele
  Rateversuche auf Verifizierungscodes.
- Passwort-Reset-Token nur noch als SHA-256-Hash in der Datenbank. Bisher
  Klartext: Lesezugriff auf die DB genuegte zur Kontouebernahme.
- Tote Mock-Login-Route entfernt. Sie verglich gegen ein hartcodiertes
  Fake-Passwort und war ueber die Middleware-Ausnahme fuer /api/auth/*
  ohne Anmeldung erreichbar.
- Passwort-Reset auf bcrypt-Kosten 12, wie beim Signup.

Reparaturen:
- npm test rief jest auf, obwohl es weder Jest-Config noch Jest-Tests gibt.
  Zeigt jetzt auf Playwright; jest/ts-jest/@types/jest entfernt.
- Falscher sonar.projectKey im Workflow.

Verifiziert per tsc gegen die geaenderten Bereiche: keine neuen Typfehler.

v1.117.0

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-06 20:01:30 +02:00
parent 155c9881bd
commit 230a4034e8
27 changed files with 635 additions and 68 deletions

View File

@ -40,7 +40,7 @@ jobs:
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
with:
args: >-
-Dsonar.projectKey=My-Weekly-ToDo-List
-Dsonar.projectKey=mARTin-B78_My-Weekly-ToDo-List_e038f066-0063-4a1e-9c33-03fdd5ab3c3a
-Dsonar.sources=src,scripts
-Dsonar.exclusions=**/node_modules/**,**/.next/**,**/*.spec.ts
-Dsonar.qualitygate.wait=true

View File

@ -2,6 +2,40 @@
All notable changes to this project are documented in this file.
## [1.117.0] - 2026-09-06
### Security
- Rate-Limiting für alle Auth-Endpunkte: `signup` (5/15 Min), `resend-verification`
(3/15 Min), `verify-email` (10/15 Min) und `reset-password` (5/15 Min), je Client-IP.
Bisher waren sie unbegrenzt aufrufbar — beliebig viele Mails über den eigenen
SMTP-Zugang, beliebig viele Rateversuche auf Verifizierungscodes.
- Passwort-Reset-Token wird nur noch als SHA-256-Hash gespeichert. Bisher lag er im
Klartext in der Datenbank: Lesezugriff auf die DB genügte, um fremde Konten zu übernehmen.
- Tote Mock-Login-Route `src/app/api/auth/login/route.ts` entfernt. Sie verglich gegen ein
hartcodiertes Fake-Passwort, erzeugte ein bedeutungsloses `mock-jwt-token-…` und war
über die Middleware-Ausnahme für `/api/auth/*` ohne Anmeldung erreichbar. Kein
Login-Bypass, aber sie protokollierte jede probierte E-Mail-Adresse.
- Passwort-Reset nutzt jetzt bcrypt-Kosten 12 statt 10 — gleicher Wert wie beim Signup.
### Fixed
- `npm test` lief ins Leere: Das Skript rief `jest` auf, obwohl es weder eine Jest-Config
noch einen einzigen Jest-Test gibt. Zeigt jetzt auf Playwright; `jest`, `ts-jest` und
`@types/jest` sind entfernt (letzteres kollidiert mit den Playwright-Globals).
- Falscher `sonar.projectKey` im GitHub-Workflow — er hätte in ein zweites, leeres
SonarQube-Projekt geschrieben statt in das bestehende.
### Changed
- Die zwölf Wegwerf-Skripte aus der Fehlersuche (`login-debug`, `css-debug-test`, …) liegen
jetzt unter `tests/debug/` und sind per `testIgnore` aus dem normalen Testlauf genommen.
Aktiv bleiben die drei echten Testdateien. Erreichbar über `npm run test:debug`.
### Added
- `TODO.md`: abhakbare Liste aus dem Repository-Audit.
- `docs/secret-rotation.md`: Anleitung zum Austausch der Zugangsdaten, ohne dass ein Wert
auf dem Bildschirm erscheint.
- `scripts/quality-check.sh`: bündelt `npm audit`, Typecheck, Lint und den SonarQube-Scan
für den nächtlichen Lauf. `npm audit` fehlte bisher komplett.
## [1.116.4] - 2026-09-06
### Fixed

197
TODO.md Normal file
View File

@ -0,0 +1,197 @@
# To-do — Repository-Audit vom 2026-09-06
Abarbeitungsliste aus dem Repository-Audit. Reihenfolge ist bewusst gewählt:
schnelle Sicherheitsgewinne zuerst, das große Refactoring zuletzt.
Abhaken mit `[x]`. Die Nummern in Klammern entsprechen den Punkten aus dem Audit.
---
## Block A — Schnell erledigt, sofortiger Gewinn
### A1. Tote Mock-Login-Route entfernen (Audit 3)
- [x] `src/app/api/auth/login/route.ts` löschen
- [x] Prüfen, dass niemand sie aufruft (`grep -rn "api/auth/login" src/`)
- [ ] Commit + Version bumpen
**Warum:** Altcode aus der Anfangszeit. Vergleicht gegen ein hartcodiertes Fake-Passwort
(`$2b$10$examplehashedpassword`), erzeugt ein bedeutungsloses `mock-jwt-token-…` und setzt es
als Cookie. Weil die Middleware `/api/auth/*` bewusst durchlässt, ist die Route **ohne Login
von außen erreichbar** und schreibt jede probierte E-Mail per `console.log` ins Log.
Kein Login-Bypass (der Vergleich schlägt immer fehl), aber unnötige Angriffsfläche.
### A2. `npm test` reparieren (Audit 6)
- [x] Entscheiden: Jest raus oder Jest einrichten
- [x] `package.json`: `"test"` auf das zeigen lassen, was wirklich existiert (Playwright)
- [x] `"test:e2e"` für Playwright ergänzen
- [x] Debug-Skripte in `tests/` aussortieren (siehe unten)
**Warum:** `"test": "jest"` — aber es gibt **keine Jest-Config und keinen einzigen Jest-Test**.
Die 15 Dateien in `tests/` sind Playwright-Specs, und die Namen verraten den Charakter:
`login-debug.spec.ts`, `css-debug-test.spec.ts`, `login-simple-debug.spec.ts`. Wegwerf-Skripte,
keine Regressionstests. `npm test` läuft ins Leere.
**Aufräumen:** Die reinen Debug-Skripte löschen, die brauchbaren behalten und benennen.
### A3. `dev`-Branch auflösen (Audit 5)
- [ ] Die vier Maßnahmen aus `f6c4a21` frisch auf `main` anwenden (siehe A1, B1, B2)
- [ ] `dev` auf `main` zurücksetzen **oder** löschen
- [ ] `CLAUDE.md`: Branch-Strategie an die gelebte Praxis anpassen
**Warum:** Commit `f6c4a21` vom 04.07.2026 behebt bereits Rate-Limiting, Token-Hashing,
die Mock-Route und die Secret-Datei — wurde aber nie gemerged. `dev` fehlen inzwischen
**70 Commits** von `main`, ein Merge würde alte Dateistände zurückholen. Deshalb:
Commit als *Vorlage* nehmen, Änderungen neu anwenden, `dev` danach entsorgen.
`CLAUDE.md` beschreibt einen `dev` → `main`-Workflow, der faktisch nicht gelebt wird
(alles läuft direkt auf `main`). Doku an die Realität anpassen ist ehrlicher.
---
## Block B — Sicherheit
### B1. Rate-Limiting auf den Auth-Endpunkten (Audit 4)
- [x] Sliding-Window-Limiter in `src/lib/rate-limit.ts` anlegen
- [x] Einbinden in `signup`
- [x] Einbinden in `resend-verification`
- [x] Einbinden in `reset-password`
- [x] Einbinden in `verify-email`
- [x] Limits dokumentieren (Vorschlag: 5 Versuche / 15 Minuten je IP)
**Warum:** Im gesamten `src/` ist **kein Limiter** vorhanden. Signup, Passwort-Reset und
Verification-Mails sind unbegrenzt aufrufbar — Einladung zum Missbrauch deines SMTP-Zugangs.
### B2. Passwort-Reset-Token nur als SHA-256-Hash speichern (Audit 4)
- [x] Beim Erzeugen: Klartext per Mail, **Hash** in die DB
- [x] Beim Einlösen: eingehenden Token hashen, dann vergleichen
- [ ] Migration für bestehende Tokens (einfachster Weg: alle invalidieren)
**Warum:** `src/app/api/auth/reset-password/route.ts:28` legt `passwordResetToken` im
**Klartext** ab. Wer Lesezugriff auf die Datenbank hat, kann fremde Konten übernehmen.
Der Token selbst kommt aus `crypto.randomUUID()` — das ist in Ordnung, nur die Ablage nicht.
### B3. Secrets aus dem Git entfernen und rotieren (Audit 2)
- [ ] `.env.production.save` aus dem Arbeitsbaum löschen
- [ ] `.gitignore` auf `.env*` erweitern (mit Ausnahme für `.env.example`)
- [ ] `NEXTAUTH_SECRET` neu erzeugen
- [ ] `SMTP_PASSWORD` neu setzen
- [ ] Datenbank-Passwort rotieren
- [ ] Entscheiden: Historie umschreiben oder belassen
- [ ] Nach dem Wechsel: einmal neu einloggen
**Warum:** `.env.production.save` ist eingecheckt und enthält **echte Werte** —
`DATABASE_URL` (70 Zeichen), `NEXTAUTH_SECRET` (20 Zeichen), `SMTP_PASSWORD` (20 Zeichen).
`.gitignore` listet `.env.production`, die `.save`-Endung fällt durchs Raster.
Drin seit Commit `da8d019`.
**Entwarnung:** Das GitHub-Repo ist **privat** — nicht öffentlich abgreifbar. Die Werte
stehen aber in der kompletten Historie, in jedem Klon und auf Gitea.
Anleitung siehe [`docs/secret-rotation.md`](docs/secret-rotation.md).
---
## Block C — Build und Typsicherheit
### C1. Build darf Fehler nicht mehr verschlucken (Audit 1)
- [ ] **Schritt 1:** `eslint.ignoreDuringBuilds` in `next.config.js` auf `false`
- [ ] Gemeldete ESLint-Fehler abarbeiten
- [ ] **Schritt 2:** `npm run type-check` laufen lassen, Fehlerzahl notieren
- [ ] Fehler abarbeiten — modulweise, nicht auf einmal
- [ ] **Schritt 3:** `typescript.ignoreBuildErrors` auf `false`
- [ ] `any`-Stellen reduzieren (aktuell **452**) — fortlaufend, kein Blocker
**Warum:** `next.config.js` hat `ignoreDuringBuilds: true` **und** `ignoreBuildErrors: true`.
`tsconfig.json` sagt zwar `strict: true`, das ist aber wirkungslos, weil der Build die Fehler
gar nicht meldet. **Ein Typfehler fällt nicht beim Bauen auf, sondern erst als kaputte Seite
im Browser.** Ein erfolgreicher Build ist derzeit *kein* Beweis, dass der Code korrekt ist.
**Achtung Reihenfolge:** Erst ESLint, dann TypeScript. Beides gleichzeitig abzuschalten
blockiert jeden Deploy, bis alles behoben ist.
---
## Block D — Struktur
### D1. Dateien über 4000 Zeilen aufteilen (Audit 7)
Betroffen sind genau zwei Dateien:
| Datei | Zeilen |
|---|---:|
| `src/components/WeeklyView.tsx` | 11.906 |
| `src/components/SettingsSidebar.tsx` | 5.445 |
**Voraussetzung:** C1 muss laufen — ohne Typecheck ist ein Refactoring dieser Größe
Blindflug. Ebenso hilfreich: A2, damit es überhaupt Tests gibt.
#### WeeklyView.tsx → Zielstruktur
- [ ] `src/components/weekly/` anlegen
- [ ] Reine Helfer nach `src/lib/weekly/` (Datum, Slots, Formatierung)
- [ ] Drag & Drop in einen Hook `useTaskDragDrop.ts`
- [ ] Someday-Listen in `SomedayLists.tsx`
- [ ] All-Day-Leiste in `AllDayBar.tsx`
- [ ] Zeitraster + Tagesspalten in `TimeGrid.tsx` / `DayColumn.tsx`
- [ ] Einstellungs-Flyouts in `QuickSettingsPanel.tsx`
- [ ] Daten-Laden in `useWeeklyData.ts`
- [ ] `WeeklyView.tsx` bleibt als Orchestrator übrig
#### SettingsSidebar.tsx → Zielstruktur
- [ ] `src/components/settings/` anlegen
- [ ] Ein Panel pro Themenbereich als eigene Datei
- [ ] Gemeinsame Bausteine (Farbwähler, Schalterzeile) in `settings/controls/`
**Vorgehen:** In kleinen Schritten, jeder Schritt ein eigener Commit, nach jedem Schritt
Typecheck. Kein "großer Wurf" — die Datei ist zu zentral.
**Warum:** `WeeklyView.tsx` ist ein Viertel des gesamten Quellcodes. Der Weekday-Bug von
Anfang September saß genau dort und war nur deshalb schwer zu finden.
---
## Block E — Dauerhafte Absicherung
### E1. Regelmäßige automatische Prüfung (Audit 8)
- [x] SonarQube-Analyse gegen https://sq.martin-bierschenk.de einrichten
- [x] Regelmäßigen Lauf aufsetzen — nightly 03:00, `todo-sonar.timer` (systemd user unit)
- [x] `npm audit` in denselben Lauf aufnehmen (`scripts/quality-check.sh`)
- [ ] `todo-sonar.timer` auf `scripts/quality-check.sh` umstellen statt nur `todo-sonar-scan`
- [ ] Playwright-Tests in den nächtlichen Lauf aufnehmen (braucht laufende App — erst nach A2)
- [ ] Quality Gate im SonarQube definieren
- [ ] Ergebnis irgendwohin melden, wo es auffällt (bisher nur Konsolenausgabe)
- [ ] **Entscheiden:** `.github/workflows/sonarqube.yml` löschen oder behalten
**Stand:** Die Analyse läuft bereits — nächtlich um 03:00 auf dem Spark, per systemd-Timer,
Ergebnis auf https://sq.martin-bierschenk.de. Was fehlte, war `npm audit` (Sicherheitslücken
in Abhängigkeiten findet SonarQube nicht) und ein Bündel-Skript. Beides ist jetzt da.
**Zur Entscheidung:** GitHub Actions wurde am 02.09. bewusst verworfen — das Repo ist privat,
Actions-Minuten kosten, und der SonarQube-Server läuft ohnehin selbst gehostet. Trotzdem liegt
seit dem 03.09. ein Workflow im Repo, der bei jedem Push auf `main` anspringt. Er ruft
`npm run type-check` und `npm run lint` auf — beides schlägt derzeit fehl (siehe C1), der
Workflow wäre also dauerhaft rot. Sein Projekt-Key war zudem falsch und hätte in ein zweites,
leeres SonarQube-Projekt geschrieben; das ist korrigiert. Entweder löschen (passt zur
Entscheidung vom 02.09.) oder bewusst behalten und C1 vorziehen.
**Warum:** Bisher fallen Regressionen erst in Produktion auf. In Kombination mit C1
(Build meldet Fehler) und A2 (Tests laufen) entsteht daraus ein echtes Netz.
### E2. Abhängigkeiten auf Sicherheitslücken prüfen
- [ ] `npm audit` auf dem Container ausführen
- [ ] Kritische Funde beheben
**Warum:** Konnte im Audit **nicht** geprüft werden — `node_modules` ist auf dem
CIFS-Share nicht installierbar (keine Symlinks). Zu bekannten Lücken in den
Abhängigkeiten liegt daher **keine Aussage** vor. Muss nachgeholt werden.
---
## Kleinkram (ohne Eile)
- [ ] 114 `console.log` aus dem Produktionscode entfernen (die 275 `console.error` sind in Ordnung)
- [ ] `NEXTAUTH_SECRET` auf 32 Byte verlängern (erledigt sich mit B3)
- [ ] API-Routen: JSON-401 statt HTML-Redirect für nicht angemeldete Aufrufe
- [ ] `deploy.sh` und `docker-compose.yml` löschen oder als veraltet markieren — sie
beschreiben ein Portainer-Setup, das nicht mehr verwendet wird
- [ ] Eigener Auth-Check in `apple/sync` und `weather/geocode` (aktuell nur durch die
Middleware geschützt — zusätzliche Absicherung schadet nicht)

196
docs/secret-rotation.md Normal file
View File

@ -0,0 +1,196 @@
# Secrets rotieren
Anleitung zum Austausch der Zugangsdaten, die versehentlich in `.env.production.save`
im Git gelandet sind.
**Grundregel:** Kein Befehl in dieser Anleitung gibt einen Geheimwert auf dem Bildschirm
aus. Die neuen Werte werden direkt in die Datei geschrieben. Du siehst sie nie, ich sehe
sie nie, und sie landen nicht in der Shell-History (dort steht nur der Befehl, nicht sein
Ergebnis).
---
## Vorab: Was passiert mit den Daten?
Kurz: **Es geht nichts verloren.** Im Einzelnen:
| Was du änderst | Folge für die Daten | Folge für die Nutzer |
|---|---|---|
| `NEXTAUTH_SECRET` | keine | Alle werden ausgeloggt, Login danach normal möglich |
| Datenbank-Passwort | keine | keine |
| `SMTP_PASSWORD` | keine | keine |
Wichtig zu verstehen: **Die Passwörter der Nutzer hängen nicht am `NEXTAUTH_SECRET`.**
Sie liegen als bcrypt-Hashes in der Datenbank und bleiben davon völlig unberührt.
`NEXTAUTH_SECRET` signiert nur die Sitzungs-Cookies. Wird es getauscht, sind die
ausgestellten Cookies ungültig — jeder muss sich **einmal neu anmelden**, mit dem
gewohnten Passwort. Danach ist alles wie vorher.
Beim Datenbank-Passwort änderst du nur die *Zugangsdaten*, nicht die Datenbank.
Tabellen und Inhalte bleiben unangetastet.
---
## Schritt 0 — Sicherheitsnetz
Auf dem Container:
```bash
cd /root/My-Weekly-ToDo-List
cp .env .env.backup-$(date +%F)
chmod 600 .env.backup-*
ls -l .env.backup-*
```
Falls etwas schiefgeht, kommst du damit zurück. Die Sicherung liegt außerhalb von Git
(`.env*` ist ignoriert) — bitte trotzdem später aufräumen.
Zusätzlich ein Datenbank-Backup, bevor du am DB-Zugang drehst:
```bash
bash /root/My-Weekly-ToDo-List/scripts/backup-db.sh
```
---
## Schritt 1 — `NEXTAUTH_SECRET` neu erzeugen
Ein Befehl, der den neuen Wert erzeugt und **direkt** in die Datei schreibt:
```bash
cd /root/My-Weekly-ToDo-List
sed -i "s|^NEXTAUTH_SECRET=.*|NEXTAUTH_SECRET=\"$(openssl rand -base64 32)\"|" .env
```
Kontrolle, ohne den Wert zu zeigen — es wird nur die Länge ausgegeben:
```bash
awk -F= '/^NEXTAUTH_SECRET=/{v=substr($0,index($0,"=")+1); gsub(/"/,"",v); print "Länge:", length(v), "Zeichen"}' .env
```
Erwartet: **44 Zeichen** (32 Byte in Base64). Der alte Wert hatte 20 — also deutlich zu kurz.
---
## Schritt 2 — Datenbank-Passwort rotieren
Das passiert bei Supabase, nicht auf dem Server:
1. Supabase-Projekt öffnen → **Settings** → **Database**
2. **Reset database password** — neues Passwort erzeugen lassen
3. Passwort in deinen Passwort-Manager, **nicht** in eine Datei kopieren
4. Supabase zeigt dir die fertigen Connection-Strings für `DATABASE_URL` (Pooler, Port 6543)
und `DIRECT_URL` (direkt, Port 5432)
Auf dem Container einsetzen — dieser Weg zeigt die Eingabe nicht an:
```bash
cd /root/My-Weekly-ToDo-List
read -rsp "Neue DATABASE_URL einfügen: " V && printf '\n' && \
sed -i "s|^DATABASE_URL=.*|DATABASE_URL=\"$V\"|" .env && unset V
read -rsp "Neue DIRECT_URL einfügen: " V && printf '\n' && \
sed -i "s|^DIRECT_URL=.*|DIRECT_URL=\"$V\"|" .env && unset V
```
`read -rs` unterdrückt die Anzeige beim Tippen, `unset` räumt die Variable weg.
Verbindung prüfen, ohne Zugangsdaten auszugeben:
```bash
npx prisma db execute --stdin <<< "SELECT 1;" && echo "Datenbank erreichbar"
```
---
## Schritt 3 — SMTP-Passwort rotieren
1. Beim Mail-Anbieter ein neues Passwort setzen (idealerweise ein App-Passwort)
2. Auf dem Container eintragen:
```bash
cd /root/My-Weekly-ToDo-List
read -rsp "Neues SMTP-Passwort: " V && printf '\n' && \
sed -i "s|^SMTP_PASSWORD=.*|SMTP_PASSWORD=\"$V\"|" .env && unset V
```
---
## Schritt 4 — Übernehmen
```bash
cd /root/My-Weekly-ToDo-List
cp .env .next/standalone/.env
pm2 restart my-weekly-todo --update-env
pm2 save
```
Der Kopierschritt ist nötig, weil der Standalone-Build seine eigene `.env` neben
`server.js` liest.
Danach: einmal im Browser neu anmelden. Dass du ausgeloggt wurdest, ist der **Beweis**,
dass das neue `NEXTAUTH_SECRET` greift.
---
## Schritt 5 — Die Datei aus dem Git entfernen
```bash
cd /root/My-Weekly-ToDo-List # oder im Arbeitsrepo
git rm --cached .env.production.save
rm .env.production.save
```
`.gitignore` ergänzen:
```
.env*
!.env.example
```
Committen, pushen, fertig.
---
## Schritt 6 — Historie: umschreiben oder nicht?
Nach Schritt 5 ist die Datei aus dem *aktuellen* Stand verschwunden — in der **Historie**
steht sie weiterhin.
**Meine Empfehlung: Historie so lassen.** Begründung:
- Das Repository ist **privat** (geprüft).
- Nach den Schritten 1–3 sind die enthaltenen Werte **wertlos**, weil überall ersetzt.
- Historie umschreiben ändert jeden Commit-Hash. Alle Klone (dein Rechner, der Container,
Gitea) müssten neu geholt werden, alte Tags zeigen ins Leere. Das Risiko übersteigt
den Nutzen deutlich.
Rotieren schlägt Umschreiben: Ein rotiertes Geheimnis in der Historie ist harmlos, ein
nicht rotiertes bleibt auch nach dem Umschreiben in jedem existierenden Klon.
Falls du es trotzdem willst — nur bei Bedarf, mit vorherigem Voll-Backup:
```bash
git filter-repo --path .env.production.save --invert-paths
```
Danach `git push --force` auf **beide** Remotes, und jeder Klon muss neu erstellt werden.
---
## Kontrolle zum Schluss
```bash
cd /root/My-Weekly-ToDo-List
git ls-files | grep -E "^\.env" || echo "OK: keine .env-Datei mehr im Git"
grep -cE "^(NEXTAUTH_SECRET|DATABASE_URL|DIRECT_URL|SMTP_PASSWORD)=" .env
```
Die zweite Zeile muss `4` ergeben — vier gesetzte Schlüssel, ohne einen davon zu zeigen.
Zum Schluss die Sicherungen aufräumen:
```bash
shred -u .env.backup-* 2>/dev/null || rm -f .env.backup-*
```

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "my-weekly-todo-list",
"version": "1.116.4",
"version": "1.117.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "my-weekly-todo-list",
"version": "1.116.4",
"version": "1.117.0",
"license": "MIT",
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.116.4",
"version": "1.117.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js",
"scripts": {
@ -9,7 +9,9 @@
"start": "echo \"\\n ✦ My Weekly ToDo List v$(node -p \"require('./package.json').version\") — production\\n\" && next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "jest",
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:debug": "playwright test tests/debug",
"sonar": "todo-sonar-scan"
},
"keywords": [
@ -60,17 +62,14 @@
},
"devDependencies": {
"@playwright/test": "^1.58.0",
"@types/jest": "^30.0.0",
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.0.0",
"eslint-config-next": "^14.0.0",
"jest": "^29.0.0",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.1",
"ts-jest": "^29.0.0",
"typescript": "^5.0.0"
}
}

View File

@ -13,6 +13,8 @@ import { defineConfig, devices } from '@playwright/test';
*/
export default defineConfig({
testDir: './tests',
/* tests/debug enthält Wegwerf-Skripte aus der Fehlersuche, keine Regressionstests */
testIgnore: '**/debug/**',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */

44
scripts/quality-check.sh Normal file
View File

@ -0,0 +1,44 @@
#!/bin/bash
#
# Wiederkehrende Qualitäts- und Sicherheitsprüfung.
#
# Gedacht für den nächtlichen Lauf auf dem Spark (todo-sonar.timer), kann aber jederzeit
# von Hand gestartet werden: bash scripts/quality-check.sh
#
# Bewusst NICHT auf dem Produktionscontainer: LXC 202 hat 2 Kerne und bedient Live-Verkehr,
# und ein Scan zur Deploy-Zeit findet Probleme erst, nachdem sie ausgeliefert wurden.
main() {
set -uo pipefail # kein -e: jede Prüfung soll laufen, auch wenn eine vorher meckert
local FAILED=0
step() { echo; echo "=== $1 ==="; }
cd "$(dirname "$0")/.."
step "Abhängigkeiten auf bekannte Sicherheitslücken"
npm audit --audit-level=high || FAILED=1
step "TypeScript"
npm run type-check || FAILED=1
step "ESLint"
npm run lint || FAILED=1
step "SonarQube"
if command -v todo-sonar-scan >/dev/null; then
todo-sonar-scan || FAILED=1
else
echo "todo-sonar-scan nicht gefunden — übersprungen"
fi
echo
if [ "$FAILED" -eq 0 ]; then
echo "=== Alle Prüfungen bestanden ==="
else
echo "=== Mindestens eine Prüfung hat angeschlagen (siehe oben) ==="
fi
return "$FAILED"
}
main "$@"

View File

@ -1,55 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
import { compare } from 'bcryptjs';
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
// Basic validation
if (!email || !password) {
return NextResponse.json(
{ error: 'Email and password are required' },
{ status: 400 }
);
}
// In a real app, you would fetch user from database here
// For now, we'll simulate a user lookup
console.log('Attempting to login user with email:', email);
// Simulate database lookup
const mockStoredPassword = '$2b$10$examplehashedpassword'; // This would come from DB
// Compare passwords
const isValid = await compare(password, mockStoredPassword);
if (!isValid) {
return NextResponse.json(
{ error: 'Invalid email or password' },
{ status: 401 }
);
}
// Create a mock JWT token (in a real app, you would use a proper JWT library)
const token = `mock-jwt-token-${Date.now()}`;
// Set cookie
const response = NextResponse.json({ message: 'Login successful' });
response.cookies.set('auth_token', token, {
httpOnly: true,
maxAge: 15 * 60, // 15 minutes
path: '/',
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
});
return response;
} catch (error) {
console.error('Login error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -1,10 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
import { prisma } from '@/lib/prisma';
import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit';
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
export async function POST(request: NextRequest) {
try {
// Mail-Versand drosseln: ohne Limit ist der Endpunkt unbegrenzt aufrufbar
const limit = rateLimit(`resend-verification:${clientIp(request)}`, 3, 15 * 60 * 1000);
if (!limit.ok) return tooManyRequests(limit.retryAfter);
const { email } = await request.json();
if (!email) {

View File

@ -3,10 +3,14 @@ export const dynamic = 'force-dynamic';
import { hash } from 'bcryptjs';
import { prisma } from '@/lib/prisma';
import { sendPasswordResetEmail } from '@/lib/email';
import crypto from 'crypto';
import { createToken, hashToken } from '@/lib/tokens';
import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit';
export async function POST(request: NextRequest) {
try {
const limit = rateLimit(`reset-password:${clientIp(request)}`, 5, 15 * 60 * 1000);
if (!limit.ok) return tooManyRequests(limit.retryAfter);
const body = await request.json();
const { email, token, newPassword } = body;
@ -19,13 +23,15 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ message: 'If an account exists, a reset link has been sent.' });
}
const resetToken = crypto.randomUUID();
const resetToken = createToken();
const resetExpires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await prisma.user.update({
where: { email },
data: {
passwordResetToken: resetToken,
// Only the hash is stored: read access to the database must not yield a
// working reset link.
passwordResetToken: hashToken(resetToken),
passwordResetExpires: resetExpires,
},
});
@ -46,7 +52,7 @@ export async function POST(request: NextRequest) {
const user = await (prisma.user as any).findFirst({
where: {
passwordResetToken: token,
passwordResetToken: hashToken(token),
passwordResetExpires: { gt: new Date() },
},
});
@ -58,7 +64,7 @@ export async function POST(request: NextRequest) {
);
}
const hashedPassword = await hash(newPassword, 10);
const hashedPassword = await hash(newPassword, 12);
await prisma.user.update({
where: { id: user.id },

View File

@ -1,12 +1,17 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
import { prisma } from '@/lib/prisma';
import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit';
import { hash } from 'bcryptjs';
import { sendVerificationEmail, generateVerificationCode, generateVerificationToken } from '@/lib/email';
import { languageFromAcceptLanguage } from '@/lib/emailTemplates';
export async function POST(request: NextRequest) {
try {
// Kontoerstellung drosseln: ohne Limit ist der Endpunkt unbegrenzt aufrufbar
const limit = rateLimit(`signup:${clientIp(request)}`, 5, 15 * 60 * 1000);
if (!limit.ok) return tooManyRequests(limit.retryAfter);
const body = await request.json();
const { name, email, password } = body;
// Prefer the explicit language from the form (filled in by the signup

View File

@ -1,10 +1,15 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
import { prisma } from '@/lib/prisma';
import { rateLimit, clientIp, tooManyRequests } from '@/lib/rate-limit';
// POST: Verify via 6-digit code
export async function POST(request: NextRequest) {
try {
// Code-Raten drosseln: ohne Limit ist der Endpunkt unbegrenzt aufrufbar
const limit = rateLimit(`verify-email:${clientIp(request)}`, 10, 15 * 60 * 1000);
if (!limit.ok) return tooManyRequests(limit.retryAfter);
const { email, code } = await request.json();
if (!email || !code) {

106
src/lib/rate-limit.ts Normal file
View File

@ -0,0 +1,106 @@
/**
* Sliding-window rate limiter, in-memory.
*
* The app runs as a single PM2 fork process, so a process-local Map is enough. If it ever
* runs clustered or on more than one host, this needs to move to Redis or the database —
* each process would otherwise keep its own counter and the effective limit would multiply.
*
* Entries are pruned on write, and the map is capped, so a flood of unique keys cannot grow
* it without bound.
*/
type Hit = number[]; // timestamps (ms) inside the current window
const buckets = new Map<string, Hit>();
/** Above this many distinct keys the oldest are dropped — a crude but effective memory bound. */
const MAX_KEYS = 10_000;
/** Prune at most this often, so a burst does not trigger a sweep per request. */
const PRUNE_INTERVAL_MS = 60_000;
let lastPrune = 0;
function prune(now: number, windowMs: number): void {
if (now - lastPrune < PRUNE_INTERVAL_MS) return;
lastPrune = now;
for (const [key, hits] of buckets) {
const alive = hits.filter((t) => now - t < windowMs);
if (alive.length === 0) buckets.delete(key);
else buckets.set(key, alive);
}
// Still too many keys: drop the oldest entries (Map preserves insertion order).
if (buckets.size > MAX_KEYS) {
const excess = buckets.size - MAX_KEYS;
let i = 0;
for (const key of buckets.keys()) {
if (i++ >= excess) break;
buckets.delete(key);
}
}
}
export interface RateLimitResult {
/** false when the caller has exhausted its allowance */
ok: boolean;
/** requests left in the current window */
remaining: number;
/** seconds until the window frees up again (0 when ok) */
retryAfter: number;
}
/**
* Records one hit for `key` and reports whether it is still within the allowance.
*
* @param key what to count per — usually `${route}:${ip}`
* @param limit allowed requests per window
* @param windowMs window length in milliseconds
*/
export function rateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
const now = Date.now();
prune(now, windowMs);
const hits = (buckets.get(key) ?? []).filter((t) => now - t < windowMs);
if (hits.length >= limit) {
const oldest = hits[0];
buckets.set(key, hits);
return {
ok: false,
remaining: 0,
retryAfter: Math.max(1, Math.ceil((windowMs - (now - oldest)) / 1000)),
};
}
hits.push(now);
buckets.set(key, hits);
return { ok: true, remaining: limit - hits.length, retryAfter: 0 };
}
/**
* Best-effort client IP.
*
* Behind the reverse proxy the socket address is always the proxy's, so the forwarded
* headers are the only usable signal. They are spoofable by anyone talking to the app
* directly — acceptable for throttling, never use this for authorisation.
*/
export function clientIp(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0].trim();
return request.headers.get("x-real-ip") ?? "unknown";
}
/** 429 response with the standard Retry-After header. */
export function tooManyRequests(retryAfter: number): Response {
return new Response(
JSON.stringify({ error: "Too many requests. Please try again later." }),
{
status: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": String(retryAfter),
},
},
);
}

23
src/lib/tokens.ts Normal file
View File

@ -0,0 +1,23 @@
import { createHash, randomBytes } from "crypto";
/**
* One-time tokens for password reset and e-mail verification.
*
* The token is sent to the user in plain text and stored only as a SHA-256 hash. Anyone with
* read access to the database therefore holds something unusable: they cannot reverse the
* hash into a working reset link.
*
* SHA-256 without a salt is deliberate here — unlike a password, the token is 256 bits of
* CSPRNG output, so there is nothing to brute-force or look up in a rainbow table. Using
* bcrypt would only make every verification needlessly slow.
*/
/** A fresh, unguessable token. Give this to the user, never store it as-is. */
export function createToken(): string {
return randomBytes(32).toString("hex");
}
/** The value to store and to compare against. */
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}