feat(auth): Durchgang durch Anmeldung und Sitzungen nach OWASP, v1.8.0
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:
2026-08-15 15:38:05 +02:00
co-authored by Claude Opus 5
parent 1fe41ac2a5
commit a122187feb
18 changed files with 439 additions and 48 deletions
+46
View File
@@ -279,6 +279,52 @@ test("Mehrere Repositories je Projekt", async ({ request, playwright }) => {
expect(zumSchluss.repositories[0].repoName).toBe("swr");
});
test("Die Anmeldung verraet nicht, welche Konten es gibt", async ({ request, playwright }) => {
await login(request, ADMIN);
// Ein Konto anlegen und stilllegen.
const angelegt = await request.post("/api/admin/users", {
data: {
username: "stillgelegt",
password: "ein-ausreichend-langes-passwort",
role: "USER",
},
});
expect(angelegt.status()).toBe(201);
const id = (await angelegt.json()).user.id;
expect((await request.patch(`/api/admin/users/${id}`, { data: { isActive: false } })).ok())
.toBeTruthy();
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
// Falsches Passwort auf dem stillgelegten Konto ...
const stillgelegt = await anonym.post("/api/auth/login", {
data: { username: "stillgelegt", password: "falsches-passwort-hier" },
});
// ... und auf einem Konto, das es gar nicht gibt.
const erfunden = await anonym.post("/api/auth/login", {
data: { username: "gibtesnicht", password: "falsches-passwort-hier" },
});
// Beides muss ununterscheidbar sein – sonst laesst sich durchprobieren,
// welche Benutzernamen vergeben sind. Frueher kam hier 403 gegen 401.
expect(stillgelegt.status()).toBe(erfunden.status());
expect(stillgelegt.status()).toBe(401);
expect(await stillgelegt.json()).toEqual(await erfunden.json());
// Mit dem richtigen Passwort darf die Anwendung dann Klartext reden.
const mitPasswort = await anonym.post("/api/auth/login", {
data: { username: "stillgelegt", password: "ein-ausreichend-langes-passwort" },
});
expect(mitPasswort.status()).toBe(403);
expect((await mitPasswort.json()).error).toMatch(/deaktiviert/);
await anonym.dispose();
await request.delete(`/api/admin/users/${id}`);
});
test("Webhook: Signatur entscheidet, sonst passiert nichts", async ({ request, playwright }) => {
await login(request, ADMIN);