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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user