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
359 lines
12 KiB
TypeScript
359 lines
12 KiB
TypeScript
import NextAuth, { CredentialsSignin } from 'next-auth'
|
|
import Credentials from 'next-auth/providers/credentials'
|
|
import type { Rolle } from '@prisma/client'
|
|
import { db } from '@/lib/db'
|
|
import { env } from '@/lib/env'
|
|
import { hashVeraltet, passwortHashen, passwortPruefen } from '@/lib/crypto/hash'
|
|
import {
|
|
anmeldungErlaubt,
|
|
aufMindestdauerWarten,
|
|
fehlversucheZuruecksetzen,
|
|
sperreVermerken,
|
|
versuchFesthalten,
|
|
} from '@/lib/auth/ratelimit'
|
|
import { protokollieren } from '@/lib/audit'
|
|
import { zweitenFaktorEinloesen } from '@/lib/auth/zweiter-faktor'
|
|
import { anschlussAus } from '@/lib/auth/anschluss'
|
|
import { PasskeyFehler, anmeldungPruefen } from '@/lib/auth/passkey'
|
|
import type { AuthenticationResponseJSON } from '@simplewebauthn/server'
|
|
|
|
/**
|
|
* Anmeldung: E-Mail + Passwort + TOTP - alle drei zusammen, in einem Schritt.
|
|
*
|
|
* Warum in einem Schritt und nicht zweistufig? Ein zweistufiger Ablauf muesste
|
|
* zwischen den Schritten einen Zwischenzustand halten ("Passwort war richtig,
|
|
* warte auf Code"). Genau dieser Zustand ist die typische Schwachstelle. Ein
|
|
* Formular mit drei Feldern ist am Handy ausserdem schneller ausgefuellt.
|
|
*
|
|
* Der zweite Faktor ist Pflicht. Ist er noch nicht eingerichtet, kommt der
|
|
* Benutzer nur bis zur Einrichtungsseite - erzwungen im App-Layout.
|
|
*/
|
|
|
|
export type AnmeldeGrund =
|
|
| 'daten-falsch'
|
|
| 'gesperrt'
|
|
| 'konto-inaktiv'
|
|
| 'code-falsch'
|
|
/** Passkey-Fehler; der genaue Grund haengt mit Doppelpunkt daran. */
|
|
| 'passkey'
|
|
|
|
/**
|
|
* Auth.js transportiert Fehler aus authorize() nur ueber das Feld "code".
|
|
* Deshalb erbt der Fehler von CredentialsSignin und kodiert eine etwaige
|
|
* Restsperrzeit in den Code hinein: "gesperrt:120".
|
|
*/
|
|
export class AnmeldeFehler extends CredentialsSignin {
|
|
code: string
|
|
|
|
constructor(grund: AnmeldeGrund, nochSekunden?: number) {
|
|
super(grund)
|
|
this.code = nochSekunden ? `${grund}:${nochSekunden}` : grund
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wie AnmeldeFehler, nur fuer Passkeys: der Grund wird als "passkey:<grund>"
|
|
* kodiert, damit die Anmeldeseite die passende Erklaerung zeigen kann.
|
|
*/
|
|
export class PasskeyAnmeldeFehler extends CredentialsSignin {
|
|
code: string
|
|
|
|
constructor(grund: string) {
|
|
super(grund)
|
|
this.code = `passkey:${grund}`
|
|
}
|
|
}
|
|
|
|
function ipAusAnfrage(anfrage: Request | undefined): string | null {
|
|
if (!anfrage) return null
|
|
return anschlussAus(anfrage.headers)
|
|
}
|
|
|
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
|
// Siehe env.vertraueHost: Ist AUTH_URL gesetzt, wird dem Host-Kopf nicht
|
|
// geglaubt. Vorher stand hier fest "true".
|
|
trustHost: env.vertraueHost,
|
|
secret: process.env.AUTH_SECRET,
|
|
|
|
session: {
|
|
strategy: 'jwt',
|
|
// Leerlauf bis zur automatischen Abmeldung.
|
|
maxAge: env.sitzungMaxAlterSekunden,
|
|
// Bei jeder Anfrage verlaengern, solange gearbeitet wird.
|
|
updateAge: 60,
|
|
},
|
|
|
|
pages: {
|
|
signIn: '/anmelden',
|
|
error: '/anmelden',
|
|
},
|
|
|
|
providers: [
|
|
Credentials({
|
|
id: 'zugangsdaten',
|
|
name: 'E-Mail, Passwort und Code',
|
|
credentials: {
|
|
email: { label: 'E-Mail', type: 'email' },
|
|
passwort: { label: 'Passwort', type: 'password' },
|
|
code: { label: 'Sechsstelliger Code', type: 'text' },
|
|
},
|
|
|
|
async authorize(daten, anfrage) {
|
|
const begonnen = Date.now()
|
|
const email = String(daten?.email ?? '').trim().toLowerCase()
|
|
const passwort = String(daten?.passwort ?? '')
|
|
const code = String(daten?.code ?? '')
|
|
const ip = ipAusAnfrage(anfrage)
|
|
|
|
if (!email || !passwort) {
|
|
await aufMindestdauerWarten(begonnen)
|
|
throw new AnmeldeFehler('daten-falsch')
|
|
}
|
|
|
|
// 1. Sperre pruefen, bevor teuer gerechnet wird.
|
|
const sperre = await anmeldungErlaubt(email, ip)
|
|
if (sperre.gesperrt) {
|
|
await sperreVermerken(email, ip)
|
|
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden)
|
|
}
|
|
|
|
const benutzer = await db.user.findUnique({ where: { email } })
|
|
|
|
// 2. Passwort pruefen. Auch bei unbekanntem Benutzer wird gerechnet,
|
|
// damit die Antwortzeit nichts verraet.
|
|
const hashZumPruefen =
|
|
benutzer?.passwortHash ??
|
|
'$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
|
const passwortStimmt = await passwortPruefen(hashZumPruefen, passwort)
|
|
|
|
if (!benutzer || !passwortStimmt) {
|
|
await versuchFesthalten(email, ip, false, benutzer ? 'passwort-falsch' : 'unbekannt')
|
|
await protokollieren({
|
|
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
|
|
entitaet: 'User',
|
|
entitaetId: benutzer?.id ?? null,
|
|
userEmail: email,
|
|
})
|
|
await aufMindestdauerWarten(begonnen)
|
|
throw new AnmeldeFehler('daten-falsch')
|
|
}
|
|
|
|
if (!benutzer.aktiv || benutzer.geloeschtAm) {
|
|
await versuchFesthalten(email, ip, false, 'konto-inaktiv')
|
|
await aufMindestdauerWarten(begonnen)
|
|
throw new AnmeldeFehler('konto-inaktiv')
|
|
}
|
|
|
|
// 3. Zweiter Faktor. Ist er noch nicht eingerichtet, kommt der
|
|
// Benutzer nur bis zur Einrichtungsseite - erzwungen in
|
|
// erfordereBenutzer(), nicht erst im Layout.
|
|
if (benutzer.totpSecretVerschluesselt && benutzer.totpAktiviertAm) {
|
|
const eingeloest = await zweitenFaktorEinloesen(benutzer, code)
|
|
|
|
if (!eingeloest.ok) {
|
|
await versuchFesthalten(email, ip, false, 'code-falsch')
|
|
await protokollieren({
|
|
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
|
|
entitaet: 'User',
|
|
entitaetId: benutzer.id,
|
|
userEmail: email,
|
|
userId: benutzer.id,
|
|
bezeichnung:
|
|
eingeloest.grund === 'wiederverwendet'
|
|
? 'Zweiter Faktor bereits verbraucht'
|
|
: 'Zweiter Faktor falsch',
|
|
})
|
|
await aufMindestdauerWarten(begonnen)
|
|
throw new AnmeldeFehler('code-falsch')
|
|
}
|
|
}
|
|
|
|
// 4. Erfolg.
|
|
if (hashVeraltet(benutzer.passwortHash)) {
|
|
// Parameter wurden inzwischen erhoeht - Gelegenheit zum Nachziehen.
|
|
await db.user.update({
|
|
where: { id: benutzer.id },
|
|
data: { passwortHash: await passwortHashen(passwort) },
|
|
})
|
|
}
|
|
|
|
await Promise.all([
|
|
versuchFesthalten(email, ip, true),
|
|
fehlversucheZuruecksetzen(email),
|
|
db.user.update({
|
|
where: { id: benutzer.id },
|
|
data: { letzteAnmeldung: new Date() },
|
|
}),
|
|
protokollieren({
|
|
aktion: 'ANMELDUNG',
|
|
entitaet: 'User',
|
|
entitaetId: benutzer.id,
|
|
userId: benutzer.id,
|
|
userEmail: benutzer.email,
|
|
userName: benutzer.name,
|
|
}),
|
|
])
|
|
|
|
await aufMindestdauerWarten(begonnen)
|
|
|
|
return {
|
|
id: benutzer.id,
|
|
email: benutzer.email,
|
|
name: benutzer.name,
|
|
rolle: benutzer.rolle,
|
|
sitzungsGeneration: benutzer.sitzungsGeneration,
|
|
zweiterFaktorAktiv: Boolean(benutzer.totpAktiviertAm),
|
|
}
|
|
},
|
|
}),
|
|
|
|
/**
|
|
* Anmeldung mit Passkey - ein Schritt, ohne E-Mail und ohne Passwort.
|
|
*
|
|
* Die vollstaendige Pruefung laeuft hier drin und nicht in der Server
|
|
* Action davor. Das ist Absicht: Wuerde die Action pruefen und das
|
|
* Ergebnis anschliessend an signIn() weiterreichen, gaebe es einen
|
|
* Zwischenzustand, dem authorize() glauben muesste. Genau solche
|
|
* "war schon geprueft"-Uebergaben sind die Stelle, an der sich
|
|
* Anmeldungen faelschen lassen. Hier gibt es sie nicht.
|
|
*/
|
|
Credentials({
|
|
id: 'passkey',
|
|
name: 'Passkey',
|
|
credentials: {
|
|
antwort: { label: 'Antwort des Authenticators', type: 'text' },
|
|
},
|
|
|
|
async authorize(daten, anfrage) {
|
|
const ip = ipAusAnfrage(anfrage)
|
|
|
|
let antwort: AuthenticationResponseJSON
|
|
try {
|
|
antwort = JSON.parse(String(daten?.antwort ?? '')) as AuthenticationResponseJSON
|
|
} catch {
|
|
throw new PasskeyAnmeldeFehler('ungueltig')
|
|
}
|
|
|
|
let geprueft
|
|
try {
|
|
geprueft = await anmeldungPruefen(antwort)
|
|
} catch (fehler) {
|
|
// Fehlversuche zaehlen mit, auch wenn der Zugang unbekannt ist -
|
|
// sonst waere der Passkey-Weg ein Schlupfloch an der Sperre vorbei.
|
|
await versuchFesthalten('(passkey)', ip, false, 'passkey-ungueltig')
|
|
await protokollieren({
|
|
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
|
|
entitaet: 'User',
|
|
userEmail: '(passkey)',
|
|
bezeichnung:
|
|
fehler instanceof PasskeyFehler ? `Passkey: ${fehler.grund}` : 'Passkey abgewiesen',
|
|
})
|
|
throw new PasskeyAnmeldeFehler(
|
|
fehler instanceof PasskeyFehler ? fehler.grund : 'ungueltig',
|
|
)
|
|
}
|
|
|
|
const { benutzer, passkey } = geprueft
|
|
|
|
// Die Sperre gilt fuer den Zugang, nicht fuer das Verfahren. Sie
|
|
// wird erst hier geprueft, weil vorher niemand weiss, wer sich
|
|
// anmeldet - erraten laesst sich ein Passkey ohnehin nicht.
|
|
const sperre = await anmeldungErlaubt(benutzer.email, ip)
|
|
if (sperre.gesperrt) {
|
|
await sperreVermerken(benutzer.email, ip)
|
|
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden)
|
|
}
|
|
|
|
if (!benutzer.aktiv || benutzer.geloeschtAm) {
|
|
await versuchFesthalten(benutzer.email, ip, false, 'konto-inaktiv')
|
|
throw new AnmeldeFehler('konto-inaktiv')
|
|
}
|
|
|
|
await Promise.all([
|
|
versuchFesthalten(benutzer.email, ip, true, 'passkey'),
|
|
fehlversucheZuruecksetzen(benutzer.email),
|
|
db.user.update({
|
|
where: { id: benutzer.id },
|
|
data: { letzteAnmeldung: new Date() },
|
|
}),
|
|
protokollieren({
|
|
aktion: 'ANMELDUNG',
|
|
entitaet: 'User',
|
|
entitaetId: benutzer.id,
|
|
userId: benutzer.id,
|
|
userEmail: benutzer.email,
|
|
userName: benutzer.name,
|
|
bezeichnung: `Mit Passkey „${passkey.name}“`,
|
|
}),
|
|
])
|
|
|
|
return {
|
|
id: benutzer.id,
|
|
email: benutzer.email,
|
|
name: benutzer.name,
|
|
rolle: benutzer.rolle,
|
|
sitzungsGeneration: benutzer.sitzungsGeneration,
|
|
// Ein Passkey mit Nutzerverifikation IST der zweite Faktor. Ohne
|
|
// eingerichtetes TOTP laesst er sich gar nicht erst anlegen, das
|
|
// Flag ist hier also immer wahr - es wird trotzdem aus dem
|
|
// Benutzer gelesen und nicht fest gesetzt.
|
|
zweiterFaktorAktiv: Boolean(benutzer.totpAktiviertAm),
|
|
}
|
|
},
|
|
}),
|
|
],
|
|
|
|
callbacks: {
|
|
async jwt({ token, user, trigger, session }) {
|
|
if (user) {
|
|
token.rolle = user.rolle
|
|
token.sitzungsGeneration = user.sitzungsGeneration
|
|
token.zweiterFaktorAktiv = user.zweiterFaktorAktiv
|
|
// Zeitpunkt der vollstaendigen Anmeldung - Grundlage fuer die
|
|
// erneute Bestaetigung vor sensiblen Aktionen.
|
|
token.angemeldetSeit = Math.floor(Date.now() / 1000)
|
|
}
|
|
|
|
// Nach dem Einrichten des zweiten Faktors aktualisiert die Seite
|
|
// die Sitzung, ohne dass man sich neu anmelden muss.
|
|
if (trigger === 'update' && session) {
|
|
const aktualisierung = session as Partial<{
|
|
zweiterFaktorAktiv: boolean
|
|
sitzungsGeneration: number
|
|
}>
|
|
if (typeof aktualisierung.zweiterFaktorAktiv === 'boolean') {
|
|
token.zweiterFaktorAktiv = aktualisierung.zweiterFaktorAktiv
|
|
}
|
|
if (typeof aktualisierung.sitzungsGeneration === 'number') {
|
|
token.sitzungsGeneration = aktualisierung.sitzungsGeneration
|
|
}
|
|
}
|
|
|
|
return token
|
|
},
|
|
|
|
async session({ session, token }) {
|
|
session.user.id = token.sub ?? ''
|
|
session.user.rolle = (token.rolle as Rolle) ?? 'BETREUER'
|
|
session.user.sitzungsGeneration = (token.sitzungsGeneration as number) ?? 0
|
|
session.user.zweiterFaktorAktiv = Boolean(token.zweiterFaktorAktiv)
|
|
session.user.angemeldetSeit = (token.angemeldetSeit as number) ?? 0
|
|
return session
|
|
},
|
|
},
|
|
|
|
events: {
|
|
async signOut(nachricht) {
|
|
const userId = 'token' in nachricht ? nachricht.token?.sub : undefined
|
|
if (userId) {
|
|
await protokollieren({
|
|
aktion: 'ABMELDUNG',
|
|
entitaet: 'User',
|
|
entitaetId: userId,
|
|
userId,
|
|
})
|
|
}
|
|
},
|
|
},
|
|
})
|