Anmeldung haerten: Einmalcodes, Anschluss, Host, Passkey-Vorzimmer
Vier Stellen, an denen eine Zusage nicht gehalten wurde. 1. Ein TOTP-Code galt mehrfach. Der Kommentar in totp.ts versprach seit dem Anfang, der Aufrufer speichere das getroffene Zeitfenster - getan hat es niemand. Damit liess sich ein abgelesener Code innerhalb von 90 Sekunden noch einmal einloesen. Jetzt geht jede Pruefung durch zweitenFaktorEinloesen(), und die Bedingung steht im WHERE von updateMany: Zwei gleichzeitige Anmeldungen mit demselben Code treffen nicht beide eine Zeile. 2. Der Anschluss kam aus dem ERSTEN Eintrag von "x-forwarded-for". Das ist der Teil, den der Aufrufer selbst schreibt. Wer bei jeder Anfrage eine andere Zahl hineinschrieb, sah fuer die Anmeldesperre wie tausend Anschluesse aus - die Zaehlung war nicht nur wirkungslos, sondern irrefuehrend. Jetzt von hinten gelesen, und nur wenn VERTRAUE_PROXY gesetzt ist. Ohne Proxy ist der Kopf fremdbestimmt; dann lieber keine Zaehlung nach Anschluss als eine, die eine Kopfzeile aushebelt. Beim ersten Mal steht eine Zeile im Log, damit das Fehlen auffaellt. 3. trustHost stand fest auf true. Ist AUTH_URL gesetzt - der dokumentierte Betrieb setzt es, Passkeys brauchen es -, gibt es keinen Grund, die Adresse aus einer Kopfzeile von aussen abzuleiten. AUTH_TRUST_HOST bleibt wirksam, damit die dokumentierte Einstellung nicht stillschweigend uebergangen wird. 4. Passkeys liessen sich ohne erneute Bestaetigung anlegen und entfernen. Ein Passkey ist ein vollwertiger Zugang, der einen Passwortwechsel ueberlebt - an einem unbeaufsichtigten Rechner waren das zwei Klicks. Jetzt dasselbe Vorzimmer wie beim Vollexport. Dabei musste alsFehler() lernen, die Weiterleitung von Next durchzulassen: Sonst verschwand die Zwischenfrage in "Der Passkey konnte nicht geprueft werden". Refs #38
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
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<ZweiterFaktorErgebnis> {
|
||||
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' }
|
||||
}
|
||||
Reference in New Issue
Block a user