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
|
||||
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
@@ -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')
|
||||
|
||||
@@ -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 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,
|
||||
|
||||
@@ -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'
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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