'use server' import { AuthError } from 'next-auth' import { revalidatePath } from 'next/cache' import { z } from 'zod' import type { AuthenticationResponseJSON, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON, } from '@simplewebauthn/server' import { signIn } from '@/auth' import { db } from '@/lib/db' import { protokollieren } from '@/lib/audit' import { PASSKEY_MELDUNG, PasskeyFehler, anmeldungPruefen, anmeldungVorbereiten, registrierungPruefen, registrierungVorbereiten, } from '@/lib/auth/passkey' import { erfordereBenutzer, erfordereFrischeAnmeldung, reauthBestaetigen, } from '@/lib/auth/sitzung' /** * Server Actions rund um Passkeys. * * Ein Passkey-Vorgang besteht immer aus zwei Aufrufen: erst holt der Browser * die Optionen samt Challenge, dann schickt er die Antwort des Geraets * zurueck. Dazwischen liegt der Griff zum Fingerabdrucksensor, und der * dauert unbestimmt lange - deshalb zwei Schritte und keine Sitzungsvariable. * * Die Antworten des Authenticators kommen als JSON-Zeichenkette herein, nicht * als Objekt. Grund: Was ueber die Grenze zum Server geht, wird hier * ausdruecklich geparst und geprueft, statt sich auf die Serialisierung von * React zu verlassen. */ /** Ergebnis eines Schritts, der Optionen liefert. */ export type PasskeyStart = { ok: true; optionen: T } | { ok: false; fehler: string } /** Ergebnis eines abschliessenden Schritts. */ export type PasskeyErgebnis = { ok: true; meldung?: string } | { ok: false; fehler: string } /** * Erkennt die Weiterleitung, die Next.js intern als Ausnahme wirft. * Wie in anmelden.ts ueber das digest-Feld statt ueber einen Import aus * next/dist - interne Pfade aendern sich zwischen Nebenversionen. */ function istWeiterleitung(fehler: unknown): boolean { return ( typeof fehler === 'object' && fehler !== null && 'digest' in fehler && typeof (fehler as { digest?: unknown }).digest === 'string' && (fehler as { digest: string }).digest.startsWith('NEXT_REDIRECT') ) } function alsFehler(fehler: unknown): { ok: false; fehler: string } { if (fehler instanceof PasskeyFehler) { return { ok: false, fehler: PASSKEY_MELDUNG[fehler.grund] } } console.error('[passkey] Unerwarteter Fehler:', fehler) return { ok: false, fehler: PASSKEY_MELDUNG.ungueltig } } /** * Wie alsFehler, wirft aber die Weiterleitung von Next durch. * * Ohne das verschwindet der Ruf nach einer erneuten Bestaetigung in der * allgemeinen Meldung "Der Passkey konnte nicht geprueft werden" - der * Benutzer sieht einen Fehler, wo eine Zwischenfrage gemeint war. */ function alsFehlerOderWeiter(fehler: unknown): { ok: false; fehler: string } { if (istWeiterleitung(fehler)) throw fehler return alsFehler(fehler) } /** * Parst die Antwort des Browsers. * * Absichtlich nur eine grobe Formpruefung: Was zaehlt, prueft anschliessend * die Bibliothek kryptografisch. Hier geht es nur darum, dass kein * offensichtlicher Unsinn bis dorthin durchlaeuft. */ const AntwortSchema = z.object({ id: z.string().min(1), rawId: z.string().min(1), type: z.literal('public-key'), response: z.object({ clientDataJSON: z.string().min(1) }).passthrough(), }) function antwortLesen(roh: string): T { const gelesen = AntwortSchema.safeParse(JSON.parse(roh)) if (!gelesen.success) throw new PasskeyFehler('ungueltig') return gelesen.data as T } // --------------------------------------------------------------------------- // Einrichten (angemeldet, unter "Mein Konto") // --------------------------------------------------------------------------- /** * Ein Passkey ist ein vollwertiger Zugang - wer einen anlegt, kommt danach * ohne Passwort und ohne Code herein. Deshalb dasselbe Vorzimmer wie beim * Vollexport: Die Anmeldung muss frisch sein. * * Das ist genau der Fall, gegen den die erneute Bestaetigung da ist: Eine * offene Sitzung an einem unbeaufsichtigten Rechner ist in diesem Umfeld * der wahrscheinlichste Angriffsweg. Wer daran vorbeikaeme, richtete sich * in zwei Klicks einen dauerhaften eigenen Zugang ein - und der ueberlebte * auch einen Passwortwechsel. */ export async function passkeyEinrichtungStarten(): Promise< PasskeyStart > { try { const benutzer = await erfordereBenutzer() await erfordereFrischeAnmeldung(benutzer, '/konto') return { ok: true, optionen: await registrierungVorbereiten(benutzer) } } catch (fehler) { return alsFehlerOderWeiter(fehler) } } const NameSchema = z .string() .trim() .min(1, 'Bitte gib dem Gerät einen Namen.') .max(60, 'Bitte höchstens 60 Zeichen.') export async function passkeyEinrichtungAbschliessen( name: string, antwortJson: string, ): Promise { try { const benutzer = await erfordereBenutzer() // Auch im zweiten Schritt: Zwischen Optionen holen und Antwort // einreichen liegt der Griff zum Sensor, und die Frist kann in dieser // Zeit ablaufen. Ohne die Pruefung waere der erste Schritt nur eine // Empfehlung. await erfordereFrischeAnmeldung(benutzer, '/konto') const geprueft = NameSchema.safeParse(name) if (!geprueft.success) { return { ok: false, fehler: geprueft.error.issues[0]?.message ?? 'Der Name passt nicht.', } } const antwort = antwortLesen(antwortJson) const passkey = await registrierungPruefen(benutzer, geprueft.data, antwort) await protokollieren({ aktion: 'PASSKEY_REGISTRIERT', entitaet: 'Passkey', entitaetId: passkey.id, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, bezeichnung: passkey.name, }) revalidatePath('/konto') return { ok: true, meldung: `„${passkey.name}“ ist eingerichtet.` } } catch (fehler) { return alsFehlerOderWeiter(fehler) } } // --------------------------------------------------------------------------- // Verwalten // --------------------------------------------------------------------------- /** * Entfernen braucht die frische Anmeldung genauso wie das Anlegen: Wer * einem Betreuer alle Passkeys wegnimmt, sperrt ihn von dem Weg aus, auf * dem er hereinkommt. */ export async function passkeyEntfernen(id: string): Promise { try { const benutzer = await erfordereBenutzer() await erfordereFrischeAnmeldung(benutzer, '/konto') // Zeilenweise Pruefung: Es genuegt nicht, dass die ID existiert - sie // muss zu DIESEM Benutzer gehoeren. deleteMany mit userId im WHERE // erledigt Pruefung und Loeschen in einem Zug; ein fremder Passkey // trifft dann auf null Zeilen. const passkey = await db.passkey.findFirst({ where: { id, userId: benutzer.id }, select: { id: true, name: true }, }) if (!passkey) return { ok: false, fehler: 'Dieser Passkey gehört nicht zu deinem Zugang.', } await db.passkey.delete({ where: { id: passkey.id } }) await protokollieren({ aktion: 'PASSKEY_ENTFERNT', entitaet: 'Passkey', entitaetId: passkey.id, userId: benutzer.id, userEmail: benutzer.email, userName: benutzer.name, bezeichnung: passkey.name, }) revalidatePath('/konto') return { ok: true, meldung: `„${passkey.name}“ wurde entfernt.` } } catch (fehler) { return alsFehlerOderWeiter(fehler) } } // --------------------------------------------------------------------------- // Anmelden (nicht angemeldet) // --------------------------------------------------------------------------- export async function passkeyAnmeldungStarten(): Promise< PasskeyStart > { try { return { ok: true, optionen: await anmeldungVorbereiten() } } catch (fehler) { return alsFehler(fehler) } } /** Verhindert offene Weiterleitungen auf fremde Seiten. */ function sicheresZiel(ziel: string): string { if (!ziel.startsWith('/') || ziel.startsWith('//')) return '/heute' return ziel } /** * Uebersetzt den Fehlercode aus authorize() in einen lesbaren Satz. * "passkey:" kommt aus PasskeyAnmeldeFehler, alles andere aus dem * gewoehnlichen Anmeldeweg. */ function fehlertext(code: string): string { const [art, wert] = code.split(':') if (art === 'passkey') { const grund = wert as keyof typeof PASSKEY_MELDUNG return PASSKEY_MELDUNG[grund] ?? PASSKEY_MELDUNG.ungueltig } if (art === 'gesperrt') { const minuten = Math.ceil(Number.parseInt(wert ?? '0', 10) / 60) return ( `Zu viele Fehlversuche. Aus Sicherheitsgründen ist die Anmeldung für ` + `${minuten > 1 ? `etwa ${minuten} Minuten` : 'kurze Zeit'} gesperrt.` ) } if (art === 'konto-inaktiv') { return 'Dieser Zugang ist deaktiviert. Bitte wende dich an den Administrator.' } return PASSKEY_MELDUNG.ungueltig } export async function passkeyAnmeldungAbschliessen( antwortJson: string, weiter: string, ): Promise { try { // Nur die Form pruefen; signIn() macht die eigentliche Arbeit. antwortLesen(antwortJson) } catch (fehler) { return alsFehler(fehler) } try { await signIn('passkey', { antwort: antwortJson, redirectTo: sicheresZiel(weiter), }) } catch (fehler) { // Bei Erfolg wirft signIn eine Weiterleitung - die muss durch. if (istWeiterleitung(fehler)) throw fehler if (fehler instanceof AuthError) { const code = 'code' in fehler && typeof fehler.code === 'string' ? fehler.code : 'passkey:ungueltig' return { ok: false, fehler: fehlertext(code) } } throw fehler } // Wird nicht erreicht - signIn leitet weiter. return { ok: true } } // --------------------------------------------------------------------------- // Erneut bestaetigen (angemeldet, vor sensiblen Aktionen) // --------------------------------------------------------------------------- export async function passkeyBestaetigungStarten(): Promise< PasskeyStart > { try { await erfordereBenutzer() return { ok: true, optionen: await anmeldungVorbereiten() } } catch (fehler) { return alsFehler(fehler) } } /** * Bestaetigt die laufende Sitzung mit einem Passkey. * * Wichtig ist die letzte Pruefung: Der Passkey muss dem angemeldeten * Benutzer gehoeren. Sonst koennte jemand mit seinem eigenen Passkey die * fremde Sitzung freischalten, die auf einem unbeaufsichtigten Rechner * offen steht - genau der Fall, gegen den die erneute Bestaetigung da ist. */ export async function passkeyBestaetigen(antwortJson: string): Promise { try { const benutzer = await erfordereBenutzer() const antwort = antwortLesen(antwortJson) const { benutzer: eigentuemer } = await anmeldungPruefen(antwort) if (eigentuemer.id !== benutzer.id) { await protokollieren({ aktion: 'ANMELDUNG_FEHLGESCHLAGEN', entitaet: 'User', entitaetId: benutzer.id, userId: benutzer.id, userEmail: benutzer.email, bezeichnung: 'Bestätigung mit fremdem Passkey', }) return { ok: false, fehler: 'Dieser Passkey gehört zu einem anderen Zugang als dem gerade angemeldeten.', } } await reauthBestaetigen(benutzer.id) return { ok: true } } catch (fehler) { return alsFehler(fehler) } }