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:
2026-07-26 12:36:37 +02:00
co-authored by Claude Opus 5
parent af562584ad
commit 2bff6faf3d
17 changed files with 1729 additions and 5 deletions
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import {
TOTP_PERIOD,
base32Decode,
base32Encode,
counterFor,
generateTotpSecret,
groupSecret,
otpauthUri,
totpCode,
verifyTotp,
} from "./totp";
describe("Base32", () => {
// Testvektoren aus RFC 4648, Abschnitt 10 – ohne die Auffuellzeichen,
// die Authenticator-Apps nicht erwarten.
it.each([
["", ""],
["f", "MY"],
["fo", "MZXQ"],
["foo", "MZXW6"],
["foob", "MZXW6YQ"],
["fooba", "MZXW6YTB"],
["foobar", "MZXW6YTBOI"],
])("kodiert %j als %j", (klartext, erwartet) => {
expect(base32Encode(Buffer.from(klartext, "utf8"))).toBe(erwartet);
});
it("dekodiert zurueck, auch mit Leerzeichen und Auffuellzeichen", () => {
expect(base32Decode("MZXW6YTBOI").toString("utf8")).toBe("foobar");
expect(base32Decode("mzxw 6ytb oi").toString("utf8")).toBe("foobar");
expect(base32Decode("MZXW6YTBOI======").toString("utf8")).toBe("foobar");
});
it("weist fremde Zeichen ab, statt still Unsinn zu liefern", () => {
// 0, 1 und 8 gehoeren nicht zum Alphabet – gerade sie werden aber gern
// mit O, I und B verwechselt.
expect(() => base32Decode("MZXW0YTB")).toThrow();
});
});
describe("TOTP nach RFC 6238", () => {
// Der Schluessel aus Anhang B des RFC: die ASCII-Ziffern 1234567890,
// zweimal hintereinander.
const SECRET = base32Encode(Buffer.from("12345678901234567890", "utf8"));
/**
* Der RFC nennt achtstellige Codes. Sechs Stellen sind davon die letzten
* sechs – gerechnet wird modulo 10^6 statt 10^8.
*/
it.each([
[59, "287082"],
[1111111109, "081804"],
[1111111111, "050471"],
[1234567890, "005924"],
[2000000000, "279037"],
[20000000000, "353130"],
])("liefert zum Zeitpunkt %i den Code %s", (sekunden, erwartet) => {
const counter = Math.floor(sekunden / TOTP_PERIOD);
expect(totpCode(SECRET, counter)).toBe(erwartet);
});
it("rechnet Zeitpunkte in Fenster um", () => {
expect(counterFor(new Date(59_000))).toBe(1);
expect(counterFor(new Date(1111111109_000))).toBe(37037036);
});
});
describe("verifyTotp", () => {
const SECRET = base32Encode(Buffer.from("12345678901234567890", "utf8"));
const JETZT = new Date(1111111109_000);
it("nimmt den Code des aktuellen Fensters an", () => {
expect(verifyTotp(SECRET, "081804", JETZT)).toBe(true);
});
it("nimmt auch das vorige und das naechste Fenster an", () => {
// Eine um bis zu 30 Sekunden falsch gehende Uhr soll niemanden aussperren.
const vorher = new Date(JETZT.getTime() - TOTP_PERIOD * 1000);
const nachher = new Date(JETZT.getTime() + TOTP_PERIOD * 1000);
expect(verifyTotp(SECRET, "081804", vorher)).toBe(true);
expect(verifyTotp(SECRET, "081804", nachher)).toBe(true);
});
it("lehnt ein Fenster weiter ab", () => {
const zuFrueh = new Date(JETZT.getTime() - 2 * TOTP_PERIOD * 1000);
expect(verifyTotp(SECRET, "081804", zuFrueh)).toBe(false);
});
it("lehnt falsche Codes und Unfug ab", () => {
expect(verifyTotp(SECRET, "000000", JETZT)).toBe(false);
expect(verifyTotp(SECRET, "", JETZT)).toBe(false);
expect(verifyTotp(SECRET, "81804", JETZT)).toBe(false);
expect(verifyTotp(SECRET, "abcdef", JETZT)).toBe(false);
// Fuehrende Nullen duerfen nicht als Zahl verlorengehen.
expect(verifyTotp(SECRET, "81804 ", JETZT)).toBe(false);
});
it("sieht ueber Leerzeichen in der Eingabe hinweg", () => {
expect(verifyTotp(SECRET, "081 804", JETZT)).toBe(true);
});
});
describe("Einrichtung", () => {
it("erzeugt Schluessel, die sich unterscheiden und lang genug sind", () => {
const a = generateTotpSecret();
const b = generateTotpSecret();
expect(a).not.toBe(b);
// 20 Byte ergeben 32 Base32-Zeichen.
expect(a).toHaveLength(32);
expect(base32Decode(a)).toHaveLength(20);
});
it("baut eine otpauth-URI mit Aussteller im Label und im Parameter", () => {
const uri = otpauthUri({ secret: "ABCD", account: "chefin", issuer: "Nebula" });
expect(uri).toContain("otpauth://totp/Nebula%3Achefin?");
expect(uri).toContain("secret=ABCD");
expect(uri).toContain("issuer=Nebula");
expect(uri).toContain("digits=6");
expect(uri).toContain("period=30");
});
it("gruppiert den Schluessel zum Abtippen", () => {
expect(groupSecret("ABCDEFGH")).toBe("ABCD EFGH");
});
});