Files
nebula/src/app/api/admin/users/[id]/route.ts
T
florian.wildeandClaude Opus 5 a122187feb
CI / pruefen (push) Canceled after 0s
feat(auth): Durchgang durch Anmeldung und Sitzungen nach OWASP, v1.8.0
Geprueft gegen ASVS und die Cheat Sheets zu Authentication, Session
Management und Password Storage. Das meiste stand schon gut da – Token mit
256 Bit und nur als Hash in der Datenbank, serverseitige Pruefung bei jeder
Anfrage, Sitzungen weg beim Passwortwechsel, TOTP mit Replay-Schutz. Fuenf
Punkte blieben.

**Aufwand beim Hashen.** N war 2^15 bei p=1. Das Password Storage Cheat
Sheet nennt 2^17/p=1 als Minimum und listet als gleichwertig unter anderem
2^16/p=2 und 2^15/p=3 – mit p=1 lag 2^15 unter *allen* zulaessigen
Kombinationen und war rund dreimal billiger zu knacken als vorgesehen. Jetzt
2^17/p=1, gemessen 0,5 s und 128 MiB je Pruefung. Der Speicher ist gedeckelt:
Node rechnet scrypt im libuv-Threadpool, also hoechstens vier parallel.

**Neuhashen bei der Anmeldung.** Ohne das blieben bestehende Passwoerter fuer
immer auf den alten Parametern – anheben laesst er sich nur dort, wo das
Klartextpasswort vorliegt. needsRehash vergleicht den Gesamtaufwand N*r*p,
nicht Feld fuer Feld, damit gleichwertige Kombinationen nicht grundlos neu
gehasht werden.

**Aufzaehlbarkeit.** Drei Zweige beantworteten sich *vor* der Passwort-
pruefung und verrieten damit die Existenz eines Kontos: gesperrt (423),
deaktiviert (403) und "kein Passwort hinterlegt, nimm einen Passkey" (401).
Der einheitliche Text GENERIC war damit wirkungslos. Jetzt wird zuerst das
Passwort geprueft; Klartext gibt es erst danach. Reine Passkey-Konten
verbrennen dieselbe Rechenzeit, damit sich der Fall auch nicht an der Dauer
zeigt. Der Preis ist der verlorene Hinweis "nimm den Passkey" – bewusst
hingenommen.

**Leerlauffrist.** lastSeenAt wurde gefuehrt, aber nie ausgewertet: ein
abgegriffener Cookie galt bis zu 30 Tage, egal ob das Konto benutzt wurde.
ASVS 3.3.2 will beide Grenzen. Acht Stunden, ueber SESSION_IDLE_HOURS
einstellbar. Das Schreibintervall fuer lastSeenAt haengt jetzt an der Frist –
bei fester Stunde haette eine kuerzere Frist sich selbst ausgehebelt.

**Cookies.** SameSite Strict statt Lax, und bei https der Praefix "__Host-".
Der laesst den Browser erzwingen, dass kein Domain-Attribut gesetzt ist –
eine andere Subdomain kann den Cookie dann nicht ueberschreiben. Beides
kostet einmalig eine neue Anmeldung.

Dazu die Passwortpruefung: Wiederholungen, durchlaufende Zeichenfolgen und
Passwoerter, die den Benutzernamen enthalten, werden abgelehnt.
Zusammensetzungsregeln bleiben bewusst aus – ASVS 2.1.9 raet davon ab.

Geprueft: 232 Unit-Tests (davon 12 neue zu Aufwand, Neuhashen und Mustern)
und 30 E2E-Tests, darunter einer, der die Antwort auf ein stillgelegtes
Konto gegen die auf ein erfundenes haelt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-15 15:38:05 +02:00

122 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { NextResponse } from "next/server";
import { requireAdminApi } from "@/lib/auth/guard";
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
import { destroyAllSessions } from "@/lib/auth/session";
import { userSafeSelect } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request";
import { adminUserUpdateSchema, formatZodError } from "@/lib/validation";
export const dynamic = "force-dynamic";
type Params = { params: Promise<{ id: string }> };
/** Verhindert, dass sich die Instanz ohne Administrator wiederfindet. */
async function wouldRemoveLastAdmin(userId: string): Promise<boolean> {
const target = await prisma.user.findUnique({
where: { id: userId },
select: { role: true },
});
if (target?.role !== "ADMIN") return false;
const activeAdmins = await prisma.user.count({
where: { role: "ADMIN", isActive: true, NOT: { id: userId } },
});
return activeAdmins === 0;
}
export async function PATCH(request: Request, { params }: Params) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const { id } = await params;
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = adminUserUpdateSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const target = await prisma.user.findUnique({ where: { id } });
if (!target) return jsonError("Konto nicht gefunden.", 404);
const { displayName, email, role, isActive, password } = parsed.data;
const losesAdmin = (role && role !== "ADMIN") || isActive === false;
if (losesAdmin && (await wouldRemoveLastAdmin(id))) {
return jsonError(
"Das ist der letzte aktive Administrator – Rolle und Status koennen nicht geaendert werden.",
409,
);
}
if (email) {
const taken = await prisma.user.findFirst({
where: { email, NOT: { id } },
select: { id: true },
});
if (taken) {
return NextResponse.json(
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
{ status: 409 },
);
}
}
if (password) {
const weak = validatePasswordStrength(password, target.username);
if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
}
}
const user = await prisma.user.update({
where: { id },
data: {
...(displayName !== undefined ? { displayName } : {}),
...(email !== undefined ? { email } : {}),
...(role ? { role } : {}),
...(isActive !== undefined ? { isActive } : {}),
...(password ? { passwordHash: await hashPassword(password) } : {}),
// Ein Eingriff des Administrators hebt eine Sperre auf.
...(isActive === true ? { lockedUntil: null, failedLoginCount: 0 } : {}),
},
select: userSafeSelect,
});
// Deaktivieren oder ein neues Passwort beenden alle Sitzungen des Kontos.
if (isActive === false || password) {
await destroyAllSessions(id);
}
return NextResponse.json({ user });
}
export async function DELETE(_request: Request, { params }: Params) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const { id } = await params;
if (id === auth.user.id) {
return jsonError("Das eigene Konto kann nicht geloescht werden.", 409);
}
if (await wouldRemoveLastAdmin(id)) {
return jsonError("Der letzte aktive Administrator kann nicht geloescht werden.", 409);
}
try {
// Projekte, Verbindungen, Passkeys und Sitzungen haengen per
// onDelete: Cascade daran und verschwinden mit.
await prisma.user.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch {
return jsonError("Konto nicht gefunden.", 404);
}
}