feat(auth): Zwei-Faktor-Anmeldung per TOTP
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>
This commit is contained in:
@@ -18,7 +18,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
export default async function AccountPage() {
|
||||
const user = await requireUserPage();
|
||||
|
||||
const [passkeys, sessions, targets] = await Promise.all([
|
||||
const [passkeys, sessions, targets, konto, offeneCodes] = await Promise.all([
|
||||
prisma.credential.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
@@ -58,6 +58,11 @@ export default async function AccountPage() {
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
select: { totpEnabledAt: true },
|
||||
}),
|
||||
prisma.recoveryCode.count({ where: { userId: user.id, usedAt: null } }),
|
||||
]);
|
||||
|
||||
const data: AccountData = {
|
||||
@@ -69,6 +74,11 @@ export default async function AccountPage() {
|
||||
passkeys,
|
||||
sessions,
|
||||
passkeysAvailable: WEBAUTHN_AVAILABLE,
|
||||
totp: {
|
||||
enabled: Boolean(konto.totpEnabledAt),
|
||||
enabledAt: konto.totpEnabledAt,
|
||||
recoveryCodesLeft: offeneCodes,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { issueRecoveryCodes } from "@/lib/auth/recovery";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const schema = z.object({ password: z.string().min(1).max(200) });
|
||||
|
||||
/**
|
||||
* Neue Wiederherstellungscodes. Die alten verfallen dabei – sonst waeren
|
||||
* Zettel im Umlauf, von denen niemand mehr weiss, welche noch gelten.
|
||||
*
|
||||
* Verlangt das Passwort, wie das Abschalten auch: die Codes sind gleichwertig
|
||||
* zum zweiten Faktor.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = schema.safeParse(body.data);
|
||||
if (!parsed.success) return jsonError("Bitte das Passwort angeben.", 422);
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: auth.user.id },
|
||||
select: { passwordHash: true, totpEnabledAt: true },
|
||||
});
|
||||
|
||||
if (!user.totpEnabledAt) {
|
||||
return jsonError("Die Zwei-Faktor-Anmeldung ist nicht aktiv.", 409);
|
||||
}
|
||||
if (!(await verifyPassword(parsed.data.password, user.passwordHash))) {
|
||||
return jsonError("Das Passwort stimmt nicht.", 401);
|
||||
}
|
||||
|
||||
return NextResponse.json({ recoveryCodes: await issueRecoveryCodes(auth.user.id) });
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import QRCode from "qrcode";
|
||||
import { z } from "zod";
|
||||
import { requireUserApi } from "@/lib/auth/guard";
|
||||
import { verifyPassword } from "@/lib/auth/password";
|
||||
import { countUnusedRecoveryCodes, issueRecoveryCodes } from "@/lib/auth/recovery";
|
||||
import { generateTotpSecret, groupSecret, otpauthUri, verifyTotp } from "@/lib/auth/totp";
|
||||
import { APP_NAME } from "@/lib/config";
|
||||
import { encryptSecret, tryDecryptSecret } from "@/lib/crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { jsonError, readJsonBody } from "@/lib/security/request";
|
||||
import { formatZodError } from "@/lib/validation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Zwei-Faktor-Anmeldung per Einmalkennwort.
|
||||
*
|
||||
* Die Einrichtung laeuft in zwei Schritten: POST legt einen Schluessel an und
|
||||
* zeigt ihn als QR-Code, PUT schaltet ihn nach einem bestaetigten Code scharf.
|
||||
* Erst dann fragt die Anmeldung danach. Wer die Einrichtung abbricht oder den
|
||||
* QR-Code nie scannt, sperrt sich so nicht aus.
|
||||
*/
|
||||
|
||||
/** Zustand fuer die Anzeige. */
|
||||
export async function GET() {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: auth.user.id },
|
||||
select: { totpEnabledAt: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
enabled: Boolean(user.totpEnabledAt),
|
||||
enabledAt: user.totpEnabledAt,
|
||||
recoveryCodesLeft: user.totpEnabledAt
|
||||
? await countUnusedRecoveryCodes(auth.user.id)
|
||||
: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** Schritt 1: Schluessel erzeugen und als QR-Code zurueckgeben. */
|
||||
export async function POST() {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: auth.user.id },
|
||||
select: { totpEnabledAt: true, username: true },
|
||||
});
|
||||
|
||||
if (user.totpEnabledAt) {
|
||||
return jsonError(
|
||||
"Die Zwei-Faktor-Anmeldung ist bereits aktiv. Bitte zuerst abschalten.",
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
// Bei jedem Anlauf ein frischer Schluessel: ein halb eingerichteter aus
|
||||
// einem frueheren Versuch koennte in einer Authenticator-App liegen, ohne
|
||||
// dass hier bekannt waere, in welcher.
|
||||
const secret = generateTotpSecret();
|
||||
await prisma.user.update({
|
||||
where: { id: auth.user.id },
|
||||
data: { totpSecretCipher: encryptSecret(secret) },
|
||||
});
|
||||
|
||||
const uri = otpauthUri({ secret, account: user.username, issuer: APP_NAME });
|
||||
|
||||
return NextResponse.json({
|
||||
// Das Bild als data:-URI, damit die Content-Security-Policy nichts
|
||||
// nachladen muss. img-src erlaubt data: bereits.
|
||||
qr: await QRCode.toDataURL(uri, { margin: 1, width: 320 }),
|
||||
uri,
|
||||
/** Zum Abtippen, falls die Kamera nicht mitspielt. */
|
||||
secret: groupSecret(secret),
|
||||
});
|
||||
}
|
||||
|
||||
const enableSchema = z.object({ code: z.string().min(6).max(10) });
|
||||
|
||||
/** Schritt 2: Code bestaetigen, scharfschalten, Wiederherstellungscodes ausgeben. */
|
||||
export async function PUT(request: Request) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = enableSchema.safeParse(body.data);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validierung fehlgeschlagen", fields: formatZodError(parsed.error) },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: auth.user.id },
|
||||
select: { totpSecretCipher: true, totpEnabledAt: true },
|
||||
});
|
||||
|
||||
if (user.totpEnabledAt) return jsonError("Die Zwei-Faktor-Anmeldung ist bereits aktiv.", 409);
|
||||
|
||||
const secret = tryDecryptSecret(user.totpSecretCipher);
|
||||
if (!secret) {
|
||||
return jsonError("Es liegt kein Schluessel vor. Bitte die Einrichtung neu starten.", 409);
|
||||
}
|
||||
|
||||
if (!verifyTotp(secret, parsed.data.code)) {
|
||||
return jsonError(
|
||||
"Der Code stimmt nicht. Geht die Uhr des Geraets richtig?",
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: auth.user.id },
|
||||
data: { totpEnabledAt: new Date() },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
// Einmalige Ausgabe: gespeichert wird nur ihr Hash.
|
||||
recoveryCodes: await issueRecoveryCodes(auth.user.id),
|
||||
});
|
||||
}
|
||||
|
||||
const disableSchema = z.object({ password: z.string().min(1).max(200) });
|
||||
|
||||
/** Abschalten. Verlangt das Passwort, nicht nur eine offene Sitzung. */
|
||||
export async function DELETE(request: Request) {
|
||||
const auth = await requireUserApi();
|
||||
if (!auth.ok) return auth.response;
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = disableSchema.safeParse(body.data);
|
||||
if (!parsed.success) return jsonError("Bitte das Passwort angeben.", 422);
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: auth.user.id },
|
||||
select: { passwordHash: true, totpEnabledAt: true },
|
||||
});
|
||||
|
||||
if (!user.totpEnabledAt) return jsonError("Die Zwei-Faktor-Anmeldung ist nicht aktiv.", 409);
|
||||
|
||||
// Ein unbeaufsichtigter Rechner mit offener Sitzung soll den zweiten Faktor
|
||||
// nicht im Vorbeigehen entfernen koennen.
|
||||
if (!(await verifyPassword(parsed.data.password, user.passwordHash))) {
|
||||
return jsonError("Das Passwort stimmt nicht.", 401);
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: auth.user.id },
|
||||
data: { totpSecretCipher: null, totpEnabledAt: null },
|
||||
}),
|
||||
prisma.recoveryCode.deleteMany({ where: { userId: auth.user.id } }),
|
||||
prisma.pendingLogin.deleteMany({ where: { userId: auth.user.id } }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -68,6 +69,18 @@ export async function POST(request: Request) {
|
||||
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}`);
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
clearMfaCookie,
|
||||
discardPendingLogin,
|
||||
noteMfaFailure,
|
||||
readPendingLogin,
|
||||
} from "@/lib/auth/mfa";
|
||||
import { redeemRecoveryCode } from "@/lib/auth/recovery";
|
||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||
import { verifyTotp } from "@/lib/auth/totp";
|
||||
import { noteSuccessfulLogin } from "@/lib/auth/users";
|
||||
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
|
||||
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Der zweite Schritt der Anmeldung: Einmalkennwort oder
|
||||
* Wiederherstellungscode.
|
||||
*
|
||||
* Der Vorgang steckt im kurzlebigen Cookie aus Schritt eins. Wer hier ohne
|
||||
* diesen Cookie ankommt, hat kein Passwort vorgezeigt und bekommt nichts.
|
||||
*/
|
||||
|
||||
const schema = z.object({
|
||||
code: z.string().min(1).max(40),
|
||||
});
|
||||
|
||||
const GENERIC = "Der Code stimmt nicht.";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const ip = clientIp(request);
|
||||
const limited = rateLimit(`mfa:${ip}`, LIMITS.login.limit, LIMITS.login.windowMs);
|
||||
if (!limited.ok) return tooManyRequests(limited.retryAfter);
|
||||
|
||||
const body = await readJsonBody(request);
|
||||
if (!body.ok) return body.response;
|
||||
|
||||
const parsed = schema.safeParse(body.data);
|
||||
if (!parsed.success) return jsonError(GENERIC, 401);
|
||||
|
||||
const pending = await readPendingLogin();
|
||||
if (!pending) {
|
||||
const response = jsonError(
|
||||
"Die Anmeldung ist abgelaufen. Bitte melde dich neu an.",
|
||||
401,
|
||||
);
|
||||
clearMfaCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
if (!pending.user.isActive) {
|
||||
await discardPendingLogin(pending.id);
|
||||
const response = jsonError("Dieses Konto ist deaktiviert.", 403);
|
||||
clearMfaCookie(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
const eingabe = parsed.data.code.trim();
|
||||
|
||||
// Sechs Ziffern sind ein Einmalkennwort, alles andere ein
|
||||
// Wiederherstellungscode. Beide Wege zu probieren waere unnoetig – die
|
||||
// Formate ueberschneiden sich nicht.
|
||||
const istZiffern = /^[\d\s]+$/.test(eingabe);
|
||||
const erfolg = istZiffern
|
||||
? Boolean(pending.user.totpSecret) && verifyTotp(pending.user.totpSecret!, eingabe)
|
||||
: await redeemRecoveryCode(pending.user.id, eingabe);
|
||||
|
||||
if (!erfolg) {
|
||||
const weiterMoeglich = await noteMfaFailure(pending.id);
|
||||
if (!weiterMoeglich) {
|
||||
const response = jsonError(
|
||||
"Zu viele Fehlversuche. Bitte melde dich neu an.",
|
||||
429,
|
||||
);
|
||||
clearMfaCookie(response);
|
||||
return response;
|
||||
}
|
||||
return jsonError(GENERIC, 401);
|
||||
}
|
||||
|
||||
await discardPendingLogin(pending.id);
|
||||
await noteSuccessfulLogin(pending.user.id);
|
||||
|
||||
const session = await createSession(pending.user.id, {
|
||||
userAgent: request.headers.get("user-agent"),
|
||||
ip,
|
||||
});
|
||||
|
||||
const response = NextResponse.json({
|
||||
ok: true,
|
||||
user: { id: pending.user.id, username: pending.user.username, role: pending.user.role },
|
||||
});
|
||||
setSessionCookie(response, session.token, session.expiresAt);
|
||||
clearMfaCookie(response);
|
||||
return response;
|
||||
}
|
||||
Reference in New Issue
Block a user