Files
nebula/src/lib/auth/recovery.ts
T
florian.wildeandClaude Opus 5 2bff6faf3d 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>
2026-07-26 12:36:37 +02:00

101 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash, randomInt, timingSafeEqual } from "node:crypto";
import { prisma } from "@/lib/db";
/**
* Wiederherstellungscodes fuer den Fall, dass der Authenticator verloren geht.
*
* Ohne sie waere die Zwei-Faktor-Anmeldung eine Falle: ein neues Handy, und
* das Konto ist zu. Ein Administrator kann zwar aushelfen – im
* Einzelbenutzerbetrieb ist der Administrator aber genau die ausgesperrte
* Person.
*/
/** Wie viele Codes bei der Einrichtung erzeugt werden. */
export const RECOVERY_CODE_COUNT = 10;
/**
* Alphabet ohne die Paare, die man beim Abschreiben verwechselt: kein O/0,
* kein I/l/1, kein B/8. Die Codes werden von Hand aus einem Ausdruck oder
* Passwortmanager uebertragen, oft unter Zeitdruck.
*/
const ALPHABET = "abcdefghjkmnpqrstuvwxyz23456789";
const GROUP = 5;
/** Ein Code der Form "a7k2m-p9xqd" – rund 50 Bit Zufall. */
function generateCode(): string {
const zeichen = Array.from(
{ length: GROUP * 2 },
() => ALPHABET[randomInt(ALPHABET.length)],
).join("");
return `${zeichen.slice(0, GROUP)}-${zeichen.slice(GROUP)}`;
}
/** Vereinheitlicht Eingaben: Grossschreibung und fehlender Bindestrich. */
export function normalizeCode(input: string): string {
const clean = input.toLowerCase().replace(/[^a-z0-9]/g, "");
if (clean.length !== GROUP * 2) return clean;
return `${clean.slice(0, GROUP)}-${clean.slice(GROUP)}`;
}
export function hashCode(code: string): string {
return createHash("sha256").update(normalizeCode(code)).digest("base64url");
}
/**
* Erzeugt einen frischen Satz und verwirft den alten. Gibt die Codes im
* Klartext zurueck – das ist die einzige Gelegenheit, sie zu sehen.
*/
export async function issueRecoveryCodes(userId: string): Promise<string[]> {
const codes = Array.from({ length: RECOVERY_CODE_COUNT }, generateCode);
await prisma.$transaction([
prisma.recoveryCode.deleteMany({ where: { userId } }),
prisma.recoveryCode.createMany({
data: codes.map((code) => ({ userId, codeHash: hashCode(code) })),
}),
]);
return codes;
}
/**
* Loest einen Code ein. Ein Code gilt genau einmal; verbraucht wird er auch
* dann, wenn die Anmeldung danach aus anderem Grund scheitert – lieber ein
* Code zu viel verbraucht als einer, der zweimal geht.
*/
export async function redeemRecoveryCode(userId: string, input: string): Promise<boolean> {
const eingabe = normalizeCode(input);
if (eingabe.length < GROUP * 2) return false;
const offen = await prisma.recoveryCode.findMany({
where: { userId, usedAt: null },
select: { id: true, codeHash: true },
});
const gesucht = Buffer.from(hashCode(eingabe), "utf8");
let treffer: string | null = null;
for (const code of offen) {
const vorhanden = Buffer.from(code.codeHash, "utf8");
// Nicht abbrechen, sobald es passt: die Laufzeit soll nicht verraten,
// an welcher Stelle der Liste der Code stand.
if (vorhanden.length === gesucht.length && timingSafeEqual(vorhanden, gesucht)) {
treffer = code.id;
}
}
if (!treffer) return false;
// updateMany mit der Bedingung "noch unbenutzt": zwei gleichzeitige
// Versuche mit demselben Code koennen so nicht beide durchkommen.
const result = await prisma.recoveryCode.updateMany({
where: { id: treffer, usedAt: null },
data: { usedAt: new Date() },
});
return result.count === 1;
}
/** Wie viele Codes noch ungenutzt sind – fuer den Hinweis im Konto. */
export async function countUnusedRecoveryCodes(userId: string): Promise<number> {
return prisma.recoveryCode.count({ where: { userId, usedAt: null } });
}