Fix #38: Sicherheitsprüfung abarbeiten (v1.11.0) #39
+29
-2
@@ -15,9 +15,17 @@ DATABASE_URL="postgresql://cockpit:bitte-aendern@localhost:5432/betreuungs_cockp
|
|||||||
# 32 zufaellige Bytes, base64. Erzeugen: npm run schluessel
|
# 32 zufaellige Bytes, base64. Erzeugen: npm run schluessel
|
||||||
AUTH_SECRET=
|
AUTH_SECRET=
|
||||||
# Oeffentliche Basis-URL der Anwendung (ohne abschliessenden Slash).
|
# 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
|
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 -------------------------
|
# --- Erreichbarkeit: HIER MUSST DU DICH ENTSCHEIDEN -------------------------
|
||||||
#
|
#
|
||||||
@@ -73,6 +81,25 @@ LOGIN_MAX_VERSUCHE=5
|
|||||||
LOGIN_FENSTER_SEKUNDEN=900
|
LOGIN_FENSTER_SEKUNDEN=900
|
||||||
LOGIN_SPERRE_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!) ------------------------------------
|
# --- Objektspeicher (S3-kompatibel, EU!) ------------------------------------
|
||||||
# Wird ab Etappe 3 verwendet. Beispiel: Hetzner Object Storage, Falkenstein.
|
# Wird ab Etappe 3 verwendet. Beispiel: Hetzner Object Storage, Falkenstein.
|
||||||
# ACHTUNG: Mit dem Anbieter muss ein AV-Vertrag nach Art. 28 DSGVO bestehen.
|
# ACHTUNG: Mit dem Anbieter muss ein AV-Vertrag nach Art. 28 DSGVO bestehen.
|
||||||
|
|||||||
+16
-31
@@ -3,8 +3,7 @@ import Credentials from 'next-auth/providers/credentials'
|
|||||||
import type { Rolle } from '@prisma/client'
|
import type { Rolle } from '@prisma/client'
|
||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
import { env } from '@/lib/env'
|
import { env } from '@/lib/env'
|
||||||
import { hashVeraltet, passwortHashen, passwortPruefen, wiederherstellungscodeFinden } from '@/lib/crypto/hash'
|
import { hashVeraltet, passwortHashen, passwortPruefen } from '@/lib/crypto/hash'
|
||||||
import { totpPruefen, totpSecretEntschluesseln } from '@/lib/crypto/totp'
|
|
||||||
import {
|
import {
|
||||||
anmeldungErlaubt,
|
anmeldungErlaubt,
|
||||||
aufMindestdauerWarten,
|
aufMindestdauerWarten,
|
||||||
@@ -13,6 +12,8 @@ import {
|
|||||||
versuchFesthalten,
|
versuchFesthalten,
|
||||||
} from '@/lib/auth/ratelimit'
|
} from '@/lib/auth/ratelimit'
|
||||||
import { protokollieren } from '@/lib/audit'
|
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 { PasskeyFehler, anmeldungPruefen } from '@/lib/auth/passkey'
|
||||||
import type { AuthenticationResponseJSON } from '@simplewebauthn/server'
|
import type { AuthenticationResponseJSON } from '@simplewebauthn/server'
|
||||||
|
|
||||||
@@ -65,12 +66,13 @@ export class PasskeyAnmeldeFehler extends CredentialsSignin {
|
|||||||
|
|
||||||
function ipAusAnfrage(anfrage: Request | undefined): string | null {
|
function ipAusAnfrage(anfrage: Request | undefined): string | null {
|
||||||
if (!anfrage) return null
|
if (!anfrage) return null
|
||||||
const weitergeleitet = anfrage.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
return anschlussAus(anfrage.headers)
|
||||||
return weitergeleitet || anfrage.headers.get('x-real-ip') || null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
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,
|
secret: process.env.AUTH_SECRET,
|
||||||
|
|
||||||
session: {
|
session: {
|
||||||
@@ -142,33 +144,13 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
throw new AnmeldeFehler('konto-inaktiv')
|
throw new AnmeldeFehler('konto-inaktiv')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Zweiter Faktor. Ohne eingerichtetes TOTP kommt der Benutzer
|
// 3. Zweiter Faktor. Ist er noch nicht eingerichtet, kommt der
|
||||||
// zwar herein, das App-Layout schickt ihn aber sofort zur
|
// Benutzer nur bis zur Einrichtungsseite - erzwungen in
|
||||||
// Einrichtung - er kann nichts anderes tun.
|
// erfordereBenutzer(), nicht erst im Layout.
|
||||||
if (benutzer.totpSecretVerschluesselt && benutzer.totpAktiviertAm) {
|
if (benutzer.totpSecretVerschluesselt && benutzer.totpAktiviertAm) {
|
||||||
const codeOhneTrenner = code.replace(/[\s-]/g, '')
|
const eingeloest = await zweitenFaktorEinloesen(benutzer, code)
|
||||||
let zweiterFaktorOk = false
|
|
||||||
|
|
||||||
if (/^\d{6}$/.test(codeOhneTrenner)) {
|
if (!eingeloest.ok) {
|
||||||
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 versuchFesthalten(email, ip, false, 'code-falsch')
|
||||||
await protokollieren({
|
await protokollieren({
|
||||||
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
|
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
|
||||||
@@ -176,7 +158,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
|||||||
entitaetId: benutzer.id,
|
entitaetId: benutzer.id,
|
||||||
userEmail: email,
|
userEmail: email,
|
||||||
userId: benutzer.id,
|
userId: benutzer.id,
|
||||||
bezeichnung: 'Zweiter Faktor falsch',
|
bezeichnung:
|
||||||
|
eingeloest.grund === 'wiederverwendet'
|
||||||
|
? 'Zweiter Faktor bereits verbraucht'
|
||||||
|
: 'Zweiter Faktor falsch',
|
||||||
})
|
})
|
||||||
await aufMindestdauerWarten(begonnen)
|
await aufMindestdauerWarten(begonnen)
|
||||||
throw new AnmeldeFehler('code-falsch')
|
throw new AnmeldeFehler('code-falsch')
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -20,6 +20,9 @@ const ZIFFERN = 6
|
|||||||
const PERIODE = 30
|
const PERIODE = 30
|
||||||
const TOLERANZ_FENSTER = 1
|
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 {
|
function totpBauen(secret: string, kennung: string): TOTP {
|
||||||
return new TOTP({
|
return new TOTP({
|
||||||
issuer: AUSSTELLER,
|
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.
|
* 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(
|
export function totpPruefen(
|
||||||
secret: string,
|
secret: string,
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ function zahl(name: string, standard: number): number {
|
|||||||
return n
|
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 {
|
function schluessel32(name: string): Buffer {
|
||||||
const roh = pflicht(name)
|
const roh = pflicht(name)
|
||||||
const buf = Buffer.from(roh, 'base64')
|
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() {
|
get istProduktion() {
|
||||||
return process.env.NODE_ENV === 'production'
|
return process.env.NODE_ENV === 'production'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { headers } from 'next/headers'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { signIn } from '@/auth'
|
import { signIn } from '@/auth'
|
||||||
import { anmeldungErlaubt } from '@/lib/auth/ratelimit'
|
import { anmeldungErlaubt } from '@/lib/auth/ratelimit'
|
||||||
|
import { anschlussAus } from '@/lib/auth/anschluss'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server Action fuer das Anmeldeformular.
|
* 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> {
|
async function ipDerAnfrage(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const kopf = await headers()
|
return anschlussAus(await headers())
|
||||||
return kopf.get('x-forwarded-for')?.split(',')[0]?.trim() || kopf.get('x-real-ip') || null
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { z } from 'zod'
|
|||||||
import { db } from '@/lib/db'
|
import { db } from '@/lib/db'
|
||||||
import { erfordereBenutzer, reauthBestaetigen } from '@/lib/auth/sitzung'
|
import { erfordereBenutzer, reauthBestaetigen } from '@/lib/auth/sitzung'
|
||||||
import { passwortPruefen } from '@/lib/crypto/hash'
|
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'
|
import { aufMindestdauerWarten } from '@/lib/auth/ratelimit'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,10 +56,14 @@ export async function anmeldungBestaetigen(
|
|||||||
|
|
||||||
const passwortStimmt = await passwortPruefen(frisch.passwortHash, gelesen.data.passwort)
|
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
|
let codeStimmt = true
|
||||||
if (frisch.totpSecretVerschluesselt && frisch.totpAktiviertAm) {
|
if (passwortStimmt && frisch.totpSecretVerschluesselt && frisch.totpAktiviertAm) {
|
||||||
const secret = totpSecretEntschluesseln(frisch.totpSecretVerschluesselt)
|
const eingeloest = await zweitenFaktorEinloesen(frisch, gelesen.data.code)
|
||||||
codeStimmt = totpPruefen(secret, frisch.email, gelesen.data.code).gueltig
|
codeStimmt = eingeloest.ok
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!passwortStimmt || !codeStimmt) {
|
if (!passwortStimmt || !codeStimmt) {
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ import {
|
|||||||
registrierungPruefen,
|
registrierungPruefen,
|
||||||
registrierungVorbereiten,
|
registrierungVorbereiten,
|
||||||
} from '@/lib/auth/passkey'
|
} from '@/lib/auth/passkey'
|
||||||
import { erfordereBenutzer, reauthBestaetigen } from '@/lib/auth/sitzung'
|
import {
|
||||||
|
erfordereBenutzer,
|
||||||
|
erfordereFrischeAnmeldung,
|
||||||
|
reauthBestaetigen,
|
||||||
|
} from '@/lib/auth/sitzung'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server Actions rund um Passkeys.
|
* 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. */
|
/** Ergebnis eines abschliessenden Schritts. */
|
||||||
export type PasskeyErgebnis = { ok: true; meldung?: string } | { ok: false; fehler: string }
|
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 } {
|
function alsFehler(fehler: unknown): { ok: false; fehler: string } {
|
||||||
if (fehler instanceof PasskeyFehler) {
|
if (fehler instanceof PasskeyFehler) {
|
||||||
return { ok: false, fehler: PASSKEY_MELDUNG[fehler.grund] }
|
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 }
|
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.
|
* Parst die Antwort des Browsers.
|
||||||
*
|
*
|
||||||
@@ -74,14 +105,26 @@ function antwortLesen<T>(roh: string): T {
|
|||||||
// Einrichten (angemeldet, unter "Mein Konto")
|
// 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<
|
export async function passkeyEinrichtungStarten(): Promise<
|
||||||
PasskeyStart<PublicKeyCredentialCreationOptionsJSON>
|
PasskeyStart<PublicKeyCredentialCreationOptionsJSON>
|
||||||
> {
|
> {
|
||||||
try {
|
try {
|
||||||
const benutzer = await erfordereBenutzer()
|
const benutzer = await erfordereBenutzer()
|
||||||
|
await erfordereFrischeAnmeldung(benutzer, '/konto')
|
||||||
return { ok: true, optionen: await registrierungVorbereiten(benutzer) }
|
return { ok: true, optionen: await registrierungVorbereiten(benutzer) }
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
return alsFehler(fehler)
|
return alsFehlerOderWeiter(fehler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +140,11 @@ export async function passkeyEinrichtungAbschliessen(
|
|||||||
): Promise<PasskeyErgebnis> {
|
): Promise<PasskeyErgebnis> {
|
||||||
try {
|
try {
|
||||||
const benutzer = await erfordereBenutzer()
|
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)
|
const geprueft = NameSchema.safeParse(name)
|
||||||
if (!geprueft.success) {
|
if (!geprueft.success) {
|
||||||
@@ -122,7 +170,7 @@ export async function passkeyEinrichtungAbschliessen(
|
|||||||
revalidatePath('/konto')
|
revalidatePath('/konto')
|
||||||
return { ok: true, meldung: `„${passkey.name}“ ist eingerichtet.` }
|
return { ok: true, meldung: `„${passkey.name}“ ist eingerichtet.` }
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
return alsFehler(fehler)
|
return alsFehlerOderWeiter(fehler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,9 +178,15 @@ export async function passkeyEinrichtungAbschliessen(
|
|||||||
// Verwalten
|
// 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> {
|
export async function passkeyEntfernen(id: string): Promise<PasskeyErgebnis> {
|
||||||
try {
|
try {
|
||||||
const benutzer = await erfordereBenutzer()
|
const benutzer = await erfordereBenutzer()
|
||||||
|
await erfordereFrischeAnmeldung(benutzer, '/konto')
|
||||||
|
|
||||||
// Zeilenweise Pruefung: Es genuegt nicht, dass die ID existiert - sie
|
// Zeilenweise Pruefung: Es genuegt nicht, dass die ID existiert - sie
|
||||||
// muss zu DIESEM Benutzer gehoeren. deleteMany mit userId im WHERE
|
// muss zu DIESEM Benutzer gehoeren. deleteMany mit userId im WHERE
|
||||||
@@ -163,7 +217,7 @@ export async function passkeyEntfernen(id: string): Promise<PasskeyErgebnis> {
|
|||||||
revalidatePath('/konto')
|
revalidatePath('/konto')
|
||||||
return { ok: true, meldung: `„${passkey.name}“ wurde entfernt.` }
|
return { ok: true, meldung: `„${passkey.name}“ wurde entfernt.` }
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
return alsFehler(fehler)
|
return alsFehlerOderWeiter(fehler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,21 +241,6 @@ function sicheresZiel(ziel: string): string {
|
|||||||
return ziel
|
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.
|
* Uebersetzt den Fehlercode aus authorize() in einen lesbaren Satz.
|
||||||
* "passkey:<grund>" kommt aus PasskeyAnmeldeFehler, alles andere aus dem
|
* "passkey:<grund>" kommt aus PasskeyAnmeldeFehler, alles andere aus dem
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { protokollieren } from '@/lib/audit'
|
import { protokollieren } from '@/lib/audit'
|
||||||
import {
|
import {
|
||||||
totpPruefen,
|
totpPruefen,
|
||||||
|
totpSchritt,
|
||||||
totpQrCode,
|
totpQrCode,
|
||||||
totpSecretEntschluesseln,
|
totpSecretEntschluesseln,
|
||||||
totpSecretErzeugen,
|
totpSecretErzeugen,
|
||||||
@@ -100,9 +101,9 @@ export async function zweitenFaktorBestaetigen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt)
|
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 {
|
return {
|
||||||
fehler:
|
fehler:
|
||||||
'Der Code stimmt nicht. Prüfe, ob die Uhrzeit deines Geräts automatisch gestellt wird — ' +
|
'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({
|
await db.user.update({
|
||||||
where: { id: benutzer.id },
|
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({
|
await protokollieren({
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user