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:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
@@ -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<Buffer>;
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
+122
-10
@@ -17,11 +17,24 @@ const scrypt = promisify(scryptCallback) as (
|
||||
options: { N: number; r: number; p: number; maxmem: number },
|
||||
) => Promise<Buffer>;
|
||||
|
||||
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<string> {
|
||||
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<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
+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);
|
||||
|
||||
@@ -41,6 +41,45 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
|
||||
};
|
||||
|
||||
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",
|
||||
|
||||
+32
-3
@@ -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. */
|
||||
|
||||
+12
-2
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user