feat(auth): Durchgang durch Anmeldung und Sitzungen nach OWASP, v1.8.0
CI / pruefen (push) Canceled after 0s
CI / pruefen (push) Canceled after 0s
Geprueft gegen ASVS und die Cheat Sheets zu Authentication, Session Management und Password Storage. Das meiste stand schon gut da – Token mit 256 Bit und nur als Hash in der Datenbank, serverseitige Pruefung bei jeder Anfrage, Sitzungen weg beim Passwortwechsel, TOTP mit Replay-Schutz. Fuenf Punkte blieben. **Aufwand beim Hashen.** N war 2^15 bei p=1. Das Password Storage Cheat Sheet nennt 2^17/p=1 als Minimum und listet als gleichwertig unter anderem 2^16/p=2 und 2^15/p=3 – mit p=1 lag 2^15 unter *allen* zulaessigen Kombinationen und war rund dreimal billiger zu knacken als vorgesehen. Jetzt 2^17/p=1, gemessen 0,5 s und 128 MiB je Pruefung. Der Speicher ist gedeckelt: Node rechnet scrypt im libuv-Threadpool, also hoechstens vier parallel. **Neuhashen bei der Anmeldung.** Ohne das blieben bestehende Passwoerter fuer immer auf den alten Parametern – anheben laesst er sich nur dort, wo das Klartextpasswort vorliegt. needsRehash vergleicht den Gesamtaufwand N*r*p, nicht Feld fuer Feld, damit gleichwertige Kombinationen nicht grundlos neu gehasht werden. **Aufzaehlbarkeit.** Drei Zweige beantworteten sich *vor* der Passwort- pruefung und verrieten damit die Existenz eines Kontos: gesperrt (423), deaktiviert (403) und "kein Passwort hinterlegt, nimm einen Passkey" (401). Der einheitliche Text GENERIC war damit wirkungslos. Jetzt wird zuerst das Passwort geprueft; Klartext gibt es erst danach. Reine Passkey-Konten verbrennen dieselbe Rechenzeit, damit sich der Fall auch nicht an der Dauer zeigt. Der Preis ist der verlorene Hinweis "nimm den Passkey" – bewusst hingenommen. **Leerlauffrist.** lastSeenAt wurde gefuehrt, aber nie ausgewertet: ein abgegriffener Cookie galt bis zu 30 Tage, egal ob das Konto benutzt wurde. ASVS 3.3.2 will beide Grenzen. Acht Stunden, ueber SESSION_IDLE_HOURS einstellbar. Das Schreibintervall fuer lastSeenAt haengt jetzt an der Frist – bei fester Stunde haette eine kuerzere Frist sich selbst ausgehebelt. **Cookies.** SameSite Strict statt Lax, und bei https der Praefix "__Host-". Der laesst den Browser erzwingen, dass kein Domain-Attribut gesetzt ist – eine andere Subdomain kann den Cookie dann nicht ueberschreiben. Beides kostet einmalig eine neue Anmeldung. Dazu die Passwortpruefung: Wiederholungen, durchlaufende Zeichenfolgen und Passwoerter, die den Benutzernamen enthalten, werden abgelehnt. Zusammensetzungsregeln bleiben bewusst aus – ASVS 2.1.9 raet davon ab. Geprueft: 232 Unit-Tests (davon 12 neue zu Aufwand, Neuhashen und Mustern) und 30 E2E-Tests, darunter einer, der die Antwort auf ein stillgelegtes Konto gegen die auf ein erfundenes haelt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+33
-6
@@ -2,7 +2,12 @@ import { createHash, randomBytes } from "node:crypto";
|
||||
import { cookies } from "next/headers";
|
||||
import type { NextResponse } from "next/server";
|
||||
import type { User } from "@prisma/client";
|
||||
import { COOKIES_SECURE, SESSION_COOKIE, SESSION_TTL_MS } from "@/lib/config";
|
||||
import {
|
||||
COOKIES_SECURE,
|
||||
SESSION_COOKIE,
|
||||
SESSION_IDLE_MS,
|
||||
SESSION_TTL_MS,
|
||||
} from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
@@ -68,7 +73,10 @@ export function setSessionCookie(response: NextResponse, token: string, expiresA
|
||||
name: SESSION_COOKIE,
|
||||
value: token,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
// Strict statt Lax: Nebula hat keinen Einstieg von aussen, den eine
|
||||
// fremde Seite anstossen duerfte. Der Preis ist, dass ein Klick von
|
||||
// einer anderen Seite auf die Anmeldung fuehrt – ein Neuladen genuegt.
|
||||
sameSite: "strict",
|
||||
secure: COOKIES_SECURE,
|
||||
path: "/",
|
||||
expires: expiresAt,
|
||||
@@ -80,7 +88,10 @@ export function clearSessionCookie(response: NextResponse) {
|
||||
name: SESSION_COOKIE,
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
// Strict statt Lax: Nebula hat keinen Einstieg von aussen, den eine
|
||||
// fremde Seite anstossen duerfte. Der Preis ist, dass ein Klick von
|
||||
// einer anderen Seite auf die Anmeldung fuehrt – ein Neuladen genuegt.
|
||||
sameSite: "strict",
|
||||
secure: COOKIES_SECURE,
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
@@ -109,11 +120,27 @@ export async function getSessionUser(): Promise<SessionUser | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Leerlaufgrenze. Vorher wurde lastSeenAt zwar gefuehrt, aber nie
|
||||
// ausgewertet – ein abgegriffener Cookie galt damit bis zum absoluten
|
||||
// Ablauf, auch wenn das Konto wochenlang unberuehrt war.
|
||||
const untaetig = Date.now() - session.lastSeenAt.getTime();
|
||||
if (SESSION_IDLE_MS > 0 && untaetig > SESSION_IDLE_MS) {
|
||||
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!session.user.isActive) return null;
|
||||
|
||||
// Hoechstens einmal pro Stunde schreiben, damit nicht jede Anfrage die
|
||||
// Datenbank anfasst.
|
||||
if (Date.now() - session.lastSeenAt.getTime() > 60 * 60 * 1000) {
|
||||
/**
|
||||
* lastSeenAt nachfuehren, aber nicht bei jeder Anfrage schreiben.
|
||||
*
|
||||
* Das Intervall muss deutlich unter der Leerlaufgrenze liegen, sonst
|
||||
* verkuerzt die Schreibbremse sie: bei stuendlichem Schreiben und einer
|
||||
* Grenze von einer Stunde waere die Sitzung schon nach Sekunden faellig.
|
||||
* Ein Zwanzigstel der Frist, hoechstens fuenf Minuten.
|
||||
*/
|
||||
const intervall = SESSION_IDLE_MS > 0 ? Math.min(SESSION_IDLE_MS / 20, 5 * 60 * 1000) : 60 * 60 * 1000;
|
||||
if (untaetig > intervall) {
|
||||
void prisma.session
|
||||
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
|
||||
.catch(() => undefined);
|
||||
|
||||
Reference in New Issue
Block a user