feat: rate limiting, token hashing, remove mock login route
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:
parent
155c9881bd
commit
230a4034e8
2
.github/workflows/sonarqube.yml
vendored
2
.github/workflows/sonarqube.yml
vendored
@ -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
|
||||
|
||||
34
CHANGELOG.md
34
CHANGELOG.md
@ -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
197
TODO.md
Normal 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
196
docs/secret-rotation.md
Normal 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
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
44
scripts/quality-check.sh
Normal 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 "$@"
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
106
src/lib/rate-limit.ts
Normal 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
23
src/lib/tokens.ts
Normal 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");
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user