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
353 lines
11 KiB
TypeScript
353 lines
11 KiB
TypeScript
'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<T> = { 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<T>(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<PublicKeyCredentialCreationOptionsJSON>
|
|
> {
|
|
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<PasskeyErgebnis> {
|
|
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<RegistrationResponseJSON>(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<PasskeyErgebnis> {
|
|
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<PublicKeyCredentialRequestOptionsJSON>
|
|
> {
|
|
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:<grund>" 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<PasskeyErgebnis> {
|
|
try {
|
|
// Nur die Form pruefen; signIn() macht die eigentliche Arbeit.
|
|
antwortLesen<AuthenticationResponseJSON>(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<PublicKeyCredentialRequestOptionsJSON>
|
|
> {
|
|
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<PasskeyErgebnis> {
|
|
try {
|
|
const benutzer = await erfordereBenutzer()
|
|
const antwort = antwortLesen<AuthenticationResponseJSON>(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)
|
|
}
|
|
}
|