import 'server-only' import type { User } from '@prisma/client' import { db } from '@/lib/db' import { wiederherstellungscodeFinden } from '@/lib/crypto/hash' import { totpPruefen, totpSchritt, totpSecretEntschluesseln } from '@/lib/crypto/totp' /** * Einloesen des zweiten Faktors - einmal und nur einmal. * * Ein TOTP-Code gilt 30 Sekunden, mit der Toleranz von einem Fenster bis zu * 90. Wer ihn in dieser Zeit noch einmal einreicht, kommt ohne die * Toleranz ein zweites Mal herein: abgelesen ueber die Schulter, * mitgeschrieben von einem Zwischenglied, oder aus einem Formular, das im * Browserverlauf steht. Der Kommentar in totp.ts versprach seit dem Anfang, * der Aufrufer speichere das getroffene Fenster - getan hat es niemand. * * Deshalb hier: Der Schritt wird festgehalten, und angenommen wird nur ein * groesserer. Damit ist jeder Code genau einmal gut. * * ## Warum updateMany und nicht update * * Die Bedingung steht im WHERE, nicht in einem if davor. Zwei Anfragen mit * demselben Code, gleichzeitig abgeschickt, wuerden sonst beide den * Vergleich bestehen, bevor eine von ihnen schreibt. So entscheidet die * Datenbank: Genau eine trifft eine Zeile, die andere keine. */ export type ZweiterFaktorErgebnis = | { ok: true; art: 'totp' | 'wiederherstellungscode' } | { ok: false; grund: 'falsch' | 'wiederverwendet' } /** Ab dieser Laenge wird die Eingabe als Wiederherstellungscode gelesen. */ const CODE_MINDESTLAENGE = 12 /** * Prueft die Eingabe und verbraucht sie. * * Nimmt sowohl einen sechsstelligen TOTP-Code als auch einen * Wiederherstellungscode. Letzterer wird nach Gebrauch aus der Liste * entfernt - auch das eine Einmalverwendung, nur mit anderem Mechanismus. * * @param benutzer Der Datensatz aus der Datenbank, nicht aus der Sitzung. * @param eingabe Was im Formular stand, ungereinigt. */ export async function zweitenFaktorEinloesen( benutzer: User, eingabe: string, ): Promise { const ohneTrenner = eingabe.replace(/[\s-]/g, '') if (/^\d{6}$/.test(ohneTrenner)) { if (!benutzer.totpSecretVerschluesselt) return { ok: false, grund: 'falsch' } const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt) const { gueltig, delta } = totpPruefen(secret, benutzer.email, ohneTrenner) if (!gueltig || delta === null) return { ok: false, grund: 'falsch' } const schritt = totpSchritt(delta) const { count } = await db.user.updateMany({ where: { id: benutzer.id, OR: [{ totpLetzterSchritt: null }, { totpLetzterSchritt: { lt: schritt } }], }, data: { totpLetzterSchritt: schritt }, }) // Keine Zeile getroffen: Dieser Schritt war schon einmal dran. Der Code // ist rechnerisch richtig und trotzdem verbraucht. if (count === 0) return { ok: false, grund: 'wiederverwendet' } return { ok: true, art: 'totp' } } if (ohneTrenner.length >= CODE_MINDESTLAENGE) { const treffer = await wiederherstellungscodeFinden(benutzer.wiederherstellungscodes, eingabe) if (treffer < 0) return { ok: false, grund: 'falsch' } // Auch hier die Bedingung in die Abfrage: Sonst koennten zwei // gleichzeitige Anmeldungen denselben Code einloesen, weil beide noch // die alte Liste sehen. const rest = benutzer.wiederherstellungscodes.filter((_, i) => i !== treffer) const { count } = await db.user.updateMany({ where: { id: benutzer.id, wiederherstellungscodes: { equals: benutzer.wiederherstellungscodes } }, data: { wiederherstellungscodes: rest }, }) if (count === 0) return { ok: false, grund: 'wiederverwendet' } return { ok: true, art: 'wiederherstellungscode' } } return { ok: false, grund: 'falsch' } }