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
79 lines
2.7 KiB
TypeScript
79 lines
2.7 KiB
TypeScript
'use server'
|
|
|
|
import { redirect } from 'next/navigation'
|
|
import { z } from 'zod'
|
|
import { db } from '@/lib/db'
|
|
import { erfordereBenutzer, reauthBestaetigen } from '@/lib/auth/sitzung'
|
|
import { passwortPruefen } from '@/lib/crypto/hash'
|
|
import { zweitenFaktorEinloesen } from '@/lib/auth/zweiter-faktor'
|
|
import { aufMindestdauerWarten } from '@/lib/auth/ratelimit'
|
|
|
|
/**
|
|
* Erneute Bestaetigung vor sensiblen Aktionen.
|
|
*
|
|
* Betroffen sind: Vollexport, endgueltiges Loeschen, Benutzerverwaltung,
|
|
* Aendern von Rollen und Freigaben, neue Wiederherstellungscodes.
|
|
*
|
|
* Warum ueberhaupt? Eine offene Sitzung an einem unbeaufsichtigten Rechner
|
|
* ist der wahrscheinlichste Angriffsweg in diesem Umfeld - nicht der
|
|
* Einbruch in den Server. Wer alle Gesundheits- und Finanzdaten in einer
|
|
* ZIP-Datei mitnehmen will, soll dafuer Passwort und Code erneut eingeben.
|
|
*/
|
|
|
|
const BestaetigungSchema = z.object({
|
|
passwort: z.string().min(1, 'Bitte dein Passwort eingeben.'),
|
|
code: z.string().trim(),
|
|
weiter: z.string().default('/heute'),
|
|
})
|
|
|
|
export type BestaetigenZustand = { fehler?: string }
|
|
|
|
function sicheresZiel(ziel: string): string {
|
|
if (!ziel.startsWith('/') || ziel.startsWith('//')) return '/heute'
|
|
return ziel
|
|
}
|
|
|
|
export async function anmeldungBestaetigen(
|
|
_vorher: BestaetigenZustand,
|
|
formular: FormData,
|
|
): Promise<BestaetigenZustand> {
|
|
const begonnen = Date.now()
|
|
const benutzer = await erfordereBenutzer()
|
|
|
|
const gelesen = BestaetigungSchema.safeParse({
|
|
passwort: formular.get('passwort'),
|
|
code: formular.get('code'),
|
|
weiter: formular.get('weiter') ?? '/heute',
|
|
})
|
|
|
|
if (!gelesen.success) {
|
|
await aufMindestdauerWarten(begonnen)
|
|
return { fehler: 'Bitte Passwort und Code eingeben.' }
|
|
}
|
|
|
|
const frisch = await db.user.findUnique({ where: { id: benutzer.id } })
|
|
if (!frisch) return { fehler: 'Der Zugang wurde zwischenzeitlich entfernt.' }
|
|
|
|
const passwortStimmt = await passwortPruefen(frisch.passwortHash, gelesen.data.passwort)
|
|
|
|
// Der Code wird nur eingeloest, wenn das Passwort stimmt. Sonst
|
|
// verbrauchte ein falsches Passwort mit richtigem Code das Zeitfenster,
|
|
// und der Benutzer muesste dreissig Sekunden auf den naechsten warten -
|
|
// ohne zu verstehen, warum.
|
|
let codeStimmt = true
|
|
if (passwortStimmt && frisch.totpSecretVerschluesselt && frisch.totpAktiviertAm) {
|
|
const eingeloest = await zweitenFaktorEinloesen(frisch, gelesen.data.code)
|
|
codeStimmt = eingeloest.ok
|
|
}
|
|
|
|
if (!passwortStimmt || !codeStimmt) {
|
|
await aufMindestdauerWarten(begonnen)
|
|
return { fehler: 'Passwort oder Code stimmen nicht.' }
|
|
}
|
|
|
|
await reauthBestaetigen(benutzer.id)
|
|
await aufMindestdauerWarten(begonnen)
|
|
|
|
redirect(sicheresZiel(gelesen.data.weiter))
|
|
}
|