Add WebAuthn support for admin login and kiosk mode
This commit is contained in:
@@ -31,6 +31,11 @@ docker compose up -d --build
|
|||||||
|
|
||||||
Die Anwendung ist standardmäßig über Port 3000 erreichbar. `PORT` in `.env` ändert nur den veröffentlichten Host-Port. Für eine Domain sollte nginx, Traefik oder ein anderer Reverse Proxy TLS terminieren und auf diesen Port weiterleiten.
|
Die Anwendung ist standardmäßig über Port 3000 erreichbar. `PORT` in `.env` ändert nur den veröffentlichten Host-Port. Für eine Domain sollte nginx, Traefik oder ein anderer Reverse Proxy TLS terminieren und auf diesen Port weiterleiten.
|
||||||
|
|
||||||
|
Für YubiKey/WebAuthn im Docker-Betrieb mit Domain sollten zusätzlich gesetzt werden:
|
||||||
|
|
||||||
|
- `WEBAUTHN_RP_ID` (z. B. `garage.example.com`)
|
||||||
|
- `WEBAUTHN_ORIGIN` (z. B. `https://garage.example.com`)
|
||||||
|
|
||||||
Sichere Schlüssel lassen sich beispielsweise so erzeugen:
|
Sichere Schlüssel lassen sich beispielsweise so erzeugen:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -44,6 +49,18 @@ Den Hex-Wert für `AUTH_SECRET` und den Base64-Wert für `NEXT_SERVER_ACTIONS_EN
|
|||||||
|
|
||||||
Es gibt Administratoren und Mitglieder. Jedes Motorrad gehört genau einem Benutzer (Besitzer). Administratoren sehen alle Motorräder, Mitglieder sehen ihre eigenen plus explizit freigegebene Motorräder. Besitzer und Administratoren können Motorräder mit anderen aktiven Benutzern per Benutzername teilen. Konten werden durch Administratoren verwaltet. Motorräder dürfen vom Besitzer oder von Administratoren gelöscht werden. Gelöschte Benutzer können sich nicht mehr anmelden und sind nicht mehr als Fahrer auswählbar; vorhandene Wartungen und Fahrten bleiben mit dem ursprünglichen Namen zur Nachvollziehbarkeit erhalten. Der letzte aktive Administrator kann weder gelöscht noch herabgestuft werden.
|
Es gibt Administratoren und Mitglieder. Jedes Motorrad gehört genau einem Benutzer (Besitzer). Administratoren sehen alle Motorräder, Mitglieder sehen ihre eigenen plus explizit freigegebene Motorräder. Besitzer und Administratoren können Motorräder mit anderen aktiven Benutzern per Benutzername teilen. Konten werden durch Administratoren verwaltet. Motorräder dürfen vom Besitzer oder von Administratoren gelöscht werden. Gelöschte Benutzer können sich nicht mehr anmelden und sind nicht mehr als Fahrer auswählbar; vorhandene Wartungen und Fahrten bleiben mit dem ursprünglichen Namen zur Nachvollziehbarkeit erhalten. Der letzte aktive Administrator kann weder gelöscht noch herabgestuft werden.
|
||||||
|
|
||||||
|
## Admin-Login mit YubiKey
|
||||||
|
|
||||||
|
Administratoren können unter **Konto → YubiKeys** bis zu zwei Sicherheitsschlüssel (z. B. YubiKey) registrieren. Sobald mindestens ein Schlüssel hinterlegt ist, wird beim Admin-Login nach Benutzername/Passwort zusätzlich eine WebAuthn-Bestätigung verlangt.
|
||||||
|
|
||||||
|
## Kiosk-Modus
|
||||||
|
|
||||||
|
Unter `/kiosk` gibt es einen öffentlichen Kiosk-Modus ohne Login. Dort reicht das Kennzeichen, um die hinterlegten Wartungs-Sollwerte (Reifendruck, Kettenspannung, Öl-Prüfanleitung) eines Motorrads anzuzeigen.
|
||||||
|
|
||||||
|
## Eingabesicherheit
|
||||||
|
|
||||||
|
Alle Datenbankzugriffe nutzen parametrisierte SQL-Statements (`?`-Platzhalter), und Eingaben werden serverseitig validiert. Damit sind SQL-Injection-Angriffe über Formularfelder abgefangen.
|
||||||
|
|
||||||
## Backups
|
## Backups
|
||||||
|
|
||||||
Alle persistenten Daten liegen im Docker-Volume `moped-data` unter `/app/data`:
|
Alle persistenten Daten liegen im Docker-Volume `moped-data` unter `/app/data`:
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import { jwtVerify } from "jose";
|
|||||||
|
|
||||||
const SESSION_COOKIE = "moped_session";
|
const SESSION_COOKIE = "moped_session";
|
||||||
|
|
||||||
const PUBLIC_PATHS = ["/login"];
|
const PUBLIC_PATHS = ["/login", "/kiosk"];
|
||||||
|
|
||||||
async function hasValidSession(request: NextRequest): Promise<boolean> {
|
async function hasValidSession(request: NextRequest): Promise<boolean> {
|
||||||
const token = request.cookies.get(SESSION_COOKIE)?.value;
|
const token = request.cookies.get(SESSION_COOKIE)?.value;
|
||||||
|
|||||||
Generated
+263
@@ -8,6 +8,8 @@
|
|||||||
"name": "familien-motorrad-tracker",
|
"name": "familien-motorrad-tracker",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
"next": "16.2.12",
|
"next": "16.2.12",
|
||||||
@@ -455,6 +457,12 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@@ -1085,6 +1093,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/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||||
@@ -1308,6 +1322,174 @@
|
|||||||
"node": ">=12.4.0"
|
"node": ">=12.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/@rtsao/scc": {
|
"node_modules/@rtsao/scc": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||||
@@ -1315,6 +1497,31 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -2551,6 +2758,20 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/ast-types-flow": {
|
"node_modules/ast-types-flow": {
|
||||||
"version": "0.0.8",
|
"version": "0.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||||
@@ -5651,6 +5872,24 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -5700,6 +5939,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"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/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -6443,6 +6688,24 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"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/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
"next": "16.2.12",
|
"next": "16.2.12",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { changePasswordAction } from "@/app/actions/auth";
|
import { changePasswordAction, removeAdminSecurityKeyAction } from "@/app/actions/auth";
|
||||||
|
import { AdminSecurityKeys } from "@/components/admin-security-keys";
|
||||||
import { Flash } from "@/components/flash";
|
import { Flash } from "@/components/flash";
|
||||||
import { requireSessionUser } from "@/lib/auth";
|
import { requireSessionUser } from "@/lib/auth";
|
||||||
|
import { db, initDatabase } from "@/lib/db";
|
||||||
import { queryMessage } from "@/lib/format";
|
import { queryMessage } from "@/lib/format";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
@@ -12,7 +14,18 @@ export default async function AccountPage({
|
|||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}) {
|
}) {
|
||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
|
initDatabase();
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
|
const securityKeys = user.role === "admin"
|
||||||
|
? (db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, label, created_at, last_used_at
|
||||||
|
FROM webauthn_credentials
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY created_at ASC`,
|
||||||
|
)
|
||||||
|
.all(user.id) as { id: number; label: string | null; created_at: string; last_used_at: string | null }[])
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -64,6 +77,52 @@ export default async function AccountPage({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{user.role === "admin" && (
|
||||||
|
<section className="section-block">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Admin-Schutz</p>
|
||||||
|
<h2>YubiKeys</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="panel stack-form">
|
||||||
|
<p className="muted">
|
||||||
|
Du kannst bis zu zwei YubiKeys hinterlegen. Sobald mindestens ein Schlüssel aktiv ist, wird er beim Admin-Login verpflichtend.
|
||||||
|
</p>
|
||||||
|
{securityKeys.length === 0 ? (
|
||||||
|
<p className="muted">Noch kein YubiKey registriert.</p>
|
||||||
|
) : (
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Schlüssel</th>
|
||||||
|
<th>Registriert</th>
|
||||||
|
<th>Zuletzt genutzt</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{securityKeys.map((key) => (
|
||||||
|
<tr key={key.id}>
|
||||||
|
<td>{key.label ?? `YubiKey #${key.id}`}</td>
|
||||||
|
<td>{key.created_at.replace("T", " ").slice(0, 16)}</td>
|
||||||
|
<td>{key.last_used_at ? key.last_used_at.replace("T", " ").slice(0, 16) : "—"}</td>
|
||||||
|
<td>
|
||||||
|
<form action={removeAdminSecurityKeyAction} className="inline-form">
|
||||||
|
<input type="hidden" name="id" value={key.id} />
|
||||||
|
<button className="link-button danger" type="submit">Entfernen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
<AdminSecurityKeys disabled={securityKeys.length >= 2} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ type BikeRow = {
|
|||||||
year: number | null;
|
year: number | null;
|
||||||
plate: string | null;
|
plate: string | null;
|
||||||
current_km: number;
|
current_km: number;
|
||||||
|
created_by: number;
|
||||||
|
owner_name: string;
|
||||||
tuev_due_date: string | null;
|
tuev_due_date: string | null;
|
||||||
tuev_due_km: number | null;
|
tuev_due_km: number | null;
|
||||||
inspection_due_date: string | null;
|
inspection_due_date: string | null;
|
||||||
@@ -47,7 +49,7 @@ export default async function Dashboard({
|
|||||||
const visible = motorcycleAccessFilter(user);
|
const visible = motorcycleAccessFilter(user);
|
||||||
const motorcycles = db
|
const motorcycles = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT m.*,
|
`SELECT m.*, owner.name AS owner_name,
|
||||||
(SELECT due_date FROM maintenance_events e
|
(SELECT due_date FROM maintenance_events e
|
||||||
WHERE e.motorcycle_id = m.id AND e.type = 'tuev_hu'
|
WHERE e.motorcycle_id = m.id AND e.type = 'tuev_hu'
|
||||||
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
|
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
|
||||||
@@ -65,6 +67,7 @@ export default async function Dashboard({
|
|||||||
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
|
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
|
||||||
ORDER BY e.event_date DESC, e.id DESC LIMIT 1) AS inspection_due_km
|
ORDER BY e.event_date DESC, e.id DESC LIMIT 1) AS inspection_due_km
|
||||||
FROM motorcycles m
|
FROM motorcycles m
|
||||||
|
JOIN users owner ON owner.id = m.created_by
|
||||||
WHERE ${visible.clause}
|
WHERE ${visible.clause}
|
||||||
ORDER BY m.nickname COLLATE NOCASE`,
|
ORDER BY m.nickname COLLATE NOCASE`,
|
||||||
)
|
)
|
||||||
@@ -140,6 +143,9 @@ export default async function Dashboard({
|
|||||||
<p className="eyebrow">{bike.brand} · {bike.model}</p>
|
<p className="eyebrow">{bike.brand} · {bike.model}</p>
|
||||||
<h2>{bike.nickname}</h2>
|
<h2>{bike.nickname}</h2>
|
||||||
<p className="muted">{bike.plate ?? "Ohne Kennzeichen"}{bike.year ? ` · ${bike.year}` : ""}</p>
|
<p className="muted">{bike.plate ?? "Ohne Kennzeichen"}{bike.year ? ` · ${bike.year}` : ""}</p>
|
||||||
|
<p className="muted">
|
||||||
|
Besitzer: {bike.created_by === user.id ? "Du" : bike.owner_name}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<InspectionSticker dueDate={bike.tuev_due_date} status={tuevStatus} />
|
<InspectionSticker dueDate={bike.tuev_due_date} status={tuevStatus} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+30
-3
@@ -4,12 +4,15 @@ import { compareSync, hashSync } from "bcryptjs";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
authenticateUser,
|
authenticateUser,
|
||||||
|
clearPendingAdminMfaCookie,
|
||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
|
setPendingAdminMfaCookie,
|
||||||
requireSessionUser,
|
requireSessionUser,
|
||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
|
requireAdminUser,
|
||||||
} from "@/lib/auth";
|
} from "@/lib/auth";
|
||||||
import { db, initDatabase } from "@/lib/db";
|
import { db, initDatabase } from "@/lib/db";
|
||||||
import { passwordValue, text } from "@/lib/validation";
|
import { idValue, passwordValue, usernameValue } from "@/lib/validation";
|
||||||
|
|
||||||
function messageUrl(path: string, kind: "error" | "success", message: string) {
|
function messageUrl(path: string, kind: "error" | "success", message: string) {
|
||||||
return `${path}?${kind}=${encodeURIComponent(message)}`;
|
return `${path}?${kind}=${encodeURIComponent(message)}`;
|
||||||
@@ -19,19 +22,31 @@ export async function loginAction(formData: FormData) {
|
|||||||
let username: string;
|
let username: string;
|
||||||
let password: string;
|
let password: string;
|
||||||
try {
|
try {
|
||||||
username = text(formData, "username", { required: true, max: 100 });
|
username = usernameValue(formData);
|
||||||
password = String(formData.get("password") ?? "");
|
password = passwordValue(formData, "password");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
redirect(messageUrl("/login", "error", error instanceof Error ? error.message : "Ungültige Eingabe."));
|
redirect(messageUrl("/login", "error", error instanceof Error ? error.message : "Ungültige Eingabe."));
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = authenticateUser(username, password);
|
const user = authenticateUser(username, password);
|
||||||
if (!user) redirect(messageUrl("/login", "error", "Benutzername oder Passwort ist falsch."));
|
if (!user) redirect(messageUrl("/login", "error", "Benutzername oder Passwort ist falsch."));
|
||||||
|
initDatabase();
|
||||||
|
if (user.role === "admin") {
|
||||||
|
const keyCount = db
|
||||||
|
.prepare("SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?")
|
||||||
|
.get(user.id) as { count: number };
|
||||||
|
if (keyCount.count > 0) {
|
||||||
|
await setPendingAdminMfaCookie(user.id);
|
||||||
|
redirect("/login/yubikey");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await clearPendingAdminMfaCookie();
|
||||||
await setSessionCookie(user);
|
await setSessionCookie(user);
|
||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logoutAction() {
|
export async function logoutAction() {
|
||||||
|
await clearPendingAdminMfaCookie();
|
||||||
await clearSessionCookie();
|
await clearSessionCookie();
|
||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
@@ -65,3 +80,15 @@ export async function changePasswordAction(formData: FormData) {
|
|||||||
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hashSync(newPassword, 12), user.id);
|
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hashSync(newPassword, 12), user.id);
|
||||||
redirect(messageUrl("/account", "success", "Passwort wurde geändert."));
|
redirect(messageUrl("/account", "success", "Passwort wurde geändert."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function removeAdminSecurityKeyAction(formData: FormData) {
|
||||||
|
const user = await requireAdminUser();
|
||||||
|
initDatabase();
|
||||||
|
try {
|
||||||
|
const id = idValue(formData);
|
||||||
|
db.prepare("DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?").run(id, user.id);
|
||||||
|
redirect(messageUrl("/account", "success", "Sicherheitsschlüssel entfernt."));
|
||||||
|
} catch (error) {
|
||||||
|
redirect(messageUrl("/account", "error", error instanceof Error ? error.message : "Ungültige Eingabe."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
integer,
|
integer,
|
||||||
maintenanceType,
|
maintenanceType,
|
||||||
text,
|
text,
|
||||||
|
usernameValue,
|
||||||
} from "@/lib/validation";
|
} from "@/lib/validation";
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 8 * 1024 * 1024;
|
const MAX_FILE_SIZE = 8 * 1024 * 1024;
|
||||||
@@ -490,7 +491,7 @@ export async function shareMotorcycleAction(formData: FormData) {
|
|||||||
let motorcycleId = 0;
|
let motorcycleId = 0;
|
||||||
try {
|
try {
|
||||||
motorcycleId = idValue(formData, "motorcycle_id");
|
motorcycleId = idValue(formData, "motorcycle_id");
|
||||||
const username = text(formData, "username", { required: true, max: 100 });
|
const username = usernameValue(formData, "username");
|
||||||
if (!canManageShares(user, motorcycleId)) {
|
if (!canManageShares(user, motorcycleId)) {
|
||||||
throw new Error("Du darfst die Freigaben dieses Mopeds nicht verwalten.");
|
throw new Error("Du darfst die Freigaben dieses Mopeds nicht verwalten.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { hashSync } from "bcryptjs";
|
|||||||
import { redirect, unstable_rethrow } from "next/navigation";
|
import { redirect, unstable_rethrow } from "next/navigation";
|
||||||
import { requireAdminUser } from "@/lib/auth";
|
import { requireAdminUser } from "@/lib/auth";
|
||||||
import { db, initDatabase, transaction } from "@/lib/db";
|
import { db, initDatabase, transaction } from "@/lib/db";
|
||||||
import { idValue, passwordValue, roleValue, text } from "@/lib/validation";
|
import { idValue, passwordValue, roleValue, usernameValue } from "@/lib/validation";
|
||||||
import type { UserRole } from "@/lib/types";
|
import type { UserRole } from "@/lib/types";
|
||||||
|
|
||||||
function go(kind: "error" | "success", message: string): never {
|
function go(kind: "error" | "success", message: string): never {
|
||||||
@@ -23,7 +23,7 @@ export async function createUserAction(formData: FormData) {
|
|||||||
await requireAdminUser();
|
await requireAdminUser();
|
||||||
initDatabase();
|
initDatabase();
|
||||||
try {
|
try {
|
||||||
const name = text(formData, "name", { required: true, max: 100 });
|
const name = usernameValue(formData, "name");
|
||||||
const existing = db
|
const existing = db
|
||||||
.prepare("SELECT 1 FROM users WHERE name = ? COLLATE NOCASE AND active = 1")
|
.prepare("SELECT 1 FROM users WHERE name = ? COLLATE NOCASE AND active = 1")
|
||||||
.get(name);
|
.get(name);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { loginAction } from "@/app/actions/auth";
|
import { loginAction } from "@/app/actions/auth";
|
||||||
import { Flash } from "@/components/flash";
|
import { Flash } from "@/components/flash";
|
||||||
@@ -30,6 +31,7 @@ export default async function LoginPage({
|
|||||||
<p className="eyebrow">Willkommen zurück</p>
|
<p className="eyebrow">Willkommen zurück</p>
|
||||||
<h1>Anmelden</h1>
|
<h1>Anmelden</h1>
|
||||||
<p className="muted">Zugang nur für eingerichtete Familienmitglieder.</p>
|
<p className="muted">Zugang nur für eingerichtete Familienmitglieder.</p>
|
||||||
|
<p className="muted"><Link href="/kiosk">Kiosk-Modus: Wartungsdaten per Kennzeichen</Link></p>
|
||||||
</div>
|
</div>
|
||||||
<Flash error={queryMessage(params, "error")} />
|
<Flash error={queryMessage(params, "error")} />
|
||||||
<form action={loginAction} className="stack-form">
|
<form action={loginAction} className="stack-form">
|
||||||
|
|||||||
@@ -11,8 +11,16 @@ type SessionPayload = {
|
|||||||
exp: number;
|
exp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AdminMfaPayload = {
|
||||||
|
uid: number;
|
||||||
|
purpose: "admin-mfa";
|
||||||
|
exp: number;
|
||||||
|
};
|
||||||
|
|
||||||
export const SESSION_COOKIE = "moped_session";
|
export const SESSION_COOKIE = "moped_session";
|
||||||
|
export const ADMIN_MFA_COOKIE = "moped_admin_mfa";
|
||||||
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30;
|
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30;
|
||||||
|
const ADMIN_MFA_TTL_SECONDS = 60 * 5;
|
||||||
|
|
||||||
function getSecretKey() {
|
function getSecretKey() {
|
||||||
return new TextEncoder().encode(getAuthSecret());
|
return new TextEncoder().encode(getAuthSecret());
|
||||||
@@ -43,6 +51,41 @@ export async function clearSessionCookie() {
|
|||||||
(await cookies()).delete(SESSION_COOKIE);
|
(await cookies()).delete(SESSION_COOKIE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setPendingAdminMfaCookie(userId: number) {
|
||||||
|
const token = await new SignJWT({ uid: userId, purpose: "admin-mfa" })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(`${ADMIN_MFA_TTL_SECONDS}s`)
|
||||||
|
.sign(getSecretKey());
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set(ADMIN_MFA_COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === "production",
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: ADMIN_MFA_TTL_SECONDS,
|
||||||
|
path: "/",
|
||||||
|
priority: "high",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPendingAdminMfaUserId() {
|
||||||
|
const token = (await cookies()).get(ADMIN_MFA_COOKIE)?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
try {
|
||||||
|
const result = await jwtVerify(token, getSecretKey());
|
||||||
|
const payload = result.payload as unknown as AdminMfaPayload;
|
||||||
|
return Number.isSafeInteger(payload.uid) && payload.uid > 0 && payload.purpose === "admin-mfa"
|
||||||
|
? payload.uid
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPendingAdminMfaCookie() {
|
||||||
|
(await cookies()).delete(ADMIN_MFA_COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSessionUser(): Promise<SessionUser | null> {
|
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||||
const token = (await cookies()).get(SESSION_COOKIE)?.value;
|
const token = (await cookies()).get(SESSION_COOKIE)?.value;
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
|
|||||||
@@ -105,6 +105,28 @@ function ensureSchema() {
|
|||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
) STRICT;
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webauthn_credentials (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
credential_id TEXT NOT NULL UNIQUE,
|
||||||
|
public_key TEXT NOT NULL,
|
||||||
|
counter INTEGER NOT NULL DEFAULT 0 CHECK (counter >= 0),
|
||||||
|
transports TEXT,
|
||||||
|
label TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_used_at TEXT
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS webauthn_challenges (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
challenge TEXT NOT NULL,
|
||||||
|
purpose TEXT NOT NULL CHECK (purpose IN ('registration', 'authentication')),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_maintenance_motorcycle
|
CREATE INDEX IF NOT EXISTS idx_maintenance_motorcycle
|
||||||
ON maintenance_events(motorcycle_id, event_date DESC, id DESC);
|
ON maintenance_events(motorcycle_id, event_date DESC, id DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_maintenance_due
|
CREATE INDEX IF NOT EXISTS idx_maintenance_due
|
||||||
@@ -113,6 +135,10 @@ function ensureSchema() {
|
|||||||
ON trips(motorcycle_id, trip_date DESC, id DESC);
|
ON trips(motorcycle_id, trip_date DESC, id DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_motorcycle_shares_user
|
CREATE INDEX IF NOT EXISTS idx_motorcycle_shares_user
|
||||||
ON motorcycle_shares(user_id, motorcycle_id);
|
ON motorcycle_shares(user_id, motorcycle_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
|
||||||
|
ON webauthn_credentials(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_lookup
|
||||||
|
ON webauthn_challenges(user_id, purpose, expires_at, used_at);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const userColumns = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
|
const userColumns = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
|
||||||
@@ -196,6 +222,8 @@ function ensureSchema() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.exec("CREATE INDEX IF NOT EXISTS idx_users_active ON users(active, name COLLATE NOCASE)");
|
db.exec("CREATE INDEX IF NOT EXISTS idx_users_active ON users(active, name COLLATE NOCASE)");
|
||||||
|
db.prepare("DELETE FROM webauthn_challenges WHERE used_at IS NOT NULL OR expires_at < ?")
|
||||||
|
.run(new Date().toISOString());
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureInitialAdmin() {
|
function ensureInitialAdmin() {
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ export function text(
|
|||||||
const value = String(formData.get(key) ?? "").trim();
|
const value = String(formData.get(key) ?? "").trim();
|
||||||
if (options.required && !value) throw new Error(`${key} ist erforderlich.`);
|
if (options.required && !value) throw new Error(`${key} ist erforderlich.`);
|
||||||
if (value.length > (options.max ?? 5000)) throw new Error(`${key} ist zu lang.`);
|
if (value.length > (options.max ?? 5000)) throw new Error(`${key} ist zu lang.`);
|
||||||
|
if (value.includes("\u0000")) throw new Error(`${key} enthält ungültige Zeichen.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usernameValue(formData: FormData, key = "username") {
|
||||||
|
const value = text(formData, key, { required: true, max: 100 });
|
||||||
|
if (!/^[\p{L}\p{N}][\p{L}\p{N} ._-]{0,99}$/u.test(value)) {
|
||||||
|
throw new Error("Benutzername enthält ungültige Zeichen.");
|
||||||
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user