Passkeys (WebAuthn/FIDO2) als zweiter Anmeldeweg - auf der Anmeldeseite und bei der erneuten Bestaetigung vor sensiblen Aktionen. Verwaltet unter "Mein Konto". Der Passkey ERSETZT den zweiten Faktor, statt zu ihm hinzuzukommen. Das ist tragbar, weil er zwei Faktoren in sich vereint: Besitz des Geraets und dessen Entsperrung. Damit das keine Annahme bleibt, wird die Nutzerverifikation angefordert (userVerification: 'required') und serverseitig nachgeprueft - eine Antwort ohne sie wird abgewiesen. TOTP muss vorher eingerichtet sein. Ein Passkey haengt an einem Geraet; waere er der einzige Weg hinein, koennte ein verlorenes Handy die Akte schliessen. Aufbau: - Die Pruefung laeuft vollstaendig im Credentials-Provider "passkey", nicht in der Server Action davor. Ein "war schon geprueft" ueber die Grenze zu reichen waere die Stelle, an der sich Anmeldungen faelschen lassen. - Challenges liegen serverseitig und werden per DELETE genau einmal verbraucht - die Datenbank entscheidet, wer sie bekommt. - Bereichskennung und Herkunft kommen aus AUTH_URL, nicht aus einer zweiten Konfigurationsquelle. - Ohne https wird der Knopf gar nicht erst angeboten. - Rate-Limiting, Kontosperre und Audit-Log gelten unveraendert; die Anmeldung nennt den verwendeten Passkey namentlich. Zwei Anmerkungen zur Migration: - "prisma migrate diff" wollte zusaetzlich die Trigramm-Indizes aus prisma/sql/suche.sql loeschen, die Prisma nicht kennt. Die Zeilen sind entfernt und im Migrationskopf vermerkt - ein DROP haette die unscharfe Suche stillgelegt, ohne dass etwas fehlgeschlagen waere. - "prisma format" hat das Schema durchgaengig neu ausgerichtet. Ohne Whitespace betrachtet sind es 88 hinzugefuegte Zeilen und keine geloeschte. Geprueft: 21 Einheitstests, drei End-to-End-Tests gegen Chromiums virtuellen Authenticator (einrichten, anmelden, bestaetigen, entfernen, Geraet ohne Verifikation abgewiesen), beide Geraeteprofile. 496 Tests, null Typfehler, Produktionsbau steht. Nebenbei: Die Zahlen im README waren veraltet (24 statt 33 Modelle, 46 statt 79 Seiten). Sie stehen jetzt mit dem Befehl daneben, mit dem man sie nachzaehlt.
373 lines
13 KiB
TypeScript
373 lines
13 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, wiederherstellungscodeFinden } from '@/lib/crypto/hash'
|
|
import { totpPruefen, totpSecretEntschluesseln } from '@/lib/crypto/totp'
|
|
import {
|
|
anmeldungErlaubt,
|
|
aufMindestdauerWarten,
|
|
fehlversucheZuruecksetzen,
|
|
versuchFesthalten,
|
|
} from '@/lib/auth/ratelimit'
|
|
import { protokollieren } from '@/lib/audit'
|
|
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
|
|
const weitergeleitet = anfrage.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
|
return weitergeleitet || anfrage.headers.get('x-real-ip') || null
|
|
}
|
|
|
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
|
trustHost: true,
|
|
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 versuchFesthalten(email, ip, false, 'gesperrt')
|
|
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. Ohne eingerichtetes TOTP kommt der Benutzer
|
|
// zwar herein, das App-Layout schickt ihn aber sofort zur
|
|
// Einrichtung - er kann nichts anderes tun.
|
|
if (benutzer.totpSecretVerschluesselt && benutzer.totpAktiviertAm) {
|
|
const codeOhneTrenner = code.replace(/[\s-]/g, '')
|
|
let zweiterFaktorOk = false
|
|
|
|
if (/^\d{6}$/.test(codeOhneTrenner)) {
|
|
const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt)
|
|
zweiterFaktorOk = totpPruefen(secret, benutzer.email, codeOhneTrenner).gueltig
|
|
} else if (codeOhneTrenner.length >= 12) {
|
|
// Wiederherstellungscode - wird nach Gebrauch entfernt.
|
|
const treffer = await wiederherstellungscodeFinden(
|
|
benutzer.wiederherstellungscodes,
|
|
code,
|
|
)
|
|
if (treffer >= 0) {
|
|
zweiterFaktorOk = true
|
|
const rest = benutzer.wiederherstellungscodes.filter((_, i) => i !== treffer)
|
|
await db.user.update({
|
|
where: { id: benutzer.id },
|
|
data: { wiederherstellungscodes: rest },
|
|
})
|
|
}
|
|
}
|
|
|
|
if (!zweiterFaktorOk) {
|
|
await versuchFesthalten(email, ip, false, 'code-falsch')
|
|
await protokollieren({
|
|
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
|
|
entitaet: 'User',
|
|
entitaetId: benutzer.id,
|
|
userEmail: email,
|
|
userId: benutzer.id,
|
|
bezeichnung: '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 versuchFesthalten(benutzer.email, ip, false, 'gesperrt')
|
|
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,
|
|
})
|
|
}
|
|
},
|
|
},
|
|
})
|