From 13bc5aa0b8bf9d873f41b333d3bca945ba475510 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 25 Jul 2026 21:30:12 +0200 Subject: [PATCH] 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) --- package-lock.json | 263 ++++++++++++++++++++++++++++++++++++++ package.json | 2 + src/lib/auth/constants.ts | 10 ++ src/lib/auth/guard.ts | 62 +++++++++ src/lib/auth/password.ts | 110 ++++++++++++++++ src/lib/auth/session.ts | 137 ++++++++++++++++++++ src/lib/auth/settings.ts | 43 +++++++ src/lib/auth/users.ts | 70 ++++++++++ src/lib/auth/webauthn.ts | 258 +++++++++++++++++++++++++++++++++++++ src/lib/config.ts | 80 ++++++++++++ src/middleware.ts | 148 +++++++++++++++++++++ 11 files changed, 1183 insertions(+) create mode 100644 src/lib/auth/constants.ts create mode 100644 src/lib/auth/guard.ts create mode 100644 src/lib/auth/password.ts create mode 100644 src/lib/auth/session.ts create mode 100644 src/lib/auth/settings.ts create mode 100644 src/lib/auth/users.ts create mode 100644 src/lib/auth/webauthn.ts create mode 100644 src/lib/config.ts create mode 100644 src/middleware.ts diff --git a/package-lock.json b/package-lock.json index b214f2e..13f4d0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 59aa578..57fde92 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/auth/constants.ts b/src/lib/auth/constants.ts new file mode 100644 index 0000000..d419ccc --- /dev/null +++ b/src/lib/auth/constants.ts @@ -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; diff --git a/src/lib/auth/guard.ts b/src/lib/auth/guard.ts new file mode 100644 index 0000000..2989019 --- /dev/null +++ b/src/lib/auth/guard.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts new file mode 100644 index 0000000..b620891 --- /dev/null +++ b/src/lib/auth/password.ts @@ -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$$$

$$ (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; + +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 { + 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 { + 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 { + 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; +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts new file mode 100644 index 0000000..36e96e6 --- /dev/null +++ b/src/lib/auth/session.ts @@ -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 { + 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 { + 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 { + await prisma.session.deleteMany({ where: { userId } }); +} diff --git a/src/lib/auth/settings.ts b/src/lib/auth/settings.ts new file mode 100644 index 0000000..6587450 --- /dev/null +++ b/src/lib/auth/settings.ts @@ -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 { + 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 { + 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, + }; +} diff --git a/src/lib/auth/users.ts b/src/lib/auth/users.ts new file mode 100644 index 0000000..fa63cca --- /dev/null +++ b/src/lib/auth/users.ts @@ -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 { + 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 { + 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 }; +} diff --git a/src/lib/auth/webauthn.ts b/src/lib/auth/webauthn.ts new file mode 100644 index 0000000..01bf691 --- /dev/null +++ b/src/lib/auth/webauthn.ts @@ -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, +): Promise { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..5aea2ca --- /dev/null +++ b/src/lib/config.ts @@ -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(" "); +} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..e4005e6 --- /dev/null +++ b/src/middleware.ts @@ -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" }, + ], + }, + ], +};