CI / pruefen (push) Canceled after 0s
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>
86 lines
2.5 KiB
TypeScript
86 lines
2.5 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { requireAdminApi } from "@/lib/auth/guard";
|
|
import { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
|
|
import { getInstanceState } from "@/lib/auth/settings";
|
|
import { userSafeSelect } from "@/lib/auth/users";
|
|
import { prisma } from "@/lib/db";
|
|
import { jsonError, readJsonBody } from "@/lib/security/request";
|
|
import { adminUserCreateSchema, formatZodError } from "@/lib/validation";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
export async function GET() {
|
|
const auth = await requireAdminApi();
|
|
if (!auth.ok) return auth.response;
|
|
|
|
const users = await prisma.user.findMany({
|
|
select: userSafeSelect,
|
|
orderBy: [{ role: "asc" }, { username: "asc" }],
|
|
});
|
|
|
|
return NextResponse.json({ users });
|
|
}
|
|
|
|
export async function POST(request: Request) {
|
|
const auth = await requireAdminApi();
|
|
if (!auth.ok) return auth.response;
|
|
|
|
const state = await getInstanceState();
|
|
if (!state.multiUser) {
|
|
return jsonError(
|
|
"Im Einzelbetrieb kann kein weiteres Konto angelegt werden. " +
|
|
"Bitte zuerst in den Mehrbenutzerbetrieb wechseln.",
|
|
409,
|
|
);
|
|
}
|
|
|
|
const body = await readJsonBody(request);
|
|
if (!body.ok) return body.response;
|
|
|
|
const parsed = adminUserCreateSchema.safeParse(body.data);
|
|
if (!parsed.success) {
|
|
return NextResponse.json(
|
|
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
|
{ status: 422 },
|
|
);
|
|
}
|
|
|
|
const { username, displayName, email, password, role } = parsed.data;
|
|
|
|
const weak = validatePasswordStrength(password, username);
|
|
if (weak) {
|
|
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
|
}
|
|
|
|
const taken = await prisma.user.findUnique({ where: { username }, select: { id: true } });
|
|
if (taken) {
|
|
return NextResponse.json(
|
|
{ error: "Dieser Benutzername ist bereits vergeben.", fields: { username: "Bereits vergeben" } },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
if (email) {
|
|
const emailTaken = await prisma.user.findUnique({ where: { email }, select: { id: true } });
|
|
if (emailTaken) {
|
|
return NextResponse.json(
|
|
{ error: "Diese E-Mail-Adresse wird bereits verwendet.", fields: { email: "Bereits vergeben" } },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
}
|
|
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
username,
|
|
displayName: displayName ?? null,
|
|
email: email ?? null,
|
|
passwordHash: await hashPassword(password),
|
|
role,
|
|
},
|
|
select: userSafeSelect,
|
|
});
|
|
|
|
return NextResponse.json({ user }, { status: 201 });
|
|
}
|