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:
@@ -0,0 +1,107 @@
|
||||
import 'server-only'
|
||||
import { db } from '@/lib/db'
|
||||
import { env } from '@/lib/env'
|
||||
import { ipHashen } from '@/lib/crypto/hash'
|
||||
|
||||
/**
|
||||
* Rate-Limiting am Anmeldeformular.
|
||||
*
|
||||
* Bewusst in der Datenbank statt in Redis: die Anwendung laeuft auf einem
|
||||
* einzelnen kleinen Server, eine zusaetzliche Komponente waere Aufwand ohne
|
||||
* Gegenwert. Bei ein paar Benutzern und wenigen Anmeldungen pro Tag ist die
|
||||
* Tabelle winzig; ein Aufraeumen nach 30 Tagen haelt sie klein.
|
||||
*
|
||||
* Gezaehlt wird nach E-Mail UND nach Anschluss (IP-Hash). Damit hilft es
|
||||
* weder, dieselbe Adresse von vielen Anschluessen zu probieren, noch viele
|
||||
* Adressen von einem Anschluss.
|
||||
*/
|
||||
|
||||
export type SperrErgebnis =
|
||||
| { gesperrt: false; verbleibendeVersuche: number }
|
||||
| { gesperrt: true; nochSekunden: number }
|
||||
|
||||
function fensterStart(): Date {
|
||||
return new Date(Date.now() - env.loginFensterSekunden * 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft, ob eine Anmeldung ueberhaupt versucht werden darf. Wird VOR dem
|
||||
* Passwortvergleich aufgerufen - ein gesperrter Zugang soll gar nicht erst
|
||||
* Rechenzeit fuer Argon2 kosten.
|
||||
*/
|
||||
export async function anmeldungErlaubt(
|
||||
email: string,
|
||||
ip: string | null,
|
||||
): Promise<SperrErgebnis> {
|
||||
const seit = fensterStart()
|
||||
const kennung = email.trim().toLowerCase()
|
||||
const ipH = ipHashen(ip)
|
||||
|
||||
const fehlversuche = await db.loginVersuch.findMany({
|
||||
where: {
|
||||
erfolg: false,
|
||||
zeitpunkt: { gte: seit },
|
||||
OR: [{ email: kennung }, ...(ipH ? [{ ipHash: ipH }] : [])],
|
||||
},
|
||||
orderBy: { zeitpunkt: 'desc' },
|
||||
select: { zeitpunkt: true },
|
||||
take: env.loginMaxVersuche + 1,
|
||||
})
|
||||
|
||||
if (fehlversuche.length >= env.loginMaxVersuche) {
|
||||
const juengster = fehlversuche[0]!.zeitpunkt.getTime()
|
||||
const frei = juengster + env.loginSperreSekunden * 1000
|
||||
const nochSekunden = Math.max(1, Math.ceil((frei - Date.now()) / 1000))
|
||||
return { gesperrt: true, nochSekunden }
|
||||
}
|
||||
|
||||
return { gesperrt: false, verbleibendeVersuche: env.loginMaxVersuche - fehlversuche.length }
|
||||
}
|
||||
|
||||
/** Haelt einen Versuch fest. Der Grund hilft spaeter beim Nachvollziehen. */
|
||||
export async function versuchFesthalten(
|
||||
email: string,
|
||||
ip: string | null,
|
||||
erfolg: boolean,
|
||||
grund?: string,
|
||||
): Promise<void> {
|
||||
await db.loginVersuch.create({
|
||||
data: {
|
||||
email: email.trim().toLowerCase(),
|
||||
ipHash: ipHashen(ip),
|
||||
erfolg,
|
||||
grund: grund ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach einer erfolgreichen Anmeldung werden die Fehlversuche dieser
|
||||
* Adresse geloescht - sonst bliebe der Zaehler bis zum Fensterende stehen.
|
||||
*/
|
||||
export async function fehlversucheZuruecksetzen(email: string): Promise<void> {
|
||||
await db.loginVersuch.deleteMany({
|
||||
where: { email: email.trim().toLowerCase(), erfolg: false },
|
||||
})
|
||||
}
|
||||
|
||||
/** Aufraeumen; wird vom Wartungslauf aufgerufen. */
|
||||
export async function alteVersucheEntfernen(): Promise<number> {
|
||||
const grenze = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||
const { count } = await db.loginVersuch.deleteMany({
|
||||
where: { zeitpunkt: { lt: grenze } },
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Gleicht die Antwortzeit an. Ohne das verraet ein schneller "Benutzer
|
||||
* unbekannt" gegenueber einem langsamen "Passwort falsch", welche
|
||||
* E-Mail-Adressen es gibt.
|
||||
*/
|
||||
export async function aufMindestdauerWarten(begonnenAm: number, mindestMs = 400): Promise<void> {
|
||||
const vergangen = Date.now() - begonnenAm
|
||||
if (vergangen < mindestMs) {
|
||||
await new Promise((fertig) => setTimeout(fertig, mindestMs - vergangen))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user