diff --git a/README.md b/README.md index 286deaf..b827664 100644 --- a/README.md +++ b/README.md @@ -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. +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: ```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. +## 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 Alle persistenten Daten liegen im Docker-Volume `moped-data` unter `/app/data`: diff --git a/middleware.ts b/middleware.ts index 05599c9..79ab172 100644 --- a/middleware.ts +++ b/middleware.ts @@ -4,7 +4,7 @@ import { jwtVerify } from "jose"; const SESSION_COOKIE = "moped_session"; -const PUBLIC_PATHS = ["/login"]; +const PUBLIC_PATHS = ["/login", "/kiosk"]; async function hasValidSession(request: NextRequest): Promise { const token = request.cookies.get(SESSION_COOKIE)?.value; diff --git a/package-lock.json b/package-lock.json index 377ee71..6845a80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "familien-motorrad-tracker", "version": "0.1.0", "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", "bcryptjs": "^3.0.3", "jose": "^6.1.0", "next": "16.2.12", @@ -455,6 +457,12 @@ "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": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1085,6 +1093,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/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -1308,6 +1322,174 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1315,6 +1497,31 @@ "dev": true, "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": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -2551,6 +2758,20 @@ "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": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -5651,6 +5872,24 @@ "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": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5700,6 +5939,12 @@ "dev": true, "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": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6443,6 +6688,24 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "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": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index bdc15b0..3530941 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "lint": "eslint" }, "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", "bcryptjs": "^3.0.3", "jose": "^6.1.0", "next": "16.2.12", diff --git a/src/app/(app)/account/page.tsx b/src/app/(app)/account/page.tsx index a51c315..4a5d659 100644 --- a/src/app/(app)/account/page.tsx +++ b/src/app/(app)/account/page.tsx @@ -1,7 +1,9 @@ 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 { requireSessionUser } from "@/lib/auth"; +import { db, initDatabase } from "@/lib/db"; import { queryMessage } from "@/lib/format"; export const runtime = "nodejs"; @@ -12,7 +14,18 @@ export default async function AccountPage({ searchParams: Promise>; }) { const user = await requireSessionUser(); + initDatabase(); 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 ( <> @@ -64,6 +77,52 @@ export default async function AccountPage({ + + {user.role === "admin" && ( +
+
+
+

Admin-Schutz

+

YubiKeys

+
+
+
+

+ Du kannst bis zu zwei YubiKeys hinterlegen. Sobald mindestens ein Schlüssel aktiv ist, wird er beim Admin-Login verpflichtend. +

+ {securityKeys.length === 0 ? ( +

Noch kein YubiKey registriert.

+ ) : ( + + + + + + + + + + + {securityKeys.map((key) => ( + + + + + + + ))} + +
SchlüsselRegistriertZuletzt genutzt
{key.label ?? `YubiKey #${key.id}`}{key.created_at.replace("T", " ").slice(0, 16)}{key.last_used_at ? key.last_used_at.replace("T", " ").slice(0, 16) : "—"} +
+ + +
+
+ )} + = 2} /> +
+
+ )} ); } diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index a2c6065..2ebfff8 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -19,6 +19,8 @@ type BikeRow = { year: number | null; plate: string | null; current_km: number; + created_by: number; + owner_name: string; tuev_due_date: string | null; tuev_due_km: number | null; inspection_due_date: string | null; @@ -47,7 +49,7 @@ export default async function Dashboard({ const visible = motorcycleAccessFilter(user); const motorcycles = db .prepare( - `SELECT m.*, + `SELECT m.*, owner.name AS owner_name, (SELECT due_date FROM maintenance_events e 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) @@ -65,6 +67,7 @@ export default async function Dashboard({ 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 FROM motorcycles m + JOIN users owner ON owner.id = m.created_by WHERE ${visible.clause} ORDER BY m.nickname COLLATE NOCASE`, ) @@ -140,6 +143,9 @@ export default async function Dashboard({

{bike.brand} · {bike.model}

{bike.nickname}

{bike.plate ?? "Ohne Kennzeichen"}{bike.year ? ` · ${bike.year}` : ""}

+

+ Besitzer: {bike.created_by === user.id ? "Du" : bike.owner_name} +

diff --git a/src/app/actions/auth.ts b/src/app/actions/auth.ts index b1e91dd..cb845ea 100644 --- a/src/app/actions/auth.ts +++ b/src/app/actions/auth.ts @@ -4,12 +4,15 @@ import { compareSync, hashSync } from "bcryptjs"; import { redirect } from "next/navigation"; import { authenticateUser, + clearPendingAdminMfaCookie, clearSessionCookie, + setPendingAdminMfaCookie, requireSessionUser, setSessionCookie, + requireAdminUser, } from "@/lib/auth"; 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) { return `${path}?${kind}=${encodeURIComponent(message)}`; @@ -19,19 +22,31 @@ export async function loginAction(formData: FormData) { let username: string; let password: string; try { - username = text(formData, "username", { required: true, max: 100 }); - password = String(formData.get("password") ?? ""); + username = usernameValue(formData); + password = passwordValue(formData, "password"); } catch (error) { redirect(messageUrl("/login", "error", error instanceof Error ? error.message : "Ungültige Eingabe.")); } const user = authenticateUser(username, password); 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); redirect("/"); } export async function logoutAction() { + await clearPendingAdminMfaCookie(); await clearSessionCookie(); 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); 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.")); + } +} diff --git a/src/app/actions/motorcycles.ts b/src/app/actions/motorcycles.ts index 00246c7..90d4a4c 100644 --- a/src/app/actions/motorcycles.ts +++ b/src/app/actions/motorcycles.ts @@ -15,6 +15,7 @@ import { integer, maintenanceType, text, + usernameValue, } from "@/lib/validation"; const MAX_FILE_SIZE = 8 * 1024 * 1024; @@ -490,7 +491,7 @@ export async function shareMotorcycleAction(formData: FormData) { let motorcycleId = 0; try { motorcycleId = idValue(formData, "motorcycle_id"); - const username = text(formData, "username", { required: true, max: 100 }); + const username = usernameValue(formData, "username"); if (!canManageShares(user, motorcycleId)) { throw new Error("Du darfst die Freigaben dieses Mopeds nicht verwalten."); } diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts index cf35acf..518725b 100644 --- a/src/app/actions/users.ts +++ b/src/app/actions/users.ts @@ -5,7 +5,7 @@ import { hashSync } from "bcryptjs"; import { redirect, unstable_rethrow } from "next/navigation"; import { requireAdminUser } from "@/lib/auth"; 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"; function go(kind: "error" | "success", message: string): never { @@ -23,7 +23,7 @@ export async function createUserAction(formData: FormData) { await requireAdminUser(); initDatabase(); try { - const name = text(formData, "name", { required: true, max: 100 }); + const name = usernameValue(formData, "name"); const existing = db .prepare("SELECT 1 FROM users WHERE name = ? COLLATE NOCASE AND active = 1") .get(name); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 204830a..335f225 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { redirect } from "next/navigation"; import { loginAction } from "@/app/actions/auth"; import { Flash } from "@/components/flash"; @@ -30,6 +31,7 @@ export default async function LoginPage({

Willkommen zurück

Anmelden

Zugang nur für eingerichtete Familienmitglieder.

+

Kiosk-Modus: Wartungsdaten per Kennzeichen

diff --git a/src/lib/auth.ts b/src/lib/auth.ts index f0f9b78..4d6c25a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -11,8 +11,16 @@ type SessionPayload = { exp: number; }; +type AdminMfaPayload = { + uid: number; + purpose: "admin-mfa"; + exp: number; +}; + export const SESSION_COOKIE = "moped_session"; +export const ADMIN_MFA_COOKIE = "moped_admin_mfa"; const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30; +const ADMIN_MFA_TTL_SECONDS = 60 * 5; function getSecretKey() { return new TextEncoder().encode(getAuthSecret()); @@ -43,6 +51,41 @@ export async function clearSessionCookie() { (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 { const token = (await cookies()).get(SESSION_COOKIE)?.value; if (!token) return null; diff --git a/src/lib/db.ts b/src/lib/db.ts index cd95960..03ecb5b 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -105,6 +105,28 @@ function ensureSchema() { created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) 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 ON maintenance_events(motorcycle_id, event_date DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_maintenance_due @@ -113,6 +135,10 @@ function ensureSchema() { ON trips(motorcycle_id, trip_date DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_motorcycle_shares_user 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 }[]; @@ -196,6 +222,8 @@ function ensureSchema() { } 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() { diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 125b30c..1c625aa 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -8,6 +8,15 @@ export function text( const value = String(formData.get(key) ?? "").trim(); 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.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; }