diff --git a/.env.example b/.env.example index 42b9ac7..c3c8e74 100644 --- a/.env.example +++ b/.env.example @@ -69,9 +69,14 @@ ADMINER_PORT="127.0.0.1:8080" # und Passkeys sind davon nicht betroffen. APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert" -# Lebensdauer einer Anmeldung in Tagen. +# Absolute Lebensdauer einer Anmeldung in Tagen – ab dem Anmelden gerechnet +# und nicht verlaengerbar. SESSION_TTL_DAYS="30" +# Leerlauffrist in Stunden: so lange darf eine Sitzung unbenutzt bleiben. +# Wird bei jeder Nutzung neu angestossen. 0 schaltet die Pruefung ab. +SESSION_IDLE_HOURS="8" + # ── Benachrichtigungen ─────────────────────────────────────── # ntfy und Webhook brauchen nichts weiter – die Ziele werden in der # Anwendung unter "Mein Konto" eingerichtet. diff --git a/FEATURES.md b/FEATURES.md index 81e6ea8..3647dce 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -22,8 +22,19 @@ sonst würden Konten ausgesperrt. Nach der Einrichtung ist `/setup` dauerhaft ge ### 0.2 Anmeldung -- **Benutzername und Passwort.** Mindestens 10 Zeichen, offensichtliche Passwörter werden - abgelehnt. Hashing mit scrypt. +- **Benutzername und Passwort.** Mindestens 10 Zeichen. Abgelehnt werden naheliegende + Passwörter, reine Ziffernfolgen, zu wenig verschiedene Zeichen (`abababab`), + durchlaufende Folgen (`abcdefgh`) und alles, was den eigenen Benutzernamen enthält. + **Keine Zusammensetzungsregeln** — ASVS 2.1.9 rät ausdrücklich davon ab, weil sie zu + `Passwort1!` führen statt zu Entropie. +- **Hashing mit scrypt**, `N = 2^17, r = 8, p = 1` nach dem OWASP Password Storage Cheat + Sheet (rund 0,5 s und 128 MiB je Prüfung). Die Parameter stehen im Hash, ältere bleiben + also lesbar — und werden bei der nächsten Anmeldung still auf den aktuellen Stand + gehoben. Das ist der einzige Moment, in dem das Klartextpasswort vorliegt. +- **Gleiche Antwort für alles Fehlgeschlagene.** Ob ein Konto existiert, gesperrt, + deaktiviert oder reines Passkey-Konto ist, sagt die Anmeldung erst, wenn das Passwort + stimmt. Davor gibt es genau einen Text und eine vergleichbare Antwortzeit + (`fakeVerifyDelay`), damit sich Benutzernamen nicht durchprobieren lassen. - **Passkey (WebAuthn).** Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel. Die Anmeldung läuft über auffindbare Credentials, ein Benutzername ist also nicht nötig. Mehrere Passkeys pro Konto, benennbar und einzeln entfernbar. @@ -760,6 +771,16 @@ nicht verloren gehen. Indizes auf Status, Verbindung und Änderungsdatum. `GIT_BLOCK_PRIVATE` und `GIT_ALLOWED_HOSTS`. - **Rate-Limiting** für Anmeldung (je IP und je Konto), ausgehende Git-Aufrufe und WebAuthn-Optionen. +- **Sitzungen**: Zufallstoken mit 256 Bit, in der Datenbank nur als SHA-256-Hash — wer die + Datenbank liest, kann keine Sitzung übernehmen. Geprüft wird bei jeder Anfrage, Abmelden + und Sperren wirken also sofort. Zwei Fristen nach ASVS 3.3.2: **absolut** 30 Tage ab + Anmeldung (`SESSION_TTL_DAYS`) und **im Leerlauf** 8 Stunden (`SESSION_IDLE_HOURS`). + Ein Passwortwechsel entwertet alle Sitzungen und stellt für das eigene Gerät eine neue + aus. +- **Cookies**: `HttpOnly`, `SameSite=Strict`, `Secure` sobald die App über HTTPS läuft, und + dann zusätzlich mit dem Präfix `__Host-`. Der Präfix lässt den Browser selbst erzwingen, + dass kein `Domain` gesetzt ist — eine andere Subdomain kann den Cookie damit nicht + überschreiben und keine Sitzung unterschieben. - Access Tokens verschlüsselt mit **AES-256-GCM**, Schlüssel per **scrypt** aus `APP_SECRET` abgeleitet, Chiffrat versioniert (`v1...`). - Tokens werden nie an den Browser gesendet — alle API-Antworten nutzen eine Feldauswahl diff --git a/README.md b/README.md index 00fbee2..7e56091 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,8 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert. | `APP_URL` | Öffentliche Adresse — bestimmt die WebAuthn-RP-ID | `http://localhost:3000` | | `DATABASE_URL` | PostgreSQL-Verbindung (Prisma) | `postgresql://nebula:nebula@localhost:5432/nebula` | | `APP_SECRET` | Schlüssel für die Token-Verschlüsselung (AES-256-GCM) | — **unbedingt setzen** | -| `SESSION_TTL_DAYS` | Lebensdauer einer Anmeldung in Tagen | `30` | +| `SESSION_TTL_DAYS` | Absolute Lebensdauer einer Anmeldung in Tagen | `30` | +| `SESSION_IDLE_HOURS` | Nach so vielen Stunden ohne Nutzung läuft die Sitzung ab (`0` = aus) | `8` | | `PORT` | Port der Anwendung | `3000` | | `APP_PORT` | Host-Port-Mapping des App-Containers | `127.0.0.1:3000` | | `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` | diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index 3fa0060..161afa6 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -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); diff --git a/package.json b/package.json index 9fb664c..f7649e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nebula", - "version": "1.7.0", + "version": "1.8.0", "private": true, "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "scripts": { diff --git a/src/app/api/account/password/route.ts b/src/app/api/account/password/route.ts index 4a93741..9767eb9 100644 --- a/src/app/api/account/password/route.ts +++ b/src/app/api/account/password/route.ts @@ -47,7 +47,7 @@ export async function POST(request: Request) { } } - const weak = validatePasswordStrength(parsed.data.newPassword); + const weak = validatePasswordStrength(parsed.data.newPassword, user.username); if (weak) { return NextResponse.json({ error: weak, fields: { newPassword: weak } }, { status: 422 }); } diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index cacf38f..a46dd1c 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -69,7 +69,7 @@ export async function PATCH(request: Request, { params }: Params) { } if (password) { - const weak = validatePasswordStrength(password); + const weak = validatePasswordStrength(password, target.username); if (weak) { return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 }); } diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index d4902a7..8058075 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -47,7 +47,7 @@ export async function POST(request: Request) { const { username, displayName, email, password, role } = parsed.data; - const weak = validatePasswordStrength(password); + const weak = validatePasswordStrength(password, username); if (weak) { return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 }); } diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index ab1a8c3..1f0c9e0 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa"; -import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password"; +import { fakeVerifyDelay, hashPassword, needsRehash, verifyPassword } from "@/lib/auth/password"; import { createSession, setSessionCookie } from "@/lib/auth/session"; import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users"; import { prisma } from "@/lib/db"; @@ -42,6 +42,30 @@ export async function POST(request: Request) { return jsonError(GENERIC, 401); } + /** + * Erst das Passwort pruefen, dann ueber den Kontozustand reden. + * + * Frueher standen die Abfragen auf gesperrt, deaktiviert und + * "kein Passwort hinterlegt" **vor** der Pruefung und beantworteten sich + * damit ohne jede Kenntnis des Passworts. Wer einen Benutzernamen riet, + * bekam an der Antwort gesagt, ob es ihn gibt – genau die Aufzaehlbarkeit, + * die der einheitliche Text GENERIC sonst verhindert. + * + * Jetzt gilt: falsches Passwort → immer dieselbe Antwort. Die + * hilfreichere Auskunft bekommt nur, wer das Passwort kennt. + */ + const valid = user.passwordHash + ? await verifyPassword(password, user.passwordHash) + : // Kein Passwort hinterlegt (reines Passkey-Konto): trotzdem Zeit + // verbrennen, damit sich der Fall nicht an der Antwortdauer zeigt. + await fakeVerifyDelay().then(() => false); + + if (!valid) { + // Fehlversuche nur zaehlen, wo es ein Passwort zu erraten gibt. + if (user.passwordHash) await noteFailedLogin(user.id); + return jsonError(GENERIC, 401); + } + const locked = lockRemainingSeconds(user.lockedUntil); if (locked > 0) { return jsonError( @@ -51,22 +75,20 @@ export async function POST(request: Request) { } if (!user.isActive) { - await fakeVerifyDelay(); return jsonError("Dieses Konto ist deaktiviert.", 403); } - if (!user.passwordHash) { - await fakeVerifyDelay(); - return jsonError( - "Fuer dieses Konto ist kein Passwort hinterlegt – bitte mit Passkey anmelden.", - 401, - ); - } - - const valid = await verifyPassword(password, user.passwordHash); - if (!valid) { - await noteFailedLogin(user.id); - return jsonError(GENERIC, 401); + /** + * Der Aufwand beim Hashen ist gestiegen. Wer sich mit einem Passwort + * anmeldet, dessen Hash noch nach den alten Vorgaben erzeugt wurde, + * bekommt hier still einen neuen – es ist der einzige Moment, in dem das + * Klartextpasswort vorliegt. Nebenlaeufig, damit die Anmeldung nicht auf + * die halbe Sekunde wartet. + */ + if (needsRehash(user.passwordHash)) { + void hashPassword(password) + .then((neu) => prisma.user.update({ where: { id: user.id }, data: { passwordHash: neu } })) + .catch(() => undefined); } // Das Passwort stimmt. Ist ein zweiter Faktor eingerichtet, entsteht hier diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 3f69af8..dade79f 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -39,7 +39,7 @@ export async function POST(request: Request) { const { username, displayName, email, password } = parsed.data; - const weak = validatePasswordStrength(password); + const weak = validatePasswordStrength(password, username); if (weak) { return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 }); } diff --git a/src/app/api/auth/setup/route.ts b/src/app/api/auth/setup/route.ts index b0db2f6..778844a 100644 --- a/src/app/api/auth/setup/route.ts +++ b/src/app/api/auth/setup/route.ts @@ -34,7 +34,7 @@ export async function POST(request: Request) { const { username, displayName, email, password, mode } = parsed.data; - const weak = validatePasswordStrength(password); + const weak = validatePasswordStrength(password, username); if (weak) { return NextResponse.json( { error: weak, fields: { password: weak } }, diff --git a/src/lib/auth/mfa.ts b/src/lib/auth/mfa.ts index 16b713e..3e68717 100644 --- a/src/lib/auth/mfa.ts +++ b/src/lib/auth/mfa.ts @@ -46,7 +46,7 @@ export function setMfaCookie(response: NextResponse, token: string) { name: MFA_COOKIE, value: token, httpOnly: true, - sameSite: "lax", + sameSite: "strict", secure: COOKIES_SECURE, path: "/", maxAge: Math.floor(MFA_TTL_MS / 1000), @@ -58,7 +58,7 @@ export function clearMfaCookie(response: NextResponse) { name: MFA_COOKIE, value: "", httpOnly: true, - sameSite: "lax", + sameSite: "strict", secure: COOKIES_SECURE, path: "/", maxAge: 0, diff --git a/src/lib/auth/password.test.ts b/src/lib/auth/password.test.ts index ee6af7a..b1e8042 100644 --- a/src/lib/auth/password.test.ts +++ b/src/lib/auth/password.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { PASSWORD_MIN_LENGTH } from "./constants"; -import { hashPassword, validatePasswordStrength, verifyPassword } from "./password"; +import { hashPassword, needsRehash, validatePasswordStrength, verifyPassword } from "./password"; describe("hashPassword und verifyPassword", () => { it("erkennt das richtige Passwort wieder", async () => { @@ -64,3 +64,82 @@ describe("validatePasswordStrength", () => { expect(validatePasswordStrength("a".repeat(300))).toMatch(/hoechstens/); }); }); + +describe("Aufwand nach OWASP", () => { + it("hasht mit den heute vorgesehenen Parametern", async () => { + // OWASP Password Storage Cheat Sheet: N = 2^17, r = 8, p = 1. + const hash = await hashPassword("ein-langes-testpasswort"); + const [, N, r, p] = hash.split("$"); + expect(Number(N)).toBe(131072); + expect(Number(r)).toBe(8); + expect(Number(p)).toBe(1); + }); + + it("prueft alte Hashes weiterhin, weil die Parameter im Hash stehen", async () => { + // Von Hand mit den frueheren Werten erzeugt – muss gueltig bleiben, + // sonst waere ein Anheben des Aufwands eine Aussperrung. + const alt = + "scrypt$32768$8$1$" + + Buffer.from("salzsalzsalzsalz").toString("base64url") + + "$"; + // Echten Hash mit alten Parametern bauen: + const { scrypt } = await import("node:crypto"); + const { promisify } = await import("node:util"); + const s = promisify(scrypt) as (a: string, b: Buffer, c: number, d: object) => Promise; + const salt = Buffer.from("salzsalzsalzsalz"); + const derived = await s("ein-langes-testpasswort", salt, 64, { + N: 32768, + r: 8, + p: 1, + maxmem: 96 * 1024 * 1024, + }); + const alterHash = `scrypt$32768$8$1$${salt.toString("base64url")}$${derived.toString("base64url")}`; + expect(alt.length).toBeGreaterThan(0); + + await expect(verifyPassword("ein-langes-testpasswort", alterHash)).resolves.toBe(true); + // ... und wird als erneuerungsbeduerftig erkannt. + expect(needsRehash(alterHash)).toBe(true); + }); + + it("laesst frische Hashes in Ruhe", async () => { + expect(needsRehash(await hashPassword("ein-langes-testpasswort"))).toBe(false); + }); + + it("erkennt gleichwertige Kombinationen als ausreichend", () => { + // N=2^16 mit p=2 ergibt denselben Gesamtaufwand wie N=2^17 mit p=1 und + // ist nach OWASP ebenso zulaessig. + expect(needsRehash("scrypt$65536$8$2$c2FsdA$aGFzaA")).toBe(false); + // N=2^15 mit p=1 dagegen nicht – das war der alte Zustand. + expect(needsRehash("scrypt$32768$8$1$c2FsdA$aGFzaA")).toBe(true); + }); + + it("haelt Unsinn fuer erneuerungsbeduerftig", () => { + expect(needsRehash("voelliger-unsinn")).toBe(true); + expect(needsRehash(null)).toBe(false); + }); +}); + +describe("Schwache Muster", () => { + it("weist Wiederholungen ab", () => { + expect(validatePasswordStrength("aaaaaaaaaaaa")).toBeTruthy(); + expect(validatePasswordStrength("abababababab")).toBeTruthy(); + }); + + it("weist Zeichenfolgen ab", () => { + expect(validatePasswordStrength("abcdefghijkl")).toBeTruthy(); + expect(validatePasswordStrength("87654321abcd")).toBeTruthy(); + }); + + it("weist Passwoerter mit dem Benutzernamen ab", () => { + expect(validatePasswordStrength("chefin-geheimnis", "chefin")).toMatch(/Benutzernamen/); + // Ohne Benutzernamen ist dasselbe Passwort in Ordnung. + expect(validatePasswordStrength("chefin-geheimnis")).toBeNull(); + // Sehr kurze Namen zaehlen nicht – sonst scheitert fast alles. + expect(validatePasswordStrength("ab-langes-passwort", "ab")).toBeNull(); + }); + + it("laesst ein gutes Passwort mit Wiederholung durch", () => { + // Ein "ll" ist kein Grund zur Ablehnung, solange genug Vielfalt da ist. + expect(validatePasswordStrength("Rollladen-Fenster-42")).toBeNull(); + }); +}); diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts index b620891..39f5393 100644 --- a/src/lib/auth/password.ts +++ b/src/lib/auth/password.ts @@ -17,11 +17,24 @@ const scrypt = promisify(scryptCallback) as ( options: { N: number; r: number; p: number; maxmem: number }, ) => Promise; -const PARAMS = { N: 32768, r: 8, p: 1 }; +/** + * Arbeitsaufwand nach der Empfehlung des OWASP Password Storage Cheat Sheet: + * N = 2^17, r = 8, p = 1. Frueher stand hier N = 2^15 mit p = 1 – das liegt + * unter *jeder* der dort genannten zulaessigen Kombinationen (zu 2^15 + * gehoerte p = 3) und war damit rund dreimal billiger zu knacken als + * vorgesehen. + * + * Gemessen kostet das rund 0,5 s je Pruefung und 128 MiB. Der Speicher + * klingt nach viel, ist aber gedeckelt: Node rechnet scrypt im + * libuv-Threadpool, standardmaessig vier Faeden parallel. Mehr als rund + * 512 MiB koennen also nicht gleichzeitig belegt sein, und davor liegen + * noch die Ratenbegrenzungen je IP und je Benutzername. + */ +const PARAMS = { N: 131072, r: 8, p: 1 }; const KEY_LENGTH = 64; -// scrypt braucht rund 128 * N * r Bytes; der Standardwert von 32 MB reicht -// dafuer nicht ganz. -const MAX_MEM = 96 * 1024 * 1024; +// scrypt braucht rund 128 * N * r Bytes – bei diesen Werten 128 MiB. +// Etwas Luft nach oben, damit die Pruefung nicht am Deckel scheitert. +const MAX_MEM = 192 * 1024 * 1024; export async function hashPassword(password: string): Promise { const salt = randomBytes(16); @@ -72,6 +85,30 @@ export async function verifyPassword( return timingSafeEqual(derived, expected); } +/** + * Ist der gespeicherte Hash mit schwaecheren Parametern erzeugt worden als + * heute ueblich? + * + * Der Aufwand laesst sich nur fuer neue Passwoerter anheben – bestehende + * Hashes bleiben sonst fuer immer auf dem alten Stand. OWASP empfiehlt + * deshalb, bei der naechsten erfolgreichen Anmeldung still neu zu hashen. + * Das geht nur dort, weil das Klartextpasswort ausschliesslich in diesem + * Moment vorliegt. + */ +export function needsRehash(stored: string | null | undefined): boolean { + if (!stored) return false; + + const parts = stored.split("$"); + if (parts.length !== 6 || parts[0] !== "scrypt") return true; + + const [N, r, p] = [Number(parts[1]), Number(parts[2]), Number(parts[3])]; + if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) return true; + + // Verglichen wird der Gesamtaufwand, nicht Feld fuer Feld: N=2^16 mit p=2 + // ist nach OWASP genauso zulaessig wie N=2^17 mit p=1. + return N * r * p < PARAMS.N * PARAMS.r * PARAMS.p; +} + /** * Verbrennt vergleichbar viel Zeit wie eine echte Pruefung. Wird aufgerufen, * wenn es den Benutzernamen gar nicht gibt – sonst liesse sich an der @@ -84,27 +121,102 @@ export async function fakeVerifyDelay(): Promise { }); } +/** + * Naheliegende Passwoerter. + * + * ASVS 2.1.7 verlangt einen Abgleich gegen bekannte geleakte Passwoerter. + * Ein Dienst wie Have-I-Been-Pwned schiede dafuer aus – Nebula ruft + * grundsaetzlich nichts von aussen, und ein Passwort-Praefix nach draussen + * zu geben widerspraeche dem erst recht. Diese Liste ist der Kompromiss: + * die haeufigsten deutschen und englischen Kandidaten, dazu weiter unten + * Muster statt Einzelwoerter, was mehr abdeckt als jede Liste dieser Groesse. + */ const OBVIOUS = new Set([ - "password", "passwort", "12345678", "123456789", "1234567890", - "qwertzuiop", "qwertyuiop", "administrator", "changeme", "letmein", - "willkommen", "nebula123", "geheim123", + // Klassiker + "password", "password1", "password123", "passwort", "passwort1", + "passwort123", "kennwort", "geheim", "geheim123", "changeme", "letmein", + "welcome", "willkommen", "willkommen1", "administrator", "admin1234", + "iloveyou", "sunshine", "princess", "dragon", "monkey", "football", + "baseball", "superman", "trustno1", "starwars", "whatever", "freedom", + // Zahlen und Tastaturlaeufe + "12345678", "123456789", "1234567890", "87654321", "11111111", + "qwertzuiop", "qwertyuiop", "asdfghjkl", "qwertz123", "qwerty123", + "1qay2wsx", "1q2w3e4r", "1q2w3e4r5t", "asdf1234", "qwer1234", + // Deutsch + "sommer2024", "sommer2025", "sommer2026", "winter2024", "winter2025", + "winter2026", "fussball", "deutschland", "hallowelt", "sonnenschein", + "schatz123", "liebling", "blumen123", "internet", "computer", + // Projektnah + "nebula", "nebula123", "nebula2026", "dashboard", "vibecoding", ]); export { PASSWORD_MIN_LENGTH }; -/** Gibt eine Fehlermeldung zurueck oder null, wenn das Passwort taugt. */ -export function validatePasswordStrength(password: string): string | null { +/** + * Laenge der laengsten auf- oder absteigenden Zeichenfolge ("abcd", "4321"). + * + * Zurueckgegeben wird die Laenge und nicht nur ein Ja/Nein: entscheidend ist, + * *wie viel* des Passworts aus der Folge besteht. Ein "de" mitten in einem + * Wort ist harmlos, "abcdefghijkl" ist das ganze Passwort. + */ +function laengsteFolge(wert: string): number { + if (wert.length < 2) return wert.length; + let beste = 1; + let auf = 1; + let ab = 1; + for (let i = 1; i < wert.length; i += 1) { + const schritt = wert.charCodeAt(i) - wert.charCodeAt(i - 1); + auf = schritt === 1 ? auf + 1 : 1; + ab = schritt === -1 ? ab + 1 : 1; + beste = Math.max(beste, auf, ab); + } + return beste; +} + +/** + * Gibt eine Fehlermeldung zurueck oder null, wenn das Passwort taugt. + * + * Bewusst **keine** Zusammensetzungsregeln ("mindestens ein Sonderzeichen"). + * ASVS 2.1.9 raet ausdruecklich davon ab: sie treiben Leute zu "Passwort1!" + * und senken die Entropie, statt sie zu heben. Geprueft wird stattdessen + * auf Laenge und auf das, was nachweislich schwach ist. + */ +export function validatePasswordStrength( + password: string, + /** Benutzername des Kontos, falls bekannt – er darf nicht im Passwort stehen. */ + username?: string | null, +): string | null { if (password.length < PASSWORD_MIN_LENGTH) { return `Das Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen haben.`; } if (password.length > 200) { return "Das Passwort darf hoechstens 200 Zeichen haben."; } - if (OBVIOUS.has(password.toLowerCase())) { + + const klein = password.toLowerCase(); + + if (OBVIOUS.has(klein)) { return "Dieses Passwort ist zu naheliegend."; } if (/^\d+$/.test(password)) { return "Nur Ziffern reichen nicht – bitte Buchstaben oder Zeichen ergaenzen."; } + // Zu wenig verschiedene Zeichen – deckt "aaaaaaaa" ebenso ab wie + // "abababab" oder "12121212", ohne dafuer je ein eigenes Muster zu + // brauchen. + if (new Set(klein).size <= 4) { + return "Das Passwort benutzt zu wenige verschiedene Zeichen."; + } + // Und wenn der groesste Teil des Passworts eine durchlaufende Folge ist. + if (laengsteFolge(klein) >= Math.max(6, Math.ceil(password.length * 0.6))) { + return "Zeichenfolgen wie „abcd“ oder „4321“ sind zu leicht zu raten."; + } + // Ein Passwort, das den Benutzernamen enthaelt, ist der erste Versuch + // jedes Angreifers. + const name = username?.trim().toLowerCase(); + if (name && name.length >= 3 && klein.includes(name)) { + return "Das Passwort darf den Benutzernamen nicht enthalten."; + } + return null; } diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 36e96e6..0bc0a2e 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -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 { 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); diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index 73c6490..71ccdce 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -41,6 +41,45 @@ export const KIND_META: Record }; export const CHANGELOG: Release[] = [ + { + version: "1.8.0", + date: "2026-08-15", + headline: "Durchgang durch Anmeldung und Sitzungen nach OWASP.", + changes: [ + { + kind: "besser", + text: + "Passwörter werden deutlich aufwendiger gehasht (scrypt mit N=2^17 statt 2^15) — " + + "das entspricht der Empfehlung des OWASP Password Storage Cheat Sheet. Bestehende " + + "Passwörter werden bei der nächsten Anmeldung still auf den neuen Stand gebracht.", + }, + { + kind: "behoben", + text: + "Die Anmeldung verriet, welche Benutzernamen existieren: gesperrte, deaktivierte " + + "und reine Passkey-Konten antworteten anders als unbekannte — und das schon vor " + + "der Passwortprüfung. Jetzt gibt es die Auskunft erst, wenn das Passwort stimmt.", + }, + { + kind: "neu", + text: + "Sitzungen laufen nach acht Stunden ohne Nutzung ab, zusätzlich zur bestehenden " + + "Frist von 30 Tagen. Einstellbar über SESSION_IDLE_HOURS; 0 schaltet es ab.", + }, + { + kind: "besser", + text: + "Das Sitzungscookie trägt bei HTTPS den Präfix „__Host-“ und gilt nur noch " + + "SameSite=Strict. Beides muss einmalig neu angemeldet werden.", + }, + { + kind: "besser", + text: + "Die Passwortprüfung erkennt jetzt Wiederholungen, Zeichenfolgen und Passwörter, " + + "die den eigenen Benutzernamen enthalten.", + }, + ], + }, { version: "1.7.0", date: "2026-08-15", diff --git a/src/lib/config.ts b/src/lib/config.ts index 0cfb9fd..be22288 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -73,18 +73,47 @@ export const WEBAUTHN_AVAILABLE = /** Cookies nur ueber TLS ausliefern, wenn die App per https laeuft. */ export const COOKIES_SECURE = APP_URL_PARSED.protocol === "https:"; -/** Lebensdauer einer Sitzung. */ +/** + * Absolute Lebensdauer einer Sitzung – ab Anmeldung, nicht verlaengerbar. + * Nach dieser Frist ist Schluss, egal wie aktiv jemand war. + */ export const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS ?? 30); export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000; -export const SESSION_COOKIE = "nebula_session"; +/** + * Leerlauffrist: so lange darf eine Sitzung unbenutzt bleiben. + * + * ASVS 3.3.2 verlangt beides – eine absolute *und* eine Leerlaufgrenze. + * Vorher gab es nur die absolute, ein abgegriffener Cookie galt also bis zu + * 30 Tage, unabhaengig davon, ob das Konto benutzt wurde. + * + * Acht Stunden sind der Kompromiss: ein Arbeitstag am Stueck bleibt + * angemeldet, ueber Nacht laeuft die Sitzung ab. Wem das zu streng ist, + * setzt SESSION_IDLE_HOURS hoch; 0 schaltet die Leerlaufgrenze ab. + */ +export const SESSION_IDLE_HOURS = Number(process.env.SESSION_IDLE_HOURS ?? 8); +export const SESSION_IDLE_MS = + SESSION_IDLE_HOURS > 0 ? SESSION_IDLE_HOURS * 60 * 60 * 1000 : 0; + +/** + * Name des Sitzungscookies. + * + * Mit dem Praefix "__Host-" erzwingt der Browser selbst, was wir ohnehin + * setzen: Secure, Pfad "/" und kein Domain-Attribut. Letzteres ist der + * eigentliche Gewinn – eine andere Subdomain kann den Cookie dann nicht + * ueberschreiben und damit keine Sitzung unterschieben. + * + * Nur bei https, denn der Praefix verlangt zwingend Secure; ohne TLS wuerde + * der Browser den Cookie schlicht verwerfen. + */ +export const SESSION_COOKIE = COOKIES_SECURE ? "__Host-nebula_session" : "nebula_session"; /** * Cookie fuer die halbfertige Anmeldung zwischen Passwort und zweitem Faktor. * Kurzlebig – wer den Code nicht binnen fuenf Minuten eingibt, faengt von * vorne an. */ -export const MFA_COOKIE = "nebula_mfa"; +export const MFA_COOKIE = COOKIES_SECURE ? "__Host-nebula_mfa" : "nebula_mfa"; export const MFA_TTL_MS = 5 * 60 * 1000; /** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */ diff --git a/src/middleware.ts b/src/middleware.ts index f5158e8..0fa7d85 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -15,7 +15,17 @@ import { NextResponse, type NextRequest } from "next/server"; */ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); -const SESSION_COOKIE = "nebula_session"; + +/** + * Beide moeglichen Namen des Sitzungscookies. + * + * Bei https traegt er den Praefix "__Host-", sonst nicht (der Praefix + * verlangt zwingend Secure). Welcher gilt, entscheidet lib/config anhand + * der APP_URL – hier im Edge-Runtime wird schlicht auf beide geschaut. Es + * geht ohnehin nur um die grobe Vorpruefung; verbindlich entscheidet + * requireUserPage bzw. requireUserApi. + */ +const SESSION_COOKIES = ["__Host-nebula_session", "nebula_session"]; /** Ohne Anmeldung erreichbar. */ const PUBLIC_PATHS = ["/login", "/setup", "/register"]; @@ -110,7 +120,7 @@ export function middleware(request: NextRequest) { } // ── 2. Zugangsschranke ───────────────────────────────────── - const hasSession = Boolean(request.cookies.get(SESSION_COOKIE)?.value); + const hasSession = SESSION_COOKIES.some((name) => Boolean(request.cookies.get(name)?.value)); if (!hasSession && !isPublicPath(pathname)) { if (pathname.startsWith("/api/")) { return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });