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
143 lines
4.5 KiB
TypeScript
143 lines
4.5 KiB
TypeScript
'use server'
|
|
|
|
import { AuthError } from 'next-auth'
|
|
import { headers } from 'next/headers'
|
|
import { z } from 'zod'
|
|
import { signIn } from '@/auth'
|
|
import { anmeldungErlaubt } from '@/lib/auth/ratelimit'
|
|
import { anschlussAus } from '@/lib/auth/anschluss'
|
|
|
|
/**
|
|
* Server Action fuer das Anmeldeformular.
|
|
*
|
|
* Die Fehlermeldung ist absichtlich unspezifisch: Ob die E-Mail unbekannt
|
|
* ist oder das Passwort falsch, geht niemanden etwas an, der davorsitzt.
|
|
* Nur die Sperre wird konkret benannt - sonst probiert man ratlos weiter.
|
|
*/
|
|
|
|
const AnmeldeSchema = z.object({
|
|
email: z.string().trim().toLowerCase().email('Bitte eine gültige E-Mail-Adresse eingeben.'),
|
|
passwort: z.string().min(1, 'Bitte das Passwort eingeben.'),
|
|
code: z.string().trim().default(''),
|
|
weiter: z.string().default('/heute'),
|
|
})
|
|
|
|
export type AnmeldeZustand = {
|
|
fehler?: string
|
|
felder?: { email?: string; passwort?: string }
|
|
/** Vorbelegung, damit die E-Mail nach einem Fehlversuch stehen bleibt. */
|
|
email?: string
|
|
}
|
|
|
|
/**
|
|
* Erkennt die Weiterleitung, die Next.js intern als Ausnahme wirft.
|
|
* Bewusst 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')
|
|
)
|
|
}
|
|
|
|
/** Siehe src/lib/auth/anschluss.ts - der Kopf wird nur mit Proxy geglaubt. */
|
|
async function ipDerAnfrage(): Promise<string | null> {
|
|
try {
|
|
return anschlussAus(await headers())
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/** Verhindert offene Weiterleitungen auf fremde Seiten. */
|
|
function sicheresZiel(ziel: string): string {
|
|
if (!ziel.startsWith('/') || ziel.startsWith('//')) return '/heute'
|
|
return ziel
|
|
}
|
|
|
|
function fehlertext(code: string): string {
|
|
const [grund, wert] = code.split(':')
|
|
|
|
switch (grund) {
|
|
case 'gesperrt': {
|
|
const sekunden = Number.parseInt(wert ?? '0', 10)
|
|
const minuten = Math.ceil(sekunden / 60)
|
|
return (
|
|
`Zu viele Fehlversuche. Aus Sicherheitsgründen ist die Anmeldung für ` +
|
|
`${minuten > 1 ? `etwa ${minuten} Minuten` : 'kurze Zeit'} gesperrt.`
|
|
)
|
|
}
|
|
case 'konto-inaktiv':
|
|
return 'Dieser Zugang ist deaktiviert. Bitte wende dich an den Administrator.'
|
|
case 'code-falsch':
|
|
return (
|
|
'Der Code stimmt nicht. Prüfe, ob die Uhrzeit deines Geräts stimmt — ' +
|
|
'TOTP-Codes hängen an der Uhr. Alternativ kannst du einen Wiederherstellungscode eingeben.'
|
|
)
|
|
default:
|
|
return 'E-Mail-Adresse, Passwort oder Code stimmen nicht.'
|
|
}
|
|
}
|
|
|
|
export async function anmelden(
|
|
_vorher: AnmeldeZustand,
|
|
formular: FormData,
|
|
): Promise<AnmeldeZustand> {
|
|
const gelesen = AnmeldeSchema.safeParse({
|
|
email: formular.get('email'),
|
|
passwort: formular.get('passwort'),
|
|
code: formular.get('code'),
|
|
weiter: formular.get('weiter') ?? '/heute',
|
|
})
|
|
|
|
if (!gelesen.success) {
|
|
const felder = gelesen.error.flatten().fieldErrors
|
|
return {
|
|
fehler: 'Bitte prüfe deine Eingaben.',
|
|
felder: { email: felder.email?.[0], passwort: felder.passwort?.[0] },
|
|
email: String(formular.get('email') ?? ''),
|
|
}
|
|
}
|
|
|
|
const { email, passwort, code, weiter } = gelesen.data
|
|
|
|
// Die Sperre wird hier geprueft und nicht erst in authorize().
|
|
//
|
|
// Grund: Auth.js reicht den Fehlercode aus authorize() nicht
|
|
// unveraendert an die Server Action zurueck - man saehe auch bei
|
|
// gesperrtem Zugang nur die allgemeine Meldung und wuerde ratlos
|
|
// weiterprobieren. Die Pruefung in authorize() bleibt trotzdem
|
|
// bestehen: sie ist die eigentliche Sperre, diese hier ist die
|
|
// Erklaerung dafuer.
|
|
const sperre = await anmeldungErlaubt(email, await ipDerAnfrage())
|
|
if (sperre.gesperrt) {
|
|
return { fehler: fehlertext(`gesperrt:${sperre.nochSekunden}`), email }
|
|
}
|
|
|
|
try {
|
|
await signIn('zugangsdaten', {
|
|
email,
|
|
passwort,
|
|
code,
|
|
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 : 'daten-falsch'
|
|
return { fehler: fehlertext(code), email }
|
|
}
|
|
|
|
throw fehler
|
|
}
|
|
|
|
// Wird nicht erreicht - signIn leitet weiter.
|
|
return {}
|
|
}
|