diff --git a/backend/prisma/reset-admin-password.ts b/backend/prisma/reset-admin-password.ts new file mode 100644 index 00000000..8d85413c --- /dev/null +++ b/backend/prisma/reset-admin-password.ts @@ -0,0 +1,94 @@ +/** + * Notfall-Reset: setzt das Passwort eines Mitarbeiter-Users direkt in der DB. + * Wird vom scripts/admin-rescue.sh-Wrapper im Container ausgeführt, wenn ein + * Admin sich ausgesperrt hat (z.B. weil admin@admin.com keine echte + * E-Mail-Adresse ist und der Passwort-vergessen-Flow daher nicht greift). + * + * Aufruf: + * npx tsx prisma/reset-admin-password.ts # generiert PW + * npx tsx prisma/reset-admin-password.ts # eigenes PW + * + * Setzt zusätzlich `tokenInvalidatedAt = now()` → alle bestehenden Sessions + * dieses Users werden sofort ausgeloggt (Defense gegen Wiederverwendung + * gestohlener Tokens). + */ +import bcrypt from 'bcryptjs'; +import prisma from '../src/lib/prisma.js'; +import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../src/utils/passwordGenerator.js'; + +const BCRYPT_COST = 12; + +function generateRescuePassword(): string { + const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; + const lower = 'abcdefghijkmnopqrstuvwxyz'; + const digits = '23456789'; + const special = '!@#$%&*+=?'; + const all = upper + lower + digits + special; + const pick = (s: string) => s[Math.floor(Math.random() * s.length)]; + const chars = [pick(upper), pick(lower), pick(digits), pick(special)]; + for (let i = chars.length; i < 28; i++) chars.push(pick(all)); + for (let i = chars.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [chars[i], chars[j]] = [chars[j], chars[i]]; + } + return chars.join(''); +} + +async function main() { + const email = process.argv[2]; + const providedPw = process.argv[3]; + + if (!email) { + console.error('Aufruf: npx tsx prisma/reset-admin-password.ts [passwort]'); + process.exit(1); + } + + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true, email: true, firstName: true, lastName: true }, + }); + if (!user) { + console.error(`User "${email}" nicht gefunden.`); + process.exit(2); + } + + let plain: string; + if (providedPw) { + const c = validatePasswordComplexity(providedPw, { minLength: STAFF_MIN_PASSWORD_LENGTH }); + if (!c.ok) { + console.error('Übergebenes Passwort erfüllt Mitarbeiter-Komplexität nicht:'); + for (const e of c.errors) console.error(' - ' + e); + process.exit(3); + } + plain = providedPw; + } else { + plain = generateRescuePassword(); + } + + const hash = await bcrypt.hash(plain, BCRYPT_COST); + await prisma.user.update({ + where: { id: user.id }, + data: { + password: hash, + passwordResetToken: null, + passwordResetExpiresAt: null, + tokenInvalidatedAt: new Date(), + }, + }); + + console.log('========================================================'); + console.log(` User: ${user.email} (${user.firstName} ${user.lastName})`); + console.log(` Neues Passwort: ${plain}`); + console.log(' ⚠️ Wird hier EINMAL ausgegeben – sofort kopieren!'); + console.log(' Alle bestehenden Sessions wurden invalidiert.'); + console.log('========================================================'); +} + +main() + .catch((e) => { + console.error('Reset fehlgeschlagen:', e); + process.exit(99); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/docs/todo.md b/docs/todo.md index 267fd16d..86f25969 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,27 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🛟 Admin-Rescue-Script (PW-Reset direkt in DB + Rate-Limit-Reset)** + - Use Case: Admin sperrt sich aus (z.B. `admin@admin.com` ist + keine echte E-Mail → Passwort-vergessen-Flow kann keine Mail + senden) oder Brute-Force-Lockout will sich nicht auflösen. + - **Node-Script** `backend/prisma/reset-admin-password.ts`: + findet User per Email, hasht PW mit bcrypt cost 12, schreibt + direkt in `user.password`, setzt `tokenInvalidatedAt = now()` + (kickt alle bestehenden Sessions), löscht ggf. anhängende + Reset-Tokens. Mit oder ohne PW-Argument (random 28-char wenn + leer), Komplexitäts-Check 25 Zeichen für eigene PWs. + - **Bash-Wrapper** `scripts/admin-rescue.sh`: + * `password [pw]` – PW-Reset im laufenden Container + via `docker exec opencrm-app npx tsx prisma/reset-admin-…` + * `unlock` – Container-Restart leert den In-Memory-Rate-Limit- + Store komplett (alle IPs frei) + * `all [pw]` – beides + - **Live-verifiziert**: random-Modus liefert 28-char PW, schwaches + eigenes PW wird mit allen Defizit-Punkten abgelehnt, langes + eigenes PW akzeptiert, unbekannter User → klarer Fehler, + bash-Syntax-Check ok. + - [x] **🔐 Mitarbeiter-Passwörter auf 25 Zeichen (BSI-Empfehlung)** - 12 Zeichen sind heute der untere akzeptable Rand. NIST/OWASP/BSI empfehlen 14-25+ Zeichen. Mitarbeiter/Admin nutzen Passwort-Manager diff --git a/scripts/admin-rescue.sh b/scripts/admin-rescue.sh new file mode 100755 index 00000000..ee1f4d7d --- /dev/null +++ b/scripts/admin-rescue.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# +# Notfall-Wrapper für ausgesperrte Admins. +# +# 1. password – Setzt das Passwort eines Mitarbeiter-Users direkt in der +# DB (umgeht jegliche Auth). Wird im laufenden Backend- +# Container über npx tsx ausgeführt, damit bcrypt + Prisma- +# Schema garantiert konsistent sind. +# 2. unlock – Restartet den Backend-Container → leert den In-Memory- +# Rate-Limit-Store komplett (alle gesperrten IPs). +# 3. all – Beides: PW setzen UND Rate-Limit leeren. +# +# Aufruf (im Repo-Root): +# ./scripts/admin-rescue.sh password [neues-passwort] +# ./scripts/admin-rescue.sh unlock +# ./scripts/admin-rescue.sh all [neues-passwort] +# +# Ohne neues-passwort wird ein 28-Zeichen-Zufallspasswort generiert und +# einmalig in stdout ausgegeben. + +set -e + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +REPO_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" +cd "$REPO_ROOT" + +CONTAINER="opencrm-app" + +usage() { + echo "Aufruf:" + echo " $0 password [neues-passwort] – Admin-Passwort direkt in DB setzen" + echo " $0 unlock – Rate-Limit-Store leeren (Container-Restart)" + echo " $0 all [neues-passwort] – beides" + exit 1 +} + +require_container_running() { + if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER}\$"; then + echo "Container '${CONTAINER}' läuft nicht. Erst docker compose up -d." >&2 + exit 2 + fi +} + +reset_password() { + local email="$1" + local pw="$2" + require_container_running + if [ -z "$email" ]; then + echo "Email fehlt." >&2 + usage + fi + echo "Setze Passwort für ${email} im Container ${CONTAINER}…" + if [ -n "$pw" ]; then + docker exec -it "$CONTAINER" npx tsx prisma/reset-admin-password.ts "$email" "$pw" + else + docker exec -it "$CONTAINER" npx tsx prisma/reset-admin-password.ts "$email" + fi +} + +unlock_rate_limit() { + require_container_running + echo "Restarte ${CONTAINER} → Rate-Limit-Store wird geleert…" + docker restart "$CONTAINER" >/dev/null + echo "Fertig. Container hochgefahren." +} + +case "${1:-}" in + password) + shift + reset_password "$@" + ;; + unlock) + unlock_rate_limit + ;; + all) + shift + reset_password "$@" + unlock_rate_limit + ;; + *) + usage + ;; +esac