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:
Generated
+263
@@ -10,6 +10,8 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.8.2",
|
"@prisma/client": "^6.8.2",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"next": "^15.3.3",
|
"next": "^15.3.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
@@ -495,6 +497,12 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@img/colour": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
@@ -1059,6 +1067,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.5.21",
|
"version": "15.5.21",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.21.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.21.tgz",
|
||||||
@@ -1205,6 +1219,174 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "6.19.3",
|
"version": "6.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz",
|
||||||
@@ -1290,6 +1472,31 @@
|
|||||||
"@prisma/debug": "6.19.3"
|
"@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": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -1619,6 +1826,20 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/c12": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||||
@@ -2461,6 +2682,24 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/rc9": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||||
@@ -2507,6 +2746,12 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -2659,6 +2904,24 @@
|
|||||||
"fsevents": "~2.3.3"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.8.2",
|
"@prisma/client": "^6.8.2",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"next": "^15.3.3",
|
"next": "^15.3.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 } });
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(" ");
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user