feat: Anmeldung mit Pflicht-2FA, Rollen und Audit-Log
Auth.js mit Zugangsdaten-Provider: E-Mail, Passwort und TOTP-Code werden in einem Formular abgefragt. Ein zweistufiger Ablauf muesste zwischen den Schritten einen Zwischenzustand halten - genau das ist die uebliche Schwachstelle, und am Handy ist ein Formular ohnehin schneller. Anmeldung: - Sperre wird VOR dem Passwortvergleich geprueft, damit ein gesperrter Zugang nicht einmal Argon2-Rechenzeit kostet. - Bei unbekannter Adresse wird gegen einen Dummy-Hash gerechnet und die Antwort auf eine Mindestdauer gestreckt. Sonst verraet die Antwortzeit, welche Adressen existieren. - Wiederherstellungscodes werden nach Gebrauch entfernt. - Veraltete Argon2-Parameter werden beim erfolgreichen Login nachgezogen. Rate-Limiting in der Datenbank statt Redis - ein Server, wenige Benutzer, kein Grund fuer eine weitere Komponente. Gezaehlt wird nach E-Mail und nach Anschluss, damit weder viele Anschluesse noch viele Adressen helfen. Zugriffsschutz (sitzung.ts): - Die Sitzung allein entscheidet nichts. Bei jedem Aufruf wird der Benutzer frisch geladen und die Sitzungsgeneration verglichen - so lassen sich alle Anmeldungen auf einen Schlag beenden. - erfordereZugang() prueft immer gegen die betreuteId, nie nur im UI. - Erneute Bestaetigung vor sensiblen Aktionen ueber ein signiertes, kurzlebiges Cookie statt ueber Serverzustand. Audit-Log: aenderungErmitteln() protokolliert nur die tatsaechlich geaenderten Felder; Passwoerter und TOTP-Geheimnisse werden ersetzt, nie gespeichert. Ein Fehler im Protokoll bricht die Fachaktion nicht ab - ausser die Aktion laeuft in einer Transaktion, dann faellt beides. CSP mit Nonce pro Anfrage in der Middleware: keine Fremdquellen, kein CDN, keine Fonts von aussen. Fachseiten werden nicht zwischengespeichert. Etappe: 1 Phase: Fundament
This commit is contained in:
+255
@@ -0,0 +1,255 @@
|
||||
import NextAuth from 'next-auth'
|
||||
import Credentials from 'next-auth/providers/credentials'
|
||||
import type { Rolle } from '@prisma/client'
|
||||
import { db } from '@/lib/db'
|
||||
import { env } from '@/lib/env'
|
||||
import { hashVeraltet, passwortHashen, passwortPruefen, wiederherstellungscodeFinden } from '@/lib/crypto/hash'
|
||||
import { totpPruefen, totpSecretEntschluesseln } from '@/lib/crypto/totp'
|
||||
import {
|
||||
anmeldungErlaubt,
|
||||
aufMindestdauerWarten,
|
||||
fehlversucheZuruecksetzen,
|
||||
versuchFesthalten,
|
||||
} from '@/lib/auth/ratelimit'
|
||||
import { protokollieren } from '@/lib/audit'
|
||||
|
||||
/**
|
||||
* Anmeldung: E-Mail + Passwort + TOTP - alle drei zusammen, in einem Schritt.
|
||||
*
|
||||
* Warum in einem Schritt und nicht zweistufig? Ein zweistufiger Ablauf muesste
|
||||
* zwischen den Schritten einen Zwischenzustand halten ("Passwort war richtig,
|
||||
* warte auf Code"). Genau dieser Zustand ist die typische Schwachstelle. Ein
|
||||
* Formular mit drei Feldern ist am Handy ausserdem schneller ausgefuellt.
|
||||
*
|
||||
* Der zweite Faktor ist Pflicht. Ist er noch nicht eingerichtet, kommt der
|
||||
* Benutzer nur bis zur Einrichtungsseite - erzwungen im App-Layout.
|
||||
*/
|
||||
|
||||
export class AnmeldeFehler extends Error {
|
||||
constructor(
|
||||
public readonly grund:
|
||||
| 'daten-falsch'
|
||||
| 'gesperrt'
|
||||
| 'konto-inaktiv'
|
||||
| 'zweiter-faktor-fehlt'
|
||||
| 'code-falsch',
|
||||
public readonly nochSekunden?: number,
|
||||
) {
|
||||
super(grund)
|
||||
this.name = 'AnmeldeFehler'
|
||||
}
|
||||
}
|
||||
|
||||
function ipAusAnfrage(anfrage: Request | undefined): string | null {
|
||||
if (!anfrage) return null
|
||||
const weitergeleitet = anfrage.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
return weitergeleitet || anfrage.headers.get('x-real-ip') || null
|
||||
}
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
trustHost: true,
|
||||
secret: process.env.AUTH_SECRET,
|
||||
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
// Leerlauf bis zur automatischen Abmeldung.
|
||||
maxAge: env.sitzungMaxAlterSekunden,
|
||||
// Bei jeder Anfrage verlaengern, solange gearbeitet wird.
|
||||
updateAge: 60,
|
||||
},
|
||||
|
||||
pages: {
|
||||
signIn: '/anmelden',
|
||||
error: '/anmelden',
|
||||
},
|
||||
|
||||
providers: [
|
||||
Credentials({
|
||||
id: 'zugangsdaten',
|
||||
name: 'E-Mail, Passwort und Code',
|
||||
credentials: {
|
||||
email: { label: 'E-Mail', type: 'email' },
|
||||
passwort: { label: 'Passwort', type: 'password' },
|
||||
code: { label: 'Sechsstelliger Code', type: 'text' },
|
||||
},
|
||||
|
||||
async authorize(daten, anfrage) {
|
||||
const begonnen = Date.now()
|
||||
const email = String(daten?.email ?? '').trim().toLowerCase()
|
||||
const passwort = String(daten?.passwort ?? '')
|
||||
const code = String(daten?.code ?? '')
|
||||
const ip = ipAusAnfrage(anfrage)
|
||||
|
||||
if (!email || !passwort) {
|
||||
await aufMindestdauerWarten(begonnen)
|
||||
throw new AnmeldeFehler('daten-falsch')
|
||||
}
|
||||
|
||||
// 1. Sperre pruefen, bevor teuer gerechnet wird.
|
||||
const sperre = await anmeldungErlaubt(email, ip)
|
||||
if (sperre.gesperrt) {
|
||||
await versuchFesthalten(email, ip, false, 'gesperrt')
|
||||
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden)
|
||||
}
|
||||
|
||||
const benutzer = await db.user.findUnique({ where: { email } })
|
||||
|
||||
// 2. Passwort pruefen. Auch bei unbekanntem Benutzer wird gerechnet,
|
||||
// damit die Antwortzeit nichts verraet.
|
||||
const hashZumPruefen =
|
||||
benutzer?.passwortHash ??
|
||||
'$argon2id$v=19$m=19456,t=2,p=1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
||||
const passwortStimmt = await passwortPruefen(hashZumPruefen, passwort)
|
||||
|
||||
if (!benutzer || !passwortStimmt) {
|
||||
await versuchFesthalten(email, ip, false, benutzer ? 'passwort-falsch' : 'unbekannt')
|
||||
await protokollieren({
|
||||
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
|
||||
entitaet: 'User',
|
||||
entitaetId: benutzer?.id ?? null,
|
||||
userEmail: email,
|
||||
})
|
||||
await aufMindestdauerWarten(begonnen)
|
||||
throw new AnmeldeFehler('daten-falsch')
|
||||
}
|
||||
|
||||
if (!benutzer.aktiv || benutzer.geloeschtAm) {
|
||||
await versuchFesthalten(email, ip, false, 'konto-inaktiv')
|
||||
await aufMindestdauerWarten(begonnen)
|
||||
throw new AnmeldeFehler('konto-inaktiv')
|
||||
}
|
||||
|
||||
// 3. Zweiter Faktor. Ohne eingerichtetes TOTP kommt der Benutzer
|
||||
// zwar herein, das App-Layout schickt ihn aber sofort zur
|
||||
// Einrichtung - er kann nichts anderes tun.
|
||||
if (benutzer.totpSecretVerschluesselt && benutzer.totpAktiviertAm) {
|
||||
const codeOhneTrenner = code.replace(/[\s-]/g, '')
|
||||
let zweiterFaktorOk = false
|
||||
|
||||
if (/^\d{6}$/.test(codeOhneTrenner)) {
|
||||
const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt)
|
||||
zweiterFaktorOk = totpPruefen(secret, benutzer.email, codeOhneTrenner).gueltig
|
||||
} else if (codeOhneTrenner.length >= 12) {
|
||||
// Wiederherstellungscode - wird nach Gebrauch entfernt.
|
||||
const treffer = await wiederherstellungscodeFinden(
|
||||
benutzer.wiederherstellungscodes,
|
||||
code,
|
||||
)
|
||||
if (treffer >= 0) {
|
||||
zweiterFaktorOk = true
|
||||
const rest = benutzer.wiederherstellungscodes.filter((_, i) => i !== treffer)
|
||||
await db.user.update({
|
||||
where: { id: benutzer.id },
|
||||
data: { wiederherstellungscodes: rest },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!zweiterFaktorOk) {
|
||||
await versuchFesthalten(email, ip, false, 'code-falsch')
|
||||
await protokollieren({
|
||||
aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
|
||||
entitaet: 'User',
|
||||
entitaetId: benutzer.id,
|
||||
userEmail: email,
|
||||
userId: benutzer.id,
|
||||
bezeichnung: 'Zweiter Faktor falsch',
|
||||
})
|
||||
await aufMindestdauerWarten(begonnen)
|
||||
throw new AnmeldeFehler('code-falsch')
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Erfolg.
|
||||
if (hashVeraltet(benutzer.passwortHash)) {
|
||||
// Parameter wurden inzwischen erhoeht - Gelegenheit zum Nachziehen.
|
||||
await db.user.update({
|
||||
where: { id: benutzer.id },
|
||||
data: { passwortHash: await passwortHashen(passwort) },
|
||||
})
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
versuchFesthalten(email, ip, true),
|
||||
fehlversucheZuruecksetzen(email),
|
||||
db.user.update({
|
||||
where: { id: benutzer.id },
|
||||
data: { letzteAnmeldung: new Date() },
|
||||
}),
|
||||
protokollieren({
|
||||
aktion: 'ANMELDUNG',
|
||||
entitaet: 'User',
|
||||
entitaetId: benutzer.id,
|
||||
userId: benutzer.id,
|
||||
userEmail: benutzer.email,
|
||||
userName: benutzer.name,
|
||||
}),
|
||||
])
|
||||
|
||||
await aufMindestdauerWarten(begonnen)
|
||||
|
||||
return {
|
||||
id: benutzer.id,
|
||||
email: benutzer.email,
|
||||
name: benutzer.name,
|
||||
rolle: benutzer.rolle,
|
||||
sitzungsGeneration: benutzer.sitzungsGeneration,
|
||||
zweiterFaktorAktiv: Boolean(benutzer.totpAktiviertAm),
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
callbacks: {
|
||||
async jwt({ token, user, trigger, session }) {
|
||||
if (user) {
|
||||
token.rolle = user.rolle
|
||||
token.sitzungsGeneration = user.sitzungsGeneration
|
||||
token.zweiterFaktorAktiv = user.zweiterFaktorAktiv
|
||||
// Zeitpunkt der vollstaendigen Anmeldung - Grundlage fuer die
|
||||
// erneute Bestaetigung vor sensiblen Aktionen.
|
||||
token.angemeldetSeit = Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
// Nach dem Einrichten des zweiten Faktors aktualisiert die Seite
|
||||
// die Sitzung, ohne dass man sich neu anmelden muss.
|
||||
if (trigger === 'update' && session) {
|
||||
const aktualisierung = session as Partial<{
|
||||
zweiterFaktorAktiv: boolean
|
||||
sitzungsGeneration: number
|
||||
}>
|
||||
if (typeof aktualisierung.zweiterFaktorAktiv === 'boolean') {
|
||||
token.zweiterFaktorAktiv = aktualisierung.zweiterFaktorAktiv
|
||||
}
|
||||
if (typeof aktualisierung.sitzungsGeneration === 'number') {
|
||||
token.sitzungsGeneration = aktualisierung.sitzungsGeneration
|
||||
}
|
||||
}
|
||||
|
||||
return token
|
||||
},
|
||||
|
||||
async session({ session, token }) {
|
||||
session.user.id = token.sub ?? ''
|
||||
session.user.rolle = (token.rolle as Rolle) ?? 'BETREUER'
|
||||
session.user.sitzungsGeneration = (token.sitzungsGeneration as number) ?? 0
|
||||
session.user.zweiterFaktorAktiv = Boolean(token.zweiterFaktorAktiv)
|
||||
session.user.angemeldetSeit = (token.angemeldetSeit as number) ?? 0
|
||||
return session
|
||||
},
|
||||
},
|
||||
|
||||
events: {
|
||||
async signOut(nachricht) {
|
||||
const userId = 'token' in nachricht ? nachricht.token?.sub : undefined
|
||||
if (userId) {
|
||||
await protokollieren({
|
||||
aktion: 'ABMELDUNG',
|
||||
entitaet: 'User',
|
||||
entitaetId: userId,
|
||||
userId,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user