feat(auth): Durchgang durch Anmeldung und Sitzungen nach OWASP, v1.8.0
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>
This commit is contained in:
2026-08-15 15:38:05 +02:00
co-authored by Claude Opus 5
parent 1fe41ac2a5
commit a122187feb
18 changed files with 439 additions and 48 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ export async function POST(request: Request) {
}
}
const weak = validatePasswordStrength(parsed.data.newPassword);
const weak = validatePasswordStrength(parsed.data.newPassword, user.username);
if (weak) {
return NextResponse.json({ error: weak, fields: { newPassword: weak } }, { status: 422 });
}
+1 -1
View File
@@ -69,7 +69,7 @@ export async function PATCH(request: Request, { params }: Params) {
}
if (password) {
const weak = validatePasswordStrength(password);
const weak = validatePasswordStrength(password, target.username);
if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
}
+1 -1
View File
@@ -47,7 +47,7 @@ export async function POST(request: Request) {
const { username, displayName, email, password, role } = parsed.data;
const weak = validatePasswordStrength(password);
const weak = validatePasswordStrength(password, username);
if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
}
+36 -14
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa";
import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password";
import { fakeVerifyDelay, hashPassword, needsRehash, verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
@@ -42,6 +42,30 @@ export async function POST(request: Request) {
return jsonError(GENERIC, 401);
}
/**
* Erst das Passwort pruefen, dann ueber den Kontozustand reden.
*
* Frueher standen die Abfragen auf gesperrt, deaktiviert und
* "kein Passwort hinterlegt" **vor** der Pruefung und beantworteten sich
* damit ohne jede Kenntnis des Passworts. Wer einen Benutzernamen riet,
* bekam an der Antwort gesagt, ob es ihn gibt – genau die Aufzaehlbarkeit,
* die der einheitliche Text GENERIC sonst verhindert.
*
* Jetzt gilt: falsches Passwort → immer dieselbe Antwort. Die
* hilfreichere Auskunft bekommt nur, wer das Passwort kennt.
*/
const valid = user.passwordHash
? await verifyPassword(password, user.passwordHash)
: // Kein Passwort hinterlegt (reines Passkey-Konto): trotzdem Zeit
// verbrennen, damit sich der Fall nicht an der Antwortdauer zeigt.
await fakeVerifyDelay().then(() => false);
if (!valid) {
// Fehlversuche nur zaehlen, wo es ein Passwort zu erraten gibt.
if (user.passwordHash) await noteFailedLogin(user.id);
return jsonError(GENERIC, 401);
}
const locked = lockRemainingSeconds(user.lockedUntil);
if (locked > 0) {
return jsonError(
@@ -51,22 +75,20 @@ export async function POST(request: Request) {
}
if (!user.isActive) {
await fakeVerifyDelay();
return jsonError("Dieses Konto ist deaktiviert.", 403);
}
if (!user.passwordHash) {
await fakeVerifyDelay();
return jsonError(
"Fuer dieses Konto ist kein Passwort hinterlegt – bitte mit Passkey anmelden.",
401,
);
}
const valid = await verifyPassword(password, user.passwordHash);
if (!valid) {
await noteFailedLogin(user.id);
return jsonError(GENERIC, 401);
/**
* Der Aufwand beim Hashen ist gestiegen. Wer sich mit einem Passwort
* anmeldet, dessen Hash noch nach den alten Vorgaben erzeugt wurde,
* bekommt hier still einen neuen – es ist der einzige Moment, in dem das
* Klartextpasswort vorliegt. Nebenlaeufig, damit die Anmeldung nicht auf
* die halbe Sekunde wartet.
*/
if (needsRehash(user.passwordHash)) {
void hashPassword(password)
.then((neu) => prisma.user.update({ where: { id: user.id }, data: { passwordHash: neu } }))
.catch(() => undefined);
}
// Das Passwort stimmt. Ist ein zweiter Faktor eingerichtet, entsteht hier
+1 -1
View File
@@ -39,7 +39,7 @@ export async function POST(request: Request) {
const { username, displayName, email, password } = parsed.data;
const weak = validatePasswordStrength(password);
const weak = validatePasswordStrength(password, username);
if (weak) {
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
}
+1 -1
View File
@@ -34,7 +34,7 @@ export async function POST(request: Request) {
const { username, displayName, email, password, mode } = parsed.data;
const weak = validatePasswordStrength(password);
const weak = validatePasswordStrength(password, username);
if (weak) {
return NextResponse.json(
{ error: weak, fields: { password: weak } },