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>
132 lines
3.7 KiB
TypeScript
132 lines
3.7 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
||
import { cookies } from "next/headers";
|
||
import type { NextResponse } from "next/server";
|
||
import { COOKIES_SECURE, MFA_COOKIE, MFA_TTL_MS } from "@/lib/config";
|
||
import { tryDecryptSecret } from "@/lib/crypto";
|
||
import { prisma } from "@/lib/db";
|
||
|
||
/**
|
||
* Der Zwischenzustand zwischen Passwort und zweitem Faktor.
|
||
*
|
||
* Bewusst nicht als halbfertige Sitzung: eine Sitzungszeile mit einem
|
||
* "noch nicht gueltig"-Merker waere eine Zeile, die bei einem vergessenen
|
||
* Filter irgendwo doch als Anmeldung durchgeht. Ein eigenes Modell kann
|
||
* dieses Versehen nicht passieren.
|
||
*/
|
||
|
||
/** Nach so vielen Fehlversuchen ist der Vorgang verbrannt. */
|
||
export const MFA_MAX_ATTEMPTS = 5;
|
||
|
||
function hashToken(token: string): string {
|
||
return createHash("sha256").update(token).digest("base64url");
|
||
}
|
||
|
||
export async function createPendingLogin(userId: string): Promise<string> {
|
||
const token = randomBytes(32).toString("base64url");
|
||
|
||
// Aeltere Vorgaenge desselben Kontos verwerfen: es kann nur einen geben,
|
||
// sonst sammeln sich bei jedem Anlauf neue Rateversuche an.
|
||
await prisma.pendingLogin.deleteMany({
|
||
where: { OR: [{ userId }, { expiresAt: { lt: new Date() } }] },
|
||
});
|
||
|
||
await prisma.pendingLogin.create({
|
||
data: {
|
||
userId,
|
||
tokenHash: hashToken(token),
|
||
expiresAt: new Date(Date.now() + MFA_TTL_MS),
|
||
},
|
||
});
|
||
|
||
return token;
|
||
}
|
||
|
||
export function setMfaCookie(response: NextResponse, token: string) {
|
||
response.cookies.set({
|
||
name: MFA_COOKIE,
|
||
value: token,
|
||
httpOnly: true,
|
||
sameSite: "strict",
|
||
secure: COOKIES_SECURE,
|
||
path: "/",
|
||
maxAge: Math.floor(MFA_TTL_MS / 1000),
|
||
});
|
||
}
|
||
|
||
export function clearMfaCookie(response: NextResponse) {
|
||
response.cookies.set({
|
||
name: MFA_COOKIE,
|
||
value: "",
|
||
httpOnly: true,
|
||
sameSite: "strict",
|
||
secure: COOKIES_SECURE,
|
||
path: "/",
|
||
maxAge: 0,
|
||
});
|
||
}
|
||
|
||
export interface PendingLoginState {
|
||
id: string;
|
||
attempts: number;
|
||
user: {
|
||
id: string;
|
||
username: string;
|
||
role: "ADMIN" | "USER";
|
||
isActive: boolean;
|
||
/** Entschluesselter TOTP-Schluessel; null, wenn er nicht lesbar ist. */
|
||
totpSecret: string | null;
|
||
/** Zuletzt eingeloestes Zeitfenster – gegen die Wiederverwendung. */
|
||
totpLastCounter: number | null;
|
||
};
|
||
}
|
||
|
||
/** Liest den laufenden Vorgang aus dem Cookie. */
|
||
export async function readPendingLogin(): Promise<PendingLoginState | null> {
|
||
const store = await cookies();
|
||
const token = store.get(MFA_COOKIE)?.value;
|
||
if (!token) return null;
|
||
|
||
const pending = await prisma.pendingLogin.findUnique({
|
||
where: { tokenHash: hashToken(token) },
|
||
include: { user: true },
|
||
});
|
||
if (!pending) return null;
|
||
|
||
if (pending.expiresAt.getTime() <= Date.now()) {
|
||
await prisma.pendingLogin.delete({ where: { id: pending.id } }).catch(() => undefined);
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
id: pending.id,
|
||
attempts: pending.attempts,
|
||
user: {
|
||
id: pending.user.id,
|
||
username: pending.user.username,
|
||
role: pending.user.role,
|
||
isActive: pending.user.isActive,
|
||
totpSecret: tryDecryptSecret(pending.user.totpSecretCipher),
|
||
totpLastCounter: pending.user.totpLastCounter,
|
||
},
|
||
};
|
||
}
|
||
|
||
/** Zaehlt einen Fehlversuch. Gibt zurueck, ob noch Versuche offen sind. */
|
||
export async function noteMfaFailure(id: string): Promise<boolean> {
|
||
const updated = await prisma.pendingLogin.update({
|
||
where: { id },
|
||
data: { attempts: { increment: 1 } },
|
||
select: { attempts: true },
|
||
});
|
||
|
||
if (updated.attempts >= MFA_MAX_ATTEMPTS) {
|
||
await prisma.pendingLogin.delete({ where: { id } }).catch(() => undefined);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export async function discardPendingLogin(id: string): Promise<void> {
|
||
await prisma.pendingLogin.delete({ where: { id } }).catch(() => undefined);
|
||
}
|