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,
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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