Files
nebula/src/app/api/auth/setup/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

79 lines
2.4 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 { hashPassword, validatePasswordStrength } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { claimOrphanedData } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { clientIp, jsonError, readJsonBody } from "@/lib/security/request";
import { formatZodError, setupSchema } from "@/lib/validation";
export const dynamic = "force-dynamic";
/**
* Erstmalige Einrichtung: legt das Administratorkonto an.
*
* Funktioniert nur, solange es noch keinen Benutzer gibt – danach ist der
* Endpunkt tot. Damit kann sich niemand nachtraeglich ein Adminkonto
* erschleichen.
*/
export async function POST(request: Request) {
const existing = await prisma.user.count();
if (existing > 0) {
return jsonError("Die Einrichtung ist bereits abgeschlossen.", 409);
}
const body = await readJsonBody(request);
if (!body.ok) return body.response;
const parsed = setupSchema.safeParse(body.data);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
{ status: 422 },
);
}
const { username, displayName, email, password, mode } = parsed.data;
const weak = validatePasswordStrength(password, username);
if (weak) {
return NextResponse.json(
{ error: weak, fields: { password: weak } },
{ status: 422 },
);
}
const user = await prisma.user.create({
data: {
username,
displayName: displayName ?? null,
email: email ?? null,
passwordHash: await hashPassword(password),
role: "ADMIN",
// Die Einrichtung meldet direkt an – sonst stuende im Adminbereich
// "noch nie angemeldet", obwohl gerade eine Sitzung laeuft.
lastLoginAt: new Date(),
},
});
await prisma.appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", mode },
update: { mode },
});
const claimed = await claimOrphanedData(user.id);
const session = await createSession(user.id, {
userAgent: request.headers.get("user-agent"),
ip: clientIp(request),
});
const response = NextResponse.json({
ok: true,
user: { id: user.id, username: user.username, role: user.role },
claimed,
});
setSessionCookie(response, session.token, session.expiresAt);
return response;
}