Admin-Rescue: PW-Reset direkt in DB + Rate-Limit-Reset
Use Case: Admin sperrt sich aus (admin@admin.com ist keine echte Mailadresse, Passwort-vergessen-Flow kann keine Mail liefern) oder Brute-Force-Lockout will sich nicht von selbst auflösen. backend/prisma/reset-admin-password.ts: - Findet User per Email, hasht neues PW mit bcrypt cost 12 - Schreibt direkt in user.password, setzt tokenInvalidatedAt=now() (kickt alle bestehenden Sessions), löscht Reset-Tokens - Eigenes PW: Komplexitäts-Check 25 Zeichen - Kein PW-Argument: 28-char Zufallspasswort (alle 4 Klassen garantiert), wird einmal in stdout ausgegeben scripts/admin-rescue.sh: - password <email> [pw] → docker exec npx tsx … reset-admin-password - unlock → docker restart opencrm-app (leert In-Memory-Rate-Limit-Store) - all <email> [pw] → beides Live-verifiziert: random-Modus, schwaches PW → klare Fehlerliste, langes eigenes PW → akzeptiert, unbekannter User → exit 2, bash -n syntax-check ok. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <email> # generiert PW
|
||||
* npx tsx prisma/reset-admin-password.ts <email> <passwort> # 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 <email> [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();
|
||||
});
|
||||
@@ -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 <email> [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 <email> [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
|
||||
|
||||
Executable
+83
@@ -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 <email> [neues-passwort]
|
||||
# ./scripts/admin-rescue.sh unlock
|
||||
# ./scripts/admin-rescue.sh all <email> [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 <email> [neues-passwort] – Admin-Passwort direkt in DB setzen"
|
||||
echo " $0 unlock – Rate-Limit-Store leeren (Container-Restart)"
|
||||
echo " $0 all <email> [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
|
||||
Reference in New Issue
Block a user