feat(auth): Anmeldung mit Passwort und Passkey

Kern der Benutzerverwaltung.

Passwoerter:
- scrypt aus der Node-Standardbibliothek (N=32768, r=8, p=1), speicherhart
  und ohne zusaetzliches natives Modul
- Format scrypt$N$r$p$salt$hash, damit sich die Parameter spaeter anheben
  lassen
- fakeVerifyDelay verbrennt bei unbekanntem Benutzernamen dieselbe
  Rechenzeit, sonst liesse sich an der Antwortzeit ablesen, welche Konten
  existieren

Passkeys ueber SimpleWebAuthn 13:
- Anmeldung ohne Benutzernamen ueber auffindbare Credentials
- Challenges liegen in der Datenbank und werden beim Einloesen geloescht,
  koennen also nicht erneut eingespielt werden
- rpID und Origin kommen aus APP_URL

Sitzungen:
- Zufallstoken im HttpOnly-Cookie, in der Datenbank nur dessen Hash
- Abmelden und Sperren wirken sofort, weil bei jeder Anfrage nachgeschlagen
  wird - anders als bei einem selbsttragenden Token

Middleware: CSRF-Pruefung ueber Sec-Fetch-Site bzw. Origin,
Sicherheitskopfzeilen inklusive CSP mit Per-Request-Nonce und
strict-dynamic, dazu eine grobe Zugangsschranke. Letztere ist ausdruecklich
keine Sicherheitsgrenze - im Edge-Runtime gibt es keine Datenbank.
Verbindlich entscheiden requireUserPage und requireUserApi, serverseitig,
in jeder Seite und jedem Endpunkt.

config.ts liest APP_NAME, APP_TAGLINE und APP_URL zur Laufzeit statt ueber
NEXT_PUBLIC_-Variablen. Die waeren beim Build fest ins Bundle geschrieben;
so laesst sich dasselbe Docker-Image ohne Neubau umbenennen und unter einer
anderen Adresse betreiben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 21:30:12 +02:00
co-authored by Claude Opus 5
parent 01d42662ca
commit 13bc5aa0b8
11 changed files with 1183 additions and 0 deletions
+263
View File
@@ -10,6 +10,8 @@
"hasInstallScript": true,
"dependencies": {
"@prisma/client": "^6.8.2",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"lucide-react": "^0.511.0",
"next": "^15.3.3",
"react": "^19.1.0",
@@ -495,6 +497,12 @@
"node": ">=18"
}
},
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
@@ -1059,6 +1067,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@next/env": {
"version": "15.5.21",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.21.tgz",
@@ -1205,6 +1219,174 @@
"node": ">= 10"
}
},
"node_modules/@peculiar/asn1-android": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz",
"integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
"integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
"integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
"integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
"integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-rsa": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
"integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
"integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pfx": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
"integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
"integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
"integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
"integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@prisma/client": {
"version": "6.19.3",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
@@ -1290,6 +1472,31 @@
"@prisma/debug": "6.19.3"
}
},
"node_modules/@simplewebauthn/browser": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"license": "MIT"
},
"node_modules/@simplewebauthn/server": {
"version": "13.3.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz",
"integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.1",
"@peculiar/asn1-rsa": "^2.6.1",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.1",
"@peculiar/x509": "^1.14.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -1619,6 +1826,20 @@
"@types/react": "^19.2.0"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
@@ -2461,6 +2682,24 @@
],
"license": "MIT"
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/rc9": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
@@ -2507,6 +2746,12 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -2659,6 +2904,24 @@
"fsevents": "~2.3.3"
}
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+2
View File
@@ -30,6 +30,8 @@
},
"dependencies": {
"@prisma/client": "^6.8.2",
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"lucide-react": "^0.511.0",
"next": "^15.3.3",
"react": "^19.1.0",
+10
View File
@@ -0,0 +1,10 @@
/**
* Konstanten, die Server und Browser gemeinsam brauchen.
*
* Bewusst in einer eigenen Datei ohne node:crypto-Import: password.ts laesst
* sich in Client-Komponenten nicht einbinden, diese Werte schon.
*/
export const PASSWORD_MIN_LENGTH = 10;
export const USERNAME_MIN_LENGTH = 3;
export const USERNAME_MAX_LENGTH = 32;
+62
View File
@@ -0,0 +1,62 @@
import { redirect } from "next/navigation";
import { NextResponse } from "next/server";
import { getSessionUser, type SessionUser } from "@/lib/auth/session";
import { getInstanceState } from "@/lib/auth/settings";
/**
* Zugriffsschutz.
*
* Die Middleware prueft nur, ob ueberhaupt ein Sitzungscookie mitkommt – im
* Edge-Runtime steht keine Datenbank zur Verfuegung. Die eigentliche
* Entscheidung faellt hier, serverseitig, und zwar in jeder Seite und jedem
* API-Endpunkt. Das ist die Sicherheitsgrenze.
*/
export type { SessionUser };
/** Fuer Server Components: leitet auf /login bzw. /setup um. */
export async function requireUserPage(): Promise<SessionUser> {
const user = await getSessionUser();
if (user) return user;
const state = await getInstanceState();
redirect(state.needsSetup ? "/setup" : "/login");
}
/** Wie requireUserPage, verlangt zusaetzlich die Administratorrolle. */
export async function requireAdminPage(): Promise<SessionUser> {
const user = await requireUserPage();
if (user.role !== "ADMIN") redirect("/");
return user;
}
export type ApiAuth =
| { ok: true; user: SessionUser }
| { ok: false; response: NextResponse };
/** Fuer Route Handler: liefert entweder den Benutzer oder eine 401/403-Antwort. */
export async function requireUserApi(): Promise<ApiAuth> {
const user = await getSessionUser();
if (!user) {
return {
ok: false,
response: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }),
};
}
return { ok: true, user };
}
export async function requireAdminApi(): Promise<ApiAuth> {
const auth = await requireUserApi();
if (!auth.ok) return auth;
if (auth.user.role !== "ADMIN") {
return {
ok: false,
response: NextResponse.json(
{ error: "Diese Aktion ist Administratoren vorbehalten." },
{ status: 403 },
),
};
}
return auth;
}
+110
View File
@@ -0,0 +1,110 @@
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
import { promisify } from "node:util";
import { PASSWORD_MIN_LENGTH } from "@/lib/auth/constants";
/**
* Passwort-Hashing mit scrypt aus der Node-Standardbibliothek – kein
* zusaetzliches natives Modul noetig, und scrypt ist speicherhart, also
* deutlich unangenehmer fuer GPU-Angriffe als PBKDF2.
*
* Ablageformat: scrypt$<N>$<r>$<p>$<salt>$<hash> (salt/hash base64url)
*/
const scrypt = promisify(scryptCallback) as (
password: string | Buffer,
salt: string | Buffer,
keylen: number,
options: { N: number; r: number; p: number; maxmem: number },
) => Promise<Buffer>;
const PARAMS = { N: 32768, 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;
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const derived = await scrypt(password.normalize("NFKC"), salt, KEY_LENGTH, {
...PARAMS,
maxmem: MAX_MEM,
});
return [
"scrypt",
PARAMS.N,
PARAMS.r,
PARAMS.p,
salt.toString("base64url"),
derived.toString("base64url"),
].join("$");
}
export async function verifyPassword(
password: string,
stored: string | null | undefined,
): Promise<boolean> {
if (!stored) return false;
const parts = stored.split("$");
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
const N = Number(parts[1]);
const r = Number(parts[2]);
const p = Number(parts[3]);
if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) return false;
const salt = Buffer.from(parts[4], "base64url");
const expected = Buffer.from(parts[5], "base64url");
let derived: Buffer;
try {
derived = await scrypt(password.normalize("NFKC"), salt, expected.length, {
N,
r,
p,
maxmem: MAX_MEM,
});
} catch {
return false;
}
if (derived.length !== expected.length) return false;
return timingSafeEqual(derived, expected);
}
/**
* Verbrennt vergleichbar viel Zeit wie eine echte Pruefung. Wird aufgerufen,
* wenn es den Benutzernamen gar nicht gibt – sonst liesse sich an der
* Antwortzeit ablesen, welche Konten existieren.
*/
export async function fakeVerifyDelay(): Promise<void> {
await scrypt("nebula-dummy-password", "nebula-dummy-salt", KEY_LENGTH, {
...PARAMS,
maxmem: MAX_MEM,
});
}
const OBVIOUS = new Set([
"password", "passwort", "12345678", "123456789", "1234567890",
"qwertzuiop", "qwertyuiop", "administrator", "changeme", "letmein",
"willkommen", "nebula123", "geheim123",
]);
export { PASSWORD_MIN_LENGTH };
/** Gibt eine Fehlermeldung zurueck oder null, wenn das Passwort taugt. */
export function validatePasswordStrength(password: string): 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())) {
return "Dieses Passwort ist zu naheliegend.";
}
if (/^\d+$/.test(password)) {
return "Nur Ziffern reichen nicht – bitte Buchstaben oder Zeichen ergaenzen.";
}
return null;
}
+137
View File
@@ -0,0 +1,137 @@
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 { prisma } from "@/lib/db";
/**
* Serverseitige Sitzungen.
*
* Der Cookie enthaelt ein Zufallstoken, die Datenbank nur dessen SHA-256-Hash.
* Wer die Datenbank liest, kann damit keine Sitzung uebernehmen. Abmelden und
* Sperren wirken sofort, weil bei jeder Anfrage nachgeschlagen wird – anders
* als bei einem selbsttragenden Token.
*/
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("base64url");
}
export interface SessionUser {
id: string;
username: string;
displayName: string | null;
email: string | null;
role: User["role"];
hasPassword: boolean;
}
function toSessionUser(user: User): SessionUser {
return {
id: user.id,
username: user.username,
displayName: user.displayName,
email: user.email,
role: user.role,
hasPassword: Boolean(user.passwordHash),
};
}
export async function createSession(
userId: string,
meta: { userAgent?: string | null; ip?: string | null } = {},
): Promise<{ token: string; expiresAt: Date }> {
const token = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await prisma.session.create({
data: {
userId,
tokenHash: hashToken(token),
expiresAt,
userAgent: meta.userAgent?.slice(0, 300) ?? null,
ip: meta.ip?.slice(0, 60) ?? null,
},
});
// Gelegenheit nutzen und abgelaufene Sitzungen wegraeumen.
void prisma.session
.deleteMany({ where: { expiresAt: { lt: new Date() } } })
.catch(() => undefined);
return { token, expiresAt };
}
export function setSessionCookie(response: NextResponse, token: string, expiresAt: Date) {
response.cookies.set({
name: SESSION_COOKIE,
value: token,
httpOnly: true,
sameSite: "lax",
secure: COOKIES_SECURE,
path: "/",
expires: expiresAt,
});
}
export function clearSessionCookie(response: NextResponse) {
response.cookies.set({
name: SESSION_COOKIE,
value: "",
httpOnly: true,
sameSite: "lax",
secure: COOKIES_SECURE,
path: "/",
maxAge: 0,
});
}
/**
* Liest die Sitzung aus dem Cookie und prueft sie gegen die Datenbank.
* Gibt null zurueck, wenn kein oder ein ungueltiges Token vorliegt oder das
* Konto deaktiviert wurde.
*/
export async function getSessionUser(): Promise<SessionUser | null> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (!token) return null;
const session = await prisma.session.findUnique({
where: { tokenHash: hashToken(token) },
include: { user: true },
});
if (!session) return null;
if (session.expiresAt.getTime() <= Date.now()) {
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) {
void prisma.session
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
.catch(() => undefined);
}
return toSessionUser(session.user);
}
export async function destroyCurrentSession(): Promise<void> {
const store = await cookies();
const token = store.get(SESSION_COOKIE)?.value;
if (!token) return;
await prisma.session
.deleteMany({ where: { tokenHash: hashToken(token) } })
.catch(() => undefined);
}
/** Meldet ein Konto ueberall ab – nach Passwortwechsel oder Sperrung. */
export async function destroyAllSessions(userId: string): Promise<void> {
await prisma.session.deleteMany({ where: { userId } });
}
+43
View File
@@ -0,0 +1,43 @@
import type { AppSettings } from "@prisma/client";
import { APP_NAME } from "@/lib/config";
import { prisma } from "@/lib/db";
/**
* Einstellungen der Instanz. Es gibt genau eine Zeile mit der id "singleton";
* sie wird beim ersten Zugriff angelegt.
*/
export async function getAppSettings(): Promise<AppSettings> {
return prisma.appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", rpName: APP_NAME },
update: {},
});
}
export interface InstanceState {
settings: AppSettings;
userCount: number;
/** Kein Konto vorhanden – die Einrichtung steht noch aus. */
needsSetup: boolean;
/** Darf sich jemand selbst registrieren? */
registrationOpen: boolean;
multiUser: boolean;
}
export async function getInstanceState(): Promise<InstanceState> {
const [settings, userCount] = await Promise.all([
getAppSettings(),
prisma.user.count(),
]);
const multiUser = settings.mode === "MULTI_USER";
return {
settings,
userCount,
needsSetup: userCount === 0,
// Im Einzelbetrieb gibt es grundsaetzlich keine Selbstregistrierung.
registrationOpen: multiUser && settings.allowRegistration,
multiUser,
};
}
+70
View File
@@ -0,0 +1,70 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
/** Benutzerfelder, die an den Browser gehen duerfen. */
export const userSafeSelect = {
id: true,
username: true,
displayName: true,
email: true,
role: true,
isActive: true,
lastLoginAt: true,
lockedUntil: true,
createdAt: true,
_count: { select: { credentials: true, projects: true, sessions: true } },
} satisfies Prisma.UserSelect;
export type UserListEntry = Prisma.UserGetPayload<{ select: typeof userSafeSelect }>;
/** Sperre nach zu vielen Fehlversuchen. */
const LOCK_THRESHOLD = 8;
const LOCK_MINUTES = 15;
export async function noteFailedLogin(userId: string): Promise<void> {
const user = await prisma.user.update({
where: { id: userId },
data: { failedLoginCount: { increment: 1 } },
select: { failedLoginCount: true },
});
if (user.failedLoginCount >= LOCK_THRESHOLD) {
await prisma.user.update({
where: { id: userId },
data: {
lockedUntil: new Date(Date.now() + LOCK_MINUTES * 60 * 1000),
failedLoginCount: 0,
},
});
}
}
export async function noteSuccessfulLogin(userId: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: { lastLoginAt: new Date(), failedLoginCount: 0, lockedUntil: null },
});
}
export function lockRemainingSeconds(lockedUntil: Date | null): number {
if (!lockedUntil) return 0;
const remaining = lockedUntil.getTime() - Date.now();
return remaining > 0 ? Math.ceil(remaining / 1000) : 0;
}
/**
* Uebernimmt Projekte und Verbindungen ohne Besitzer.
*
* Greift genau einmal: beim Anlegen des ersten Kontos. So gehen Daten, die
* vor der Einfuehrung der Benutzerverwaltung entstanden sind, nicht verloren.
*/
export async function claimOrphanedData(userId: string): Promise<{
projects: number;
connections: number;
}> {
const [projects, connections] = await prisma.$transaction([
prisma.project.updateMany({ where: { ownerId: null }, data: { ownerId: userId } }),
prisma.gitConnection.updateMany({ where: { ownerId: null }, data: { ownerId: userId } }),
]);
return { projects: projects.count, connections: connections.count };
}
+258
View File
@@ -0,0 +1,258 @@
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from "@simplewebauthn/server";
import type { Credential, User } from "@prisma/client";
import { APP_NAME, WEBAUTHN_AVAILABLE, WEBAUTHN_ORIGIN, WEBAUTHN_RP_ID } from "@/lib/config";
import { prisma } from "@/lib/db";
/**
* Passkeys (WebAuthn).
*
* Die Challenge liegt in der Datenbank statt in einem Cookie: so laesst sie
* sich nach einmaligem Gebrauch sicher loeschen und kann nicht erneut
* eingespielt werden. Ausserdem funktioniert das ueber mehrere Prozesse.
*
* rpID und Origin kommen aus APP_URL. Passkeys sind an die rpID gebunden –
* wird APP_URL auf einen anderen Hostnamen geaendert, muessen die Passkeys
* neu angelegt werden.
*/
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
export class WebAuthnUnavailableError extends Error {
constructor() {
super(
"Passkeys benoetigen einen sicheren Kontext. Bitte APP_URL auf eine " +
"https-Adresse (oder http://localhost) setzen.",
);
this.name = "WebAuthnUnavailableError";
}
}
function assertAvailable() {
if (!WEBAUTHN_AVAILABLE) throw new WebAuthnUnavailableError();
}
async function storeChallenge(
challenge: string,
purpose: "registration" | "authentication",
userId: string | null,
) {
// Abgelaufene Challenges bei der Gelegenheit entfernen.
await prisma.webAuthnChallenge.deleteMany({ where: { expiresAt: { lt: new Date() } } });
await prisma.webAuthnChallenge.create({
data: {
challenge,
purpose,
userId,
expiresAt: new Date(Date.now() + CHALLENGE_TTL_MS),
},
});
}
/** Holt die Challenge und loescht sie sofort – jede gilt genau einmal. */
async function consumeChallenge(
challenge: string,
purpose: "registration" | "authentication",
): Promise<{ userId: string | null } | null> {
const stored = await prisma.webAuthnChallenge.findUnique({ where: { challenge } });
if (!stored) return null;
await prisma.webAuthnChallenge.delete({ where: { id: stored.id } }).catch(() => undefined);
if (stored.purpose !== purpose) return null;
if (stored.expiresAt.getTime() <= Date.now()) return null;
return { userId: stored.userId };
}
// ── Registrierung eines neuen Passkeys ───────────────────────
export async function buildRegistrationOptions(
user: Pick<User, "id" | "username" | "displayName">,
): Promise<PublicKeyCredentialCreationOptionsJSON> {
assertAvailable();
const existing = await prisma.credential.findMany({
where: { userId: user.id },
select: { credentialId: true, transports: true },
});
const options = await generateRegistrationOptions({
rpName: APP_NAME,
rpID: WEBAUTHN_RP_ID,
userID: new TextEncoder().encode(user.id),
userName: user.username,
userDisplayName: user.displayName ?? user.username,
attestationType: "none",
// Bereits registrierte Passkeys ausschliessen, damit derselbe
// Authenticator nicht doppelt hinterlegt wird.
excludeCredentials: existing.map((credential) => ({
id: credential.credentialId,
transports: credential.transports as AuthenticatorTransportFuture[],
})),
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});
await storeChallenge(options.challenge, "registration", user.id);
return options;
}
export async function confirmRegistration(
userId: string,
response: RegistrationResponseJSON,
name: string | null,
): Promise<Credential> {
assertAvailable();
const expectedChallenge = response.response.clientDataJSON
? decodeChallenge(response.response.clientDataJSON)
: null;
if (!expectedChallenge) {
throw new Error("Die Antwort des Authenticators enthaelt keine Challenge.");
}
const stored = await consumeChallenge(expectedChallenge, "registration");
if (!stored) {
throw new Error("Die Challenge ist abgelaufen oder wurde bereits verwendet.");
}
if (stored.userId !== userId) {
throw new Error("Die Challenge gehoert zu einem anderen Konto.");
}
const verification = await verifyRegistrationResponse({
response,
expectedChallenge,
expectedOrigin: WEBAUTHN_ORIGIN,
expectedRPID: WEBAUTHN_RP_ID,
requireUserVerification: false,
});
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Der Passkey konnte nicht bestaetigt werden.");
}
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
return prisma.credential.create({
data: {
userId,
credentialId: credential.id,
publicKey: Buffer.from(credential.publicKey),
counter: credential.counter,
transports: (credential.transports ?? []) as string[],
deviceType: credentialDeviceType,
backedUp: credentialBackedUp,
name: name?.trim() || null,
},
});
}
// ── Anmeldung per Passkey ────────────────────────────────────
/**
* Ohne allowCredentials: der Browser bietet alle passenden, auffindbaren
* Passkeys an. Der Benutzername muss also nicht eingegeben werden.
*/
export async function buildAuthenticationOptions(): Promise<PublicKeyCredentialRequestOptionsJSON> {
assertAvailable();
const options = await generateAuthenticationOptions({
rpID: WEBAUTHN_RP_ID,
userVerification: "preferred",
});
await storeChallenge(options.challenge, "authentication", null);
return options;
}
export interface PasskeyLoginResult {
userId: string;
credentialId: string;
}
export async function confirmAuthentication(
response: AuthenticationResponseJSON,
): Promise<PasskeyLoginResult> {
assertAvailable();
const expectedChallenge = decodeChallenge(response.response.clientDataJSON);
if (!expectedChallenge) {
throw new Error("Die Antwort des Authenticators enthaelt keine Challenge.");
}
const stored = await consumeChallenge(expectedChallenge, "authentication");
if (!stored) {
throw new Error("Die Challenge ist abgelaufen oder wurde bereits verwendet.");
}
const credential = await prisma.credential.findUnique({
where: { credentialId: response.id },
include: { user: true },
});
if (!credential) {
throw new Error("Dieser Passkey ist hier nicht hinterlegt.");
}
if (!credential.user.isActive) {
throw new Error("Das Konto ist deaktiviert.");
}
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge,
expectedOrigin: WEBAUTHN_ORIGIN,
expectedRPID: WEBAUTHN_RP_ID,
requireUserVerification: false,
credential: {
id: credential.credentialId,
publicKey: new Uint8Array(credential.publicKey),
counter: credential.counter,
transports: credential.transports as AuthenticatorTransportFuture[],
},
});
if (!verification.verified) {
throw new Error("Der Passkey konnte nicht bestaetigt werden.");
}
// Zaehler fortschreiben – ein Ruecksprung deutet auf einen Klon hin,
// SimpleWebAuthn wirft in dem Fall bereits oben.
await prisma.credential.update({
where: { id: credential.id },
data: {
counter: verification.authenticationInfo.newCounter,
lastUsedAt: new Date(),
},
});
return { userId: credential.userId, credentialId: credential.credentialId };
}
/**
* Liest die Challenge aus clientDataJSON. Die Bibliothek prueft sie zwar
* selbst, wir brauchen sie aber vorher, um den passenden Datenbankeintrag
* zu finden und zu entwerten.
*/
function decodeChallenge(clientDataJSON: string): string | null {
try {
const json = JSON.parse(Buffer.from(clientDataJSON, "base64url").toString("utf8")) as {
challenge?: string;
};
return typeof json.challenge === "string" ? json.challenge : null;
} catch {
return null;
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Laufzeitkonfiguration.
*
* Bewusst ohne NEXT_PUBLIC_-Variablen: die wuerden beim Build fest in das
* JavaScript-Bundle geschrieben. So laesst sich dasselbe Docker-Image ohne
* Neubau anders benennen und unter einer anderen URL betreiben. Werte, die
* der Browser braucht, reicht das Layout als Props weiter.
*/
function trimmed(value: string | undefined): string | null {
const result = value?.trim();
return result ? result : null;
}
/** Anzeigename der Instanz – Seitentitel, Kopfzeile, Passkey-Dialog. */
export const APP_NAME = trimmed(process.env.APP_NAME) ?? "Nebula";
/** Kleine Zeile unter dem Namen in der Kopfzeile. */
export const APP_TAGLINE = trimmed(process.env.APP_TAGLINE) ?? "Vibe Coding";
const RAW_APP_URL = trimmed(process.env.APP_URL) ?? "http://localhost:3000";
function parseAppUrl(raw: string): URL {
try {
const url = new URL(raw);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`APP_URL muss http oder https sein, ist aber "${url.protocol}"`);
}
return url;
} catch (error) {
throw new Error(
`APP_URL ist keine gueltige URL: "${raw}". Beispiel: https://nebula.example.com` +
` (${error instanceof Error ? error.message : String(error)})`,
);
}
}
const APP_URL_PARSED = parseAppUrl(RAW_APP_URL);
/** Vollstaendige Basis-URL ohne abschliessenden Slash. */
export const APP_URL = APP_URL_PARSED.origin;
/** Origin, den WebAuthn in der Assertion erwartet. */
export const WEBAUTHN_ORIGIN = APP_URL_PARSED.origin;
/**
* Relying-Party-ID fuer WebAuthn: der reine Hostname ohne Port.
* Passkeys sind an diesen Wert gebunden – aendert er sich, sind bestehende
* Passkeys nicht mehr verwendbar.
*/
export const WEBAUTHN_RP_ID = APP_URL_PARSED.hostname;
/**
* WebAuthn verlangt einen sicheren Kontext. Ausnahme: localhost gilt in
* Browsern als sicher, auch ohne TLS.
*/
export const WEBAUTHN_AVAILABLE =
APP_URL_PARSED.protocol === "https:" ||
APP_URL_PARSED.hostname === "localhost" ||
APP_URL_PARSED.hostname === "127.0.0.1";
/** Cookies nur ueber TLS ausliefern, wenn die App per https laeuft. */
export const COOKIES_SECURE = APP_URL_PARSED.protocol === "https:";
/** Lebensdauer einer Sitzung. */
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";
/** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */
export function describeConfig(): string {
return [
`name=${APP_NAME}`,
`url=${APP_URL}`,
`rpId=${WEBAUTHN_RP_ID}`,
`passkeys=${WEBAUTHN_AVAILABLE ? "moeglich" : "deaktiviert (kein sicherer Kontext)"}`,
`secureCookies=${COOKIES_SECURE}`,
].join(" ");
}
+148
View File
@@ -0,0 +1,148 @@
import { NextResponse, type NextRequest } from "next/server";
/**
* Erste Verteidigungslinie – laeuft vor jeder Anfrage.
*
* 1. CSRF: schreibende Anfragen muessen vom eigenen Origin kommen.
* 2. Sicherheitskopfzeilen inklusive Content-Security-Policy mit Nonce.
* 3. Grobe Zugangsschranke: ohne Sitzungscookie geht es zur Anmeldung.
*
* Wichtig: Punkt 3 ist nur Bequemlichkeit und Lastschutz, keine
* Sicherheitsgrenze. Die Middleware laeuft im Edge-Runtime und kann das
* Token nicht gegen die Datenbank pruefen. Verbindlich entschieden wird in
* requireUserPage bzw. requireUserApi – also serverseitig in jeder Seite und
* jedem Endpunkt.
*/
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
const SESSION_COOKIE = "nebula_session";
/** Ohne Anmeldung erreichbar. */
const PUBLIC_PATHS = ["/login", "/setup", "/register"];
const PUBLIC_API_PREFIXES = ["/api/auth/"];
/** Der Docker-Healthcheck hat kein Cookie. */
const PUBLIC_API_EXACT = ["/api/health"];
function isPublicPath(pathname: string): boolean {
if (PUBLIC_PATHS.includes(pathname)) return true;
if (PUBLIC_API_EXACT.includes(pathname)) return true;
return PUBLIC_API_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
/**
* Gleicher Ursprung?
*
* Moderne Browser senden Sec-Fetch-Site bei jeder Anfrage und Origin bei
* jeder schreibenden. Fehlen beide, stammt die Anfrage nicht aus einem
* Browser (curl, Skript, Cronjob) und ist damit kein CSRF-Vektor – solche
* Aufrufe bleiben erlaubt, damit die API skriptbar bleibt.
*/
function isSameOrigin(request: NextRequest): boolean {
const site = request.headers.get("sec-fetch-site");
if (site) return site === "same-origin" || site === "none";
const origin = request.headers.get("origin");
if (origin) {
try {
return new URL(origin).host === request.headers.get("host");
} catch {
return false;
}
}
return true;
}
function buildCsp(nonce: string, isDev: boolean): string {
const scriptSrc = isDev
? `'self' 'nonce-${nonce}' 'unsafe-eval' 'strict-dynamic'`
: `'self' 'nonce-${nonce}' 'strict-dynamic'`;
return [
"default-src 'self'",
`script-src ${scriptSrc}`,
// Inline-Styles sind noetig: React setzt style-Attribute fuer die
// Akzentfarben, Next liefert kritisches CSS inline aus.
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self' data:",
// Der Browser spricht nur mit der eigenen Instanz; die Git-APIs ruft
// ausschliesslich der Server auf.
"connect-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
"manifest-src 'self'",
].join("; ");
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isDev = process.env.NODE_ENV === "development";
// ── 1. CSRF ────────────────────────────────────────────────
if (!SAFE_METHODS.has(request.method) && !isSameOrigin(request)) {
return NextResponse.json(
{ error: "Anfrage von fremdem Ursprung abgelehnt." },
{ status: 403 },
);
}
// ── 2. Zugangsschranke ─────────────────────────────────────
const hasSession = Boolean(request.cookies.get(SESSION_COOKIE)?.value);
if (!hasSession && !isPublicPath(pathname)) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
const target = request.nextUrl.clone();
target.pathname = "/login";
target.search = "";
// Nur den Pfad merken, keine absolute URL – sonst waere das ein
// offener Redirect.
if (pathname !== "/") target.searchParams.set("weiter", pathname);
return NextResponse.redirect(target);
}
// ── 3. Kopfzeilen ──────────────────────────────────────────
const nonce = crypto.randomUUID().replace(/-/g, "");
const csp = buildCsp(nonce, isDev);
// Next liest die Nonce aus der CSP der Anfrage und setzt sie auf seine
// eigenen Skript-Tags.
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set("Content-Security-Policy", csp);
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("Referrer-Policy", "same-origin");
response.headers.set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), interest-cohort=()",
);
response.headers.set("Cross-Origin-Opener-Policy", "same-origin");
response.headers.set("Cross-Origin-Resource-Policy", "same-origin");
response.headers.set("X-DNS-Prefetch-Control", "off");
return response;
}
export const config = {
matcher: [
/*
* Alles ausser den statischen Auslieferungen von Next. Die brauchen
* weder CSRF-Pruefung noch Nonce und wuerden nur Rechenzeit kosten.
*/
{
source: "/((?!_next/static|_next/image|favicon.ico|icon.svg).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};