Ein Einmalkennwort zusaetzlich zum Passwort, mit QR-Code zum Einrichten und zehn Wiederherstellungscodes fuer den Fall, dass das Geraet verloren geht. Die Rechnung nach RFC 6238 steht in lib/auth/totp.ts statt in einer Abhaengigkeit. Es sind wenige Zeilen, das Verfahren liegt seit 2011 fest, und der RFC bringt Testvektoren mit - die Richtigkeit ist damit belegt und nicht nur behauptet. Fuer den QR-Code kommt qrcode dazu; ihn selbst zu zeichnen waere unverhaeltnismaessig. Einrichtung in zwei Schritten: der Schluessel wird hinterlegt, aber erst ein bestaetigter Code schaltet ihn scharf. Wer den QR-Code nicht scannen konnte und abbricht, sperrt sich sonst beim naechsten Anmelden selbst aus. Zwischen Passwort und Code steht ein eigenes Modell statt einer halbfertigen Sitzung. Eine Sitzungszeile mit einem "noch nicht gueltig"-Merker waere eine Zeile, die bei einem vergessenen Filter irgendwo doch als Anmeldung durchgeht; dieses Versehen kann hier nicht passieren. Nach fuenf Fehlversuchen ist der Vorgang verbrannt, damit sechs Stellen nicht in Ruhe durchprobiert werden. Die Wiederherstellungscodes liegen als SHA-256 statt als scrypt: sie sind zufaellig erzeugt und lang genug, dass Raten aussichtslos ist - ein langsames Verfahren brauchte bei jeder Anmeldung zehn teure Vergleiche und kaufte nichts dafuer. Das Alphabet laesst O, I und B weg, weil die Codes von Hand abgeschrieben werden. Abschalten und Neuerzeugen verlangen das Passwort. Ein unbeaufsichtigter Rechner mit offener Sitzung soll den zweiten Faktor nicht im Vorbeigehen entfernen koennen. Nachgewiesen im Browser: Einrichtung, Anmeldung mit Code, Anmeldung mit Wiederherstellungscode, derselbe Code ein zweites Mal abgelehnt, Abschalten mit falschem und richtigem Passwort. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
100 lines
3.3 KiB
TypeScript
100 lines
3.3 KiB
TypeScript
import { NextResponse } from "next/server";
|
||
import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa";
|
||
import { fakeVerifyDelay, 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";
|
||
import { LIMITS, rateLimit, resetRateLimit } from "@/lib/security/rateLimit";
|
||
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||
import { loginSchema } from "@/lib/validation";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
/** Immer dieselbe Meldung – sonst verraet sie, welche Konten existieren. */
|
||
const GENERIC = "Benutzername oder Passwort ist falsch.";
|
||
|
||
export async function POST(request: Request) {
|
||
const ip = clientIp(request);
|
||
|
||
const byIp = rateLimit(`login:ip:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
|
||
if (!byIp.ok) return tooManyRequests(byIp.retryAfter);
|
||
|
||
const body = await readJsonBody(request);
|
||
if (!body.ok) return body.response;
|
||
|
||
const parsed = loginSchema.safeParse(body.data);
|
||
if (!parsed.success) return jsonError(GENERIC, 401);
|
||
|
||
const { username, password } = parsed.data;
|
||
|
||
const byUser = rateLimit(
|
||
`login:user:${username}`,
|
||
LIMITS.loginUser.limit,
|
||
LIMITS.loginUser.windowMs,
|
||
);
|
||
if (!byUser.ok) return tooManyRequests(byUser.retryAfter);
|
||
|
||
const user = await prisma.user.findUnique({ where: { username } });
|
||
|
||
if (!user) {
|
||
// Gleiche Rechenzeit verbrennen wie bei einem echten Konto.
|
||
await fakeVerifyDelay();
|
||
return jsonError(GENERIC, 401);
|
||
}
|
||
|
||
const locked = lockRemainingSeconds(user.lockedUntil);
|
||
if (locked > 0) {
|
||
return jsonError(
|
||
`Das Konto ist nach mehreren Fehlversuchen noch ${Math.ceil(locked / 60)} Minuten gesperrt.`,
|
||
423,
|
||
);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
// Das Passwort stimmt. Ist ein zweiter Faktor eingerichtet, entsteht hier
|
||
// noch keine Sitzung, sondern nur ein Vorgang mit kurzer Frist.
|
||
if (user.totpEnabledAt) {
|
||
// Die Zaehler bleiben stehen, bis die Anmeldung wirklich durch ist:
|
||
// sonst liesse sich ueber wiederholte Passworteingaben die Sperre
|
||
// umgehen, die falsche Passwoerter aufbaut.
|
||
const token = await createPendingLogin(user.id);
|
||
const response = NextResponse.json({ ok: false, mfaRequired: true });
|
||
setMfaCookie(response, token);
|
||
return response;
|
||
}
|
||
|
||
await noteSuccessfulLogin(user.id);
|
||
resetRateLimit(`login:user:${username}`);
|
||
resetRateLimit(`login:ip:${ip}`);
|
||
|
||
const session = await createSession(user.id, {
|
||
userAgent: request.headers.get("user-agent"),
|
||
ip,
|
||
});
|
||
|
||
const response = NextResponse.json({
|
||
ok: true,
|
||
user: { id: user.id, username: user.username, role: user.role },
|
||
});
|
||
setSessionCookie(response, session.token, session.expiresAt);
|
||
return response;
|
||
}
|