feat(auth): Durchgang durch Anmeldung und Sitzungen nach OWASP, v1.8.0
CI / pruefen (push) Canceled after 0s
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:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user