Fix #38: Sicherheitsprüfung abarbeiten (v1.11.0) #39

Merged
florian.wilde merged 10 commits from fix/38-sicherheitsaudit into main 2026-08-24 10:35:20 +00:00
12 changed files with 726 additions and 64 deletions
Showing only changes of commit 609aa5d27d - Show all commits
+29 -2
View File
@@ -15,9 +15,17 @@ DATABASE_URL="postgresql://cockpit:bitte-aendern@localhost:5432/betreuungs_cockp
# 32 zufaellige Bytes, base64. Erzeugen: npm run schluessel
AUTH_SECRET=
# Oeffentliche Basis-URL der Anwendung (ohne abschliessenden Slash).
# Hinter einem Proxy die Adresse, die im Browser in der Adresszeile steht -
# nicht die interne. Passkeys pruefen dagegen.
AUTH_URL=http://localhost:3000
# Hinter einem Reverse Proxy auf true setzen.
AUTH_TRUST_HOST=true
# Darf der Host-Kopf der Anfrage die Adresse bestimmen?
#
# Solange AUTH_URL gesetzt ist: nein, und die Zeile bleibt weg. Die Adresse
# ist dann bekannt und muss nicht aus einer Kopfzeile abgeleitet werden, die
# von aussen kommt. Ein "X-Forwarded-Host: fremde.example" schlaegt sonst in
# erzeugte Adressen durch.
# AUTH_TRUST_HOST=true
# --- Erreichbarkeit: HIER MUSST DU DICH ENTSCHEIDEN -------------------------
#
@@ -73,6 +81,25 @@ LOGIN_MAX_VERSUCHE=5
LOGIN_FENSTER_SEKUNDEN=900
LOGIN_SPERRE_SEKUNDEN=900
# Geheimnis fuer die Ersteinrichtung. OHNE DIESEN WERT LAESST SICH DER ERSTE
# ZUGANG NICHT ANLEGEN - das ist Absicht.
#
# Zwischen dem ersten Start und dem Anlegen des Zugangs stand die
# Einrichtungsseite sonst offen: Wer sie in diesem Fenster findet, wird
# Administrator. Erzeugen: npm run schluessel
ERSTEINRICHTUNG_GEHEIMNIS=
# Steht ein Reverse Proxy davor?
#
# Nur mit "true" wird "X-Forwarded-For" geglaubt - und dann von HINTEN
# gelesen, weil der vordere Teil der Liste vom Aufrufer selbst kommt.
# Ohne diese Zeile zaehlt die Anmeldesperre nur nach E-Mail-Adresse.
#
# Der dokumentierte Betrieb (Reverse Proxy davor) braucht sie also:
VERTRAUE_PROXY=true
# Bei mehreren gestaffelten Proxys deren Anzahl. Sonst weglassen.
# VERTRAUE_PROXY_STUFEN=1
# --- Objektspeicher (S3-kompatibel, EU!) ------------------------------------
# Wird ab Etappe 3 verwendet. Beispiel: Hetzner Object Storage, Falkenstein.
# ACHTUNG: Mit dem Anbieter muss ein AV-Vertrag nach Art. 28 DSGVO bestehen.
+16 -31
View File
@@ -3,8 +3,7 @@ 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 { hashVeraltet, passwortHashen, passwortPruefen } from '@/lib/crypto/hash'
import {
anmeldungErlaubt,
aufMindestdauerWarten,
@@ -13,6 +12,8 @@ import {
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'
@@ -65,12 +66,13 @@ export class PasskeyAnmeldeFehler extends CredentialsSignin {
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
return anschlussAus(anfrage.headers)
}
export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true,
// 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: {
@@ -142,33 +144,13 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
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.
// 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 codeOhneTrenner = code.replace(/[\s-]/g, '')
let zweiterFaktorOk = false
const eingeloest = await zweitenFaktorEinloesen(benutzer, code)
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) {
if (!eingeloest.ok) {
await versuchFesthalten(email, ip, false, 'code-falsch')
await protokollieren({
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
@@ -176,7 +158,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
entitaetId: benutzer.id,
userEmail: email,
userId: benutzer.id,
bezeichnung: 'Zweiter Faktor falsch',
bezeichnung:
eingeloest.grund === 'wiederverwendet'
? 'Zweiter Faktor bereits verbraucht'
: 'Zweiter Faktor falsch',
})
await aufMindestdauerWarten(begonnen)
throw new AnmeldeFehler('code-falsch')
+92
View File
@@ -0,0 +1,92 @@
import 'server-only'
import { env } from '@/lib/env'
/**
* Von welchem Anschluss kommt diese Anfrage?
*
* Gebraucht wird das nur fuer eines: die zweite Dimension der Anmeldesperre.
* Gezaehlt wird nach E-Mail UND nach Anschluss, damit es weder hilft,
* dieselbe Adresse von vielen Anschluessen zu probieren, noch viele
* Adressen von einem.
*
* ## Warum das vorher nichts geschuetzt hat
*
* Der Wert kam aus "x-forwarded-for", und zwar aus dem ERSTEN Eintrag:
*
* kopf.get('x-forwarded-for')?.split(',')[0]?.trim()
*
* Dieser Kopf ist eine Liste, an die jeder Zwischenknoten hinten anhaengt.
* Der erste Eintrag ist deshalb der, den der Aufrufer selbst geschickt hat -
* frei erfunden. Wer bei jeder Anfrage eine andere Zahl hineinschrieb, sah
* fuer die Sperre wie tausend verschiedene Anschluesse aus. Die Zaehlung
* nach Anschluss war damit nicht nur wirkungslos, sondern irrefuehrend: Im
* Protokoll standen tausend Anschluesse, wo einer war.
*
* Richtig ist das andere Ende: Was der vertraute Proxy anhaengt, hat er
* selbst gesehen und nicht der Aufrufer geschrieben.
*
* ## Und wenn kein Proxy davor steht
*
* Dann ist der Kopf vollstaendig fremdbestimmt und es gibt hier nichts zu
* holen - also null. Keine Zaehlung nach Anschluss ist besser als eine, die
* sich mit einer Kopfzeile aushebeln laesst. Deshalb muss der Betrieb
* ausdruecklich sagen, dass ein Proxy davorsteht (VERTRAUE_PROXY=true).
*/
let gewarnt = false
/**
* Warnt einmal, wenn weitergereichte Koepfe ankommen, ihnen aber nicht
* geglaubt wird. Genau dann fehlt die Einstellung im Betrieb.
*/
function einmalWarnen(): void {
if (gewarnt) return
gewarnt = true
console.warn(
'[anschluss] Es kommen "x-forwarded-for"-Koepfe an, aber VERTRAUE_PROXY ist nicht gesetzt. ' +
'Die Anmeldesperre zaehlt deshalb nur nach E-Mail-Adresse, nicht nach Anschluss. ' +
'Steht ein Reverse Proxy davor, setze VERTRAUE_PROXY=true (und, bei mehreren, ' +
'VERTRAUE_PROXY_STUFEN=<Anzahl>). Steht keiner davor, ist das hier richtig so.',
)
}
/**
* Liest die Adresse des Aufrufers aus den Koepfen.
*
* @param kopf Die Kopfzeilen der Anfrage.
* @returns Die Adresse, oder null, wenn sie sich nicht vertrauenswuerdig
* bestimmen laesst.
*/
export function anschlussAus(kopf: Headers): string | null {
const weitergereicht = kopf.get('x-forwarded-for')
const echterIp = kopf.get('x-real-ip')
if (!env.vertraueProxy) {
if (weitergereicht || echterIp) einmalWarnen()
return null
}
if (weitergereicht) {
const kette = weitergereicht
.split(',')
.map((teil) => teil.trim())
.filter((teil) => teil.length > 0)
// Von hinten zaehlen: Der letzte Eintrag stammt vom naechsten
// Zwischenknoten, der zweitletzte von dem davor. Bei einer Stufe ist
// der letzte Eintrag der Aufrufer, wie der Proxy ihn gesehen hat.
if (kette.length > 0) {
const stelle = kette.length - env.vertraueProxyStufen
return kette[Math.max(0, stelle)] ?? null
}
}
// Manche Proxys setzen nur diesen Kopf. Er enthaelt genau eine Adresse,
// also gibt es hier nichts abzuzaehlen.
return echterIp?.trim() || null
}
/** Nur fuer Tests: setzt die einmalige Warnung zurueck. */
export function warnungZuruecksetzen(): void {
gewarnt = false
}
+93
View File
@@ -0,0 +1,93 @@
import 'server-only'
import type { User } from '@prisma/client'
import { db } from '@/lib/db'
import { wiederherstellungscodeFinden } from '@/lib/crypto/hash'
import { totpPruefen, totpSchritt, totpSecretEntschluesseln } from '@/lib/crypto/totp'
/**
* Einloesen des zweiten Faktors - einmal und nur einmal.
*
* Ein TOTP-Code gilt 30 Sekunden, mit der Toleranz von einem Fenster bis zu
* 90. Wer ihn in dieser Zeit noch einmal einreicht, kommt ohne die
* Toleranz ein zweites Mal herein: abgelesen ueber die Schulter,
* mitgeschrieben von einem Zwischenglied, oder aus einem Formular, das im
* Browserverlauf steht. Der Kommentar in totp.ts versprach seit dem Anfang,
* der Aufrufer speichere das getroffene Fenster - getan hat es niemand.
*
* Deshalb hier: Der Schritt wird festgehalten, und angenommen wird nur ein
* groesserer. Damit ist jeder Code genau einmal gut.
*
* ## Warum updateMany und nicht update
*
* Die Bedingung steht im WHERE, nicht in einem if davor. Zwei Anfragen mit
* demselben Code, gleichzeitig abgeschickt, wuerden sonst beide den
* Vergleich bestehen, bevor eine von ihnen schreibt. So entscheidet die
* Datenbank: Genau eine trifft eine Zeile, die andere keine.
*/
export type ZweiterFaktorErgebnis =
| { ok: true; art: 'totp' | 'wiederherstellungscode' }
| { ok: false; grund: 'falsch' | 'wiederverwendet' }
/** Ab dieser Laenge wird die Eingabe als Wiederherstellungscode gelesen. */
const CODE_MINDESTLAENGE = 12
/**
* Prueft die Eingabe und verbraucht sie.
*
* Nimmt sowohl einen sechsstelligen TOTP-Code als auch einen
* Wiederherstellungscode. Letzterer wird nach Gebrauch aus der Liste
* entfernt - auch das eine Einmalverwendung, nur mit anderem Mechanismus.
*
* @param benutzer Der Datensatz aus der Datenbank, nicht aus der Sitzung.
* @param eingabe Was im Formular stand, ungereinigt.
*/
export async function zweitenFaktorEinloesen(
benutzer: User,
eingabe: string,
): Promise<ZweiterFaktorErgebnis> {
const ohneTrenner = eingabe.replace(/[\s-]/g, '')
if (/^\d{6}$/.test(ohneTrenner)) {
if (!benutzer.totpSecretVerschluesselt) return { ok: false, grund: 'falsch' }
const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt)
const { gueltig, delta } = totpPruefen(secret, benutzer.email, ohneTrenner)
if (!gueltig || delta === null) return { ok: false, grund: 'falsch' }
const schritt = totpSchritt(delta)
const { count } = await db.user.updateMany({
where: {
id: benutzer.id,
OR: [{ totpLetzterSchritt: null }, { totpLetzterSchritt: { lt: schritt } }],
},
data: { totpLetzterSchritt: schritt },
})
// Keine Zeile getroffen: Dieser Schritt war schon einmal dran. Der Code
// ist rechnerisch richtig und trotzdem verbraucht.
if (count === 0) return { ok: false, grund: 'wiederverwendet' }
return { ok: true, art: 'totp' }
}
if (ohneTrenner.length >= CODE_MINDESTLAENGE) {
const treffer = await wiederherstellungscodeFinden(benutzer.wiederherstellungscodes, eingabe)
if (treffer < 0) return { ok: false, grund: 'falsch' }
// Auch hier die Bedingung in die Abfrage: Sonst koennten zwei
// gleichzeitige Anmeldungen denselben Code einloesen, weil beide noch
// die alte Liste sehen.
const rest = benutzer.wiederherstellungscodes.filter((_, i) => i !== treffer)
const { count } = await db.user.updateMany({
where: { id: benutzer.id, wiederherstellungscodes: { equals: benutzer.wiederherstellungscodes } },
data: { wiederherstellungscodes: rest },
})
if (count === 0) return { ok: false, grund: 'wiederverwendet' }
return { ok: true, art: 'wiederherstellungscode' }
}
return { ok: false, grund: 'falsch' }
}
+19 -2
View File
@@ -20,6 +20,9 @@ const ZIFFERN = 6
const PERIODE = 30
const TOLERANZ_FENSTER = 1
/** Laenge eines TOTP-Fensters in Sekunden - fuer den Wiederverwendungsschutz. */
export const TOTP_PERIODE = PERIODE
function totpBauen(secret: string, kennung: string): TOTP {
return new TOTP({
issuer: AUSSTELLER,
@@ -54,10 +57,24 @@ export async function totpQrCode(secret: string, kennung: string): Promise<strin
})
}
/**
* Der absolute Zeitschritt, auf den ein Code passt.
*
* validate() gibt nur die Abweichung zum aktuellen Fenster zurueck (delta).
* Zum Festhalten braucht es eine Zahl, die nicht davon abhaengt, wann
* geprueft wurde - sonst waere ein spaeter geprueftes Fenster kleiner als
* ein frueher geprueftes.
*/
export function totpSchritt(delta: number, jetztMs = Date.now()): number {
return Math.floor(jetztMs / 1000 / PERIODE) + delta
}
/**
* Prueft einen eingegebenen Sechsstelligen gegen das Geheimnis.
* Gibt zusaetzlich das getroffene Zeitfenster zurueck - der Aufrufer
* speichert es, um eine Wiederverwendung desselben Codes zu verhindern.
*
* Gibt zusaetzlich das getroffene Zeitfenster zurueck. Wer damit anmeldet,
* muss es ueber zweitenFaktorEinloesen() festhalten - sonst gilt derselbe
* Code bis zu 90 Sekunden lang ein zweites Mal.
*/
export function totpPruefen(
secret: string,
+71
View File
@@ -31,6 +31,18 @@ function zahl(name: string, standard: number): number {
return n
}
/**
* Ein Ja/Nein-Schalter. Bewusst nur "true"/"1"/"ja" als Ja - alles andere,
* auch ein Tippfehler, gilt als Nein. Bei einem Sicherheitsschalter ist das
* die richtige Richtung: Wer sich verschreibt, bekommt die strengere
* Einstellung, nicht die laxere.
*/
function schalter(name: string, standard = false): boolean {
const wert = process.env[name]?.trim().toLowerCase()
if (wert === undefined || wert === '') return standard
return wert === 'true' || wert === '1' || wert === 'ja'
}
function schluessel32(name: string): Buffer {
const roh = pflicht(name)
const buf = Buffer.from(roh, 'base64')
@@ -214,6 +226,65 @@ export const env = {
}
},
/**
* Darf der Host-Kopf der Anfrage die oeffentliche Adresse bestimmen?
*
* Standard: nur dann, wenn AUTH_URL nicht gesetzt ist. Ist die Adresse
* konfiguriert - der dokumentierte Betrieb setzt sie, Passkeys brauchen
* sie ohnehin -, gibt es keinen Grund, sie aus einer Kopfzeile von aussen
* abzuleiten. Vorher stand in auth.ts fest "true", und ein
* "X-Forwarded-Host: fremde.example" haette in erzeugte Adressen
* durchgeschlagen.
*
* AUTH_TRUST_HOST bleibt wirksam, damit die dokumentierte Einstellung
* nicht stillschweigend uebergangen wird.
*/
get vertraueHost() {
return schalter('AUTH_TRUST_HOST', !process.env.AUTH_URL)
},
/**
* Geheimnis fuer die Ersteinrichtung.
*
* Zwischen dem ersten Start und dem Anlegen des Zugangs steht die Seite
* offen: Wer sie in diesem Fenster findet, legt sich selbst einen
* Administratorzugang an. Das Fenster ist kurz, aber es ist da - und bei
* einer Anwendung mit Gesundheits- und Finanzdaten ist "kurz" kein
* Argument.
*
* Ohne diesen Wert ist die Ersteinrichtung gesperrt. Das ist Absicht:
* Eine Installation, bei der das Feld vergessen wurde, soll nicht
* einrichtbar sein, sondern eine Meldung zeigen.
*/
get ersteinrichtungGeheimnis() {
return process.env.ERSTEINRICHTUNG_GEHEIMNIS?.trim() ?? ''
},
/**
* Steht ein Reverse Proxy davor, dem die weitergereichten Koepfe zu
* glauben sind?
*
* Standard ist nein. Das ist die unbequeme, aber richtige Voreinstellung:
* Ohne Proxy ist "x-forwarded-for" frei erfunden, und eine Sperre, die
* darauf zaehlt, laesst sich mit einer Kopfzeile aushebeln. Wer einen
* Proxy davorstellt - der dokumentierte Betrieb tut das -, sagt es hier
* ausdruecklich. Siehe src/lib/auth/anschluss.ts.
*/
get vertraueProxy() {
return schalter('VERTRAUE_PROXY', false)
},
/**
* Wie viele Zwischenknoten vertraut werden. Bei einem Proxy: 1.
*
* Die Zahl sagt, wie weit von hinten in der Kette der Aufrufer steht.
* Zu hoch gesetzt liest man wieder fremdbestimmte Eintraege, deshalb
* bleibt es bei eins, solange nichts anderes dasteht.
*/
get vertraueProxyStufen() {
return Math.max(1, zahl('VERTRAUE_PROXY_STUFEN', 1))
},
get istProduktion() {
return process.env.NODE_ENV === 'production'
},
+3 -3
View File
@@ -5,6 +5,7 @@ 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.
@@ -43,11 +44,10 @@ function istWeiterleitung(fehler: unknown): boolean {
)
}
/** Hinter dem Reverse Proxy steht die echte Adresse im weitergereichten Kopf. */
/** Siehe src/lib/auth/anschluss.ts - der Kopf wird nur mit Proxy geglaubt. */
async function ipDerAnfrage(): Promise<string | null> {
try {
const kopf = await headers()
return kopf.get('x-forwarded-for')?.split(',')[0]?.trim() || kopf.get('x-real-ip') || null
return anschlussAus(await headers())
} catch {
return null
}
+8 -4
View File
@@ -5,7 +5,7 @@ import { z } from 'zod'
import { db } from '@/lib/db'
import { erfordereBenutzer, reauthBestaetigen } from '@/lib/auth/sitzung'
import { passwortPruefen } from '@/lib/crypto/hash'
import { totpPruefen, totpSecretEntschluesseln } from '@/lib/crypto/totp'
import { zweitenFaktorEinloesen } from '@/lib/auth/zweiter-faktor'
import { aufMindestdauerWarten } from '@/lib/auth/ratelimit'
/**
@@ -56,10 +56,14 @@ export async function anmeldungBestaetigen(
const passwortStimmt = await passwortPruefen(frisch.passwortHash, gelesen.data.passwort)
// Der Code wird nur eingeloest, wenn das Passwort stimmt. Sonst
// verbrauchte ein falsches Passwort mit richtigem Code das Zeitfenster,
// und der Benutzer muesste dreissig Sekunden auf den naechsten warten -
// ohne zu verstehen, warum.
let codeStimmt = true
if (frisch.totpSecretVerschluesselt && frisch.totpAktiviertAm) {
const secret = totpSecretEntschluesseln(frisch.totpSecretVerschluesselt)
codeStimmt = totpPruefen(secret, frisch.email, gelesen.data.code).gueltig
if (passwortStimmt && frisch.totpSecretVerschluesselt && frisch.totpAktiviertAm) {
const eingeloest = await zweitenFaktorEinloesen(frisch, gelesen.data.code)
codeStimmt = eingeloest.ok
}
if (!passwortStimmt || !codeStimmt) {
+58 -19
View File
@@ -20,7 +20,11 @@ import {
registrierungPruefen,
registrierungVorbereiten,
} from '@/lib/auth/passkey'
import { erfordereBenutzer, reauthBestaetigen } from '@/lib/auth/sitzung'
import {
erfordereBenutzer,
erfordereFrischeAnmeldung,
reauthBestaetigen,
} from '@/lib/auth/sitzung'
/**
* Server Actions rund um Passkeys.
@@ -42,6 +46,21 @@ export type PasskeyStart<T> = { ok: true; optionen: T } | { ok: false; fehler: s
/** 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] }
@@ -50,6 +69,18 @@ function alsFehler(fehler: unknown): { ok: false; fehler: string } {
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.
*
@@ -74,14 +105,26 @@ function antwortLesen<T>(roh: string): 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 alsFehler(fehler)
return alsFehlerOderWeiter(fehler)
}
}
@@ -97,6 +140,11 @@ export async function passkeyEinrichtungAbschliessen(
): 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) {
@@ -122,7 +170,7 @@ export async function passkeyEinrichtungAbschliessen(
revalidatePath('/konto')
return { ok: true, meldung: `„${passkey.name}“ ist eingerichtet.` }
} catch (fehler) {
return alsFehler(fehler)
return alsFehlerOderWeiter(fehler)
}
}
@@ -130,9 +178,15 @@ export async function passkeyEinrichtungAbschliessen(
// 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
@@ -163,7 +217,7 @@ export async function passkeyEntfernen(id: string): Promise<PasskeyErgebnis> {
revalidatePath('/konto')
return { ok: true, meldung: `„${passkey.name}“ wurde entfernt.` }
} catch (fehler) {
return alsFehler(fehler)
return alsFehlerOderWeiter(fehler)
}
}
@@ -187,21 +241,6 @@ function sicheresZiel(ziel: string): string {
return ziel
}
/**
* 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')
)
}
/**
* Uebersetzt den Fehlercode aus authorize() in einen lesbaren Satz.
* "passkey:<grund>" kommt aus PasskeyAnmeldeFehler, alles andere aus dem
+11 -3
View File
@@ -11,6 +11,7 @@ import {
import { protokollieren } from '@/lib/audit'
import {
totpPruefen,
totpSchritt,
totpQrCode,
totpSecretEntschluesseln,
totpSecretErzeugen,
@@ -100,9 +101,9 @@ export async function zweitenFaktorBestaetigen(
}
const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt)
const { gueltig } = totpPruefen(secret, benutzer.email, gelesen.data)
const { gueltig, delta } = totpPruefen(secret, benutzer.email, gelesen.data)
if (!gueltig) {
if (!gueltig || delta === null) {
return {
fehler:
'Der Code stimmt nicht. Prüfe, ob die Uhrzeit deines Geräts automatisch gestellt wird — ' +
@@ -115,7 +116,14 @@ export async function zweitenFaktorBestaetigen(
await db.user.update({
where: { id: benutzer.id },
data: { totpAktiviertAm: new Date(), wiederherstellungscodes: hashes },
data: {
totpAktiviertAm: new Date(),
wiederherstellungscodes: hashes,
// Der Code, mit dem gerade bestaetigt wurde, ist verbraucht. Ohne
// diese Zeile liesse er sich unmittelbar danach fuer die erste
// Anmeldung noch einmal verwenden.
totpLetzterSchritt: totpSchritt(delta),
},
})
await protokollieren({
+136
View File
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { anschlussAus, warnungZuruecksetzen } from '@/lib/auth/anschluss'
/**
* Aus welchem Kopf die Adresse des Aufrufers gelesen wird.
*
* Vorher stand dort der ERSTE Eintrag von "x-forwarded-for". Das ist die
* klassische Verwechslung: Der Kopf ist eine Liste, an die jeder
* Zwischenknoten hinten anhaengt - der erste Eintrag ist also der, den der
* Aufrufer selbst mitgeschickt hat. Wer bei jeder Anfrage eine andere Zahl
* hineinschrieb, sah fuer die Anmeldesperre wie tausend Anschluesse aus.
*/
function koepfe(werte: Record<string, string>): Headers {
return new Headers(werte)
}
describe('Anschluss des Aufrufers', () => {
beforeEach(() => {
warnungZuruecksetzen()
delete process.env.VERTRAUE_PROXY
delete process.env.VERTRAUE_PROXY_STUFEN
})
afterEach(() => {
delete process.env.VERTRAUE_PROXY
delete process.env.VERTRAUE_PROXY_STUFEN
vi.restoreAllMocks()
})
describe('ohne vertrauten Proxy', () => {
it('glaubt dem weitergereichten Kopf nicht', () => {
const warnung = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(anschlussAus(koepfe({ 'x-forwarded-for': '203.0.113.9' }))).toBeNull()
expect(warnung).toHaveBeenCalledOnce()
})
it('warnt nur einmal, nicht bei jeder Anfrage', () => {
const warnung = vi.spyOn(console, 'warn').mockImplementation(() => {})
for (let i = 0; i < 20; i += 1) {
anschlussAus(koepfe({ 'x-forwarded-for': `203.0.113.${i}` }))
}
expect(warnung).toHaveBeenCalledOnce()
})
it('schweigt, wenn gar kein solcher Kopf ankommt', () => {
const warnung = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(anschlussAus(koepfe({}))).toBeNull()
expect(warnung).not.toHaveBeenCalled()
})
})
describe('mit einem vertrauten Proxy', () => {
beforeEach(() => {
process.env.VERTRAUE_PROXY = 'true'
})
it('nimmt den letzten Eintrag, nicht den ersten', () => {
// Links das, was der Aufrufer selbst behauptet hat; rechts das, was
// der Proxy gesehen hat. Nur letzteres ist etwas wert.
const kopf = koepfe({ 'x-forwarded-for': '10.0.0.1, 198.51.100.7' })
expect(anschlussAus(kopf)).toBe('198.51.100.7')
})
it('laesst sich durch eine erfundene Kette nicht taeuschen', () => {
const kopf = koepfe({
'x-forwarded-for': '1.1.1.1, 2.2.2.2, 3.3.3.3, 198.51.100.7',
})
expect(anschlussAus(kopf)).toBe('198.51.100.7')
})
it('nimmt den einzigen Eintrag, wenn der Aufrufer keinen mitschickte', () => {
expect(anschlussAus(koepfe({ 'x-forwarded-for': '198.51.100.7' }))).toBe('198.51.100.7')
})
it('faellt auf x-real-ip zurueck', () => {
expect(anschlussAus(koepfe({ 'x-real-ip': '198.51.100.7' }))).toBe('198.51.100.7')
})
it('gibt null, wenn nichts da ist', () => {
expect(anschlussAus(koepfe({}))).toBeNull()
})
it('kommt mit Leerraum und leeren Gliedern zurecht', () => {
const kopf = koepfe({ 'x-forwarded-for': ' 10.0.0.1 , , 198.51.100.7 ' })
expect(anschlussAus(kopf)).toBe('198.51.100.7')
})
})
describe('mit mehreren Stufen', () => {
it('zaehlt die angegebene Anzahl von hinten ab', () => {
process.env.VERTRAUE_PROXY = 'true'
process.env.VERTRAUE_PROXY_STUFEN = '2'
// Zwei eigene Zwischenknoten: der aeussere haengt 198.51.100.7 an,
// der innere 10.0.0.5. Der Aufrufer steht eine Stelle weiter links.
const kopf = koepfe({ 'x-forwarded-for': 'erfunden, 198.51.100.7, 10.0.0.5' })
expect(anschlussAus(kopf)).toBe('198.51.100.7')
})
it('greift nicht ueber den Anfang der Kette hinaus', () => {
process.env.VERTRAUE_PROXY = 'true'
process.env.VERTRAUE_PROXY_STUFEN = '9'
expect(anschlussAus(koepfe({ 'x-forwarded-for': '198.51.100.7' }))).toBe('198.51.100.7')
})
})
describe('der Schalter selbst', () => {
it('gilt nur bei ausdruecklichem Ja', () => {
for (const wert of ['false', 'nein', '0', 'vielleicht', '']) {
warnungZuruecksetzen()
vi.spyOn(console, 'warn').mockImplementation(() => {})
process.env.VERTRAUE_PROXY = wert
expect(anschlussAus(koepfe({ 'x-forwarded-for': '198.51.100.7' }))).toBeNull()
}
})
it('nimmt true, 1 und ja', () => {
for (const wert of ['true', '1', 'ja', 'TRUE', ' Ja ']) {
process.env.VERTRAUE_PROXY = wert
expect(anschlussAus(koepfe({ 'x-forwarded-for': '198.51.100.7' }))).toBe('198.51.100.7')
}
})
})
})
+190
View File
@@ -0,0 +1,190 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { User } from '@prisma/client'
/**
* Ein TOTP-Code gilt genau einmal.
*
* Ein Code ist 30 Sekunden gueltig, mit der Toleranz von einem Fenster bis
* zu 90. In dieser Zeit liess er sich beliebig oft einloesen: abgelesen
* ueber die Schulter, mitgeschrieben von einem Zwischenglied, oder aus einem
* Formular im Browserverlauf. Der Kommentar in totp.ts versprach von Anfang
* an, der Aufrufer speichere das getroffene Fenster - getan hat es niemand.
*
* Geprueft wird gegen eine Attrappe der Datenbank, die das WHERE von
* updateMany nachbildet. Genau darauf beruht die Einmalverwendung: Die
* Bedingung steht in der Abfrage, nicht in einem if davor.
*/
let benutzer: User
let updateAufrufe = 0
function passtSchritt(bedingung: Record<string, unknown>, wert: number | null): boolean {
if ('OR' in bedingung) {
const zweige = bedingung.OR as Record<string, unknown>[]
return zweige.some((zweig) => passtSchritt(zweig, wert))
}
if ('totpLetzterSchritt' in bedingung) {
const erwartet = bedingung.totpLetzterSchritt
if (erwartet === null) return wert === null
if (typeof erwartet === 'object' && erwartet !== null && 'lt' in erwartet) {
return wert !== null && wert < (erwartet as { lt: number }).lt
}
}
return true
}
vi.mock('@/lib/db', () => ({
db: {
user: {
updateMany: async ({
where,
data,
}: {
where: Record<string, unknown>
data: Partial<User>
}) => {
updateAufrufe += 1
if (where.id !== benutzer.id) return { count: 0 }
// Die Liste der Wiederherstellungscodes muss noch die sein, die der
// Aufrufer gesehen hat - sonst war jemand schneller.
if ('wiederherstellungscodes' in where) {
const erwartet = (where.wiederherstellungscodes as { equals: string[] }).equals
const gleich =
erwartet.length === benutzer.wiederherstellungscodes.length &&
erwartet.every((wert, i) => wert === benutzer.wiederherstellungscodes[i])
if (!gleich) return { count: 0 }
}
if (('OR' in where || 'totpLetzterSchritt' in where) && !passtSchritt(where, benutzer.totpLetzterSchritt)) {
return { count: 0 }
}
benutzer = { ...benutzer, ...data }
return { count: 1 }
},
},
},
}))
const { zweitenFaktorEinloesen } = await import('@/lib/auth/zweiter-faktor')
const { totpAktuellerCode, totpSchritt, totpSecretErzeugen, totpSecretVerschluesseln } =
await import('@/lib/crypto/totp')
const { wiederherstellungscodeHashen, wiederherstellungscodesErzeugen } = await import(
'@/lib/crypto/hash'
)
const EMAIL = 'betreuer@example.de'
const SECRET = totpSecretErzeugen()
function grundstand(): User {
return {
id: 'benutzer-1',
email: EMAIL,
totpSecretVerschluesselt: totpSecretVerschluesseln(SECRET),
totpAktiviertAm: new Date('2026-01-01T00:00:00Z'),
totpLetzterSchritt: null,
wiederherstellungscodes: [],
} as unknown as User
}
describe('Zweiten Faktor einloesen', () => {
beforeEach(() => {
benutzer = grundstand()
updateAufrufe = 0
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-24T12:00:15Z'))
})
it('nimmt einen gueltigen Code an und haelt das Fenster fest', async () => {
const code = totpAktuellerCode(SECRET, EMAIL)
const ergebnis = await zweitenFaktorEinloesen(benutzer, code)
expect(ergebnis).toEqual({ ok: true, art: 'totp' })
expect(benutzer.totpLetzterSchritt).toBe(totpSchritt(0))
})
it('lehnt denselben Code beim zweiten Mal ab', async () => {
const code = totpAktuellerCode(SECRET, EMAIL)
expect(await zweitenFaktorEinloesen(benutzer, code)).toEqual({ ok: true, art: 'totp' })
// Noch im selben Fenster - der Code waere rechnerisch weiter richtig.
vi.setSystemTime(new Date('2026-08-24T12:00:25Z'))
expect(await zweitenFaktorEinloesen(benutzer, code)).toEqual({
ok: false,
grund: 'wiederverwendet',
})
})
it('lehnt ihn auch im Toleranzfenster danach ab', async () => {
const code = totpAktuellerCode(SECRET, EMAIL)
await zweitenFaktorEinloesen(benutzer, code)
// Ein Fenster weiter: Die Toleranz liesse den alten Code noch gelten.
vi.setSystemTime(new Date('2026-08-24T12:00:45Z'))
expect(await zweitenFaktorEinloesen(benutzer, code)).toEqual({
ok: false,
grund: 'wiederverwendet',
})
})
it('nimmt den naechsten Code an', async () => {
await zweitenFaktorEinloesen(benutzer, totpAktuellerCode(SECRET, EMAIL))
const vorher = benutzer.totpLetzterSchritt
vi.setSystemTime(new Date('2026-08-24T12:00:45Z'))
const naechster = totpAktuellerCode(SECRET, EMAIL)
expect(await zweitenFaktorEinloesen(benutzer, naechster)).toEqual({ ok: true, art: 'totp' })
expect(benutzer.totpLetzterSchritt!).toBeGreaterThan(vorher!)
})
it('lehnt einen falschen Code ab, ohne zu schreiben', async () => {
expect(await zweitenFaktorEinloesen(benutzer, '000000')).toEqual({
ok: false,
grund: 'falsch',
})
expect(updateAufrufe).toBe(0)
expect(benutzer.totpLetzterSchritt).toBeNull()
})
it('toleriert Leerzeichen und Bindestriche in der Eingabe', async () => {
const code = totpAktuellerCode(SECRET, EMAIL)
const zerlegt = `${code.slice(0, 3)} ${code.slice(3)}`
expect(await zweitenFaktorEinloesen(benutzer, zerlegt)).toEqual({ ok: true, art: 'totp' })
})
it('loest einen Wiederherstellungscode genau einmal ein', async () => {
const codes = wiederherstellungscodesErzeugen(3)
benutzer = {
...grundstand(),
wiederherstellungscodes: await Promise.all(codes.map(wiederherstellungscodeHashen)),
} as User
const erster = codes[0]!
expect(await zweitenFaktorEinloesen(benutzer, erster)).toEqual({
ok: true,
art: 'wiederherstellungscode',
})
expect(benutzer.wiederherstellungscodes).toHaveLength(2)
// Derselbe Code noch einmal: Er steht nicht mehr in der Liste.
expect(await zweitenFaktorEinloesen(benutzer, erster)).toEqual({
ok: false,
grund: 'falsch',
})
expect(benutzer.wiederherstellungscodes).toHaveLength(2)
})
it('haelt die Reihenfolge der Zeitschritte ein', () => {
const frueh = totpSchritt(0, Date.parse('2026-08-24T12:00:00Z'))
const spaet = totpSchritt(0, Date.parse('2026-08-24T12:00:30Z'))
expect(spaet).toBe(frueh + 1)
// Das Delta verschiebt um ganze Fenster, nicht um Sekunden.
expect(totpSchritt(1, Date.parse('2026-08-24T12:00:00Z'))).toBe(spaet)
})
})