From d3718da1f808f9dfcf2d6a5669516aa24c5e75de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Reinhard=20X=2E=20F=C3=BCrst?= Date: Sun, 9 Aug 2026 16:35:35 +0200 Subject: [PATCH] V 2.2.1 Anmeldung per Passkey (WebAuthn) Passkey-Login analog zu werte-next: auf der Login-Seite "Mit Passkey anmelden", Verwaltung der eigenen Passkeys im neuen Tab Einstellungen. Das Passwort bleibt als Alternative bestehen. - lib/webauthn.ts, lib/passkeys.ts sowie API-Routen unter /api/passkey - proxy.ts: /api/passkey/authenticate ohne Session erreichbar - TabLayout: dritter Tab Einstellungen (Index-Tabs statt Route wie werte) - RP-Konfiguration ueber AUSGABEN_RP_*, da sich die .env auf dem Server mit anderen Apps teilt, die RP_ID/RP_ORIGIN bereits belegen - Eigene Tabelle ausgaben_passkeys aus demselben Grund: werte-next nutzt in der gemeinsamen Datenbank RXF bereits die Tabelle passkeys - eslint.config.mjs auf die Flat-Config von eslint-config-next umgestellt Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 16 ++ AUTH_README.md | 33 +++- DEPLOYMENT.md | 27 +++ README.md | 21 ++ app/api/passkey/authenticate/route.ts | 28 +++ app/api/passkey/register/route.ts | 36 ++++ app/api/passkey/route.ts | 40 ++++ app/login/page.tsx | 59 ++++++ app/page.tsx | 17 +- components/Passkeys.tsx | 151 +++++++++++++++ components/TabLayout.tsx | 4 + create_table.sql | 17 ++ docker-compose.local.yml | 6 + docker-compose.prod.yml | 7 + eslint.config.mjs | 28 +-- lib/auth.ts | 5 + lib/passkeys.ts | 146 ++++++++++++++ lib/webauthn.ts | 176 +++++++++++++++++ package-lock.json | 267 +++++++++++++++++++++++++- package.json | 4 +- proxy.ts | 3 +- 21 files changed, 1070 insertions(+), 21 deletions(-) create mode 100644 app/api/passkey/authenticate/route.ts create mode 100644 app/api/passkey/register/route.ts create mode 100644 app/api/passkey/route.ts create mode 100644 components/Passkeys.tsx create mode 100644 lib/passkeys.ts create mode 100644 lib/webauthn.ts diff --git a/.env.example b/.env.example index e33cfca..2fb092f 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,19 @@ AUTH_USERS=admin:$2b$10$DKLO7uQPmdAw9Z64NChro.8mOsnqZQaRZjctWDojIkK926ROBVyJW,us # Secret key for JWT session encryption (change in production!) AUTH_SECRET=your-super-secret-key-change-this-in-production + +# Passkeys (WebAuthn) — Relying Party +# RP_ID = nur der Host (ohne Protokoll/Port), RP_ORIGIN = volle URL +# +# Bei "npm run dev" liest Next.js RP_ID/RP_ORIGIN/RP_NAME direkt aus dieser Datei. +# Ohne Angabe gilt localhost / http://localhost:3005 — für lokale Entwicklung +# können die Zeilen also entfallen. +# RP_ID=localhost +# RP_ORIGIN=http://localhost:3005 + +# Bei Docker Compose werden die AUSGABEN_RP_*-Variablen ausgewertet und im +# Container auf RP_* abgebildet. Eigene Namen deshalb, weil die .env auf dem +# Server mit anderen Apps geteilt wird, die RP_ID/RP_ORIGIN schon belegen. +AUSGABEN_RP_ID=ausgaben.fuerst-stuttgart.de +AUSGABEN_RP_ORIGIN=https://ausgaben.fuerst-stuttgart.de +AUSGABEN_RP_NAME=Ausgaben-Log diff --git a/AUTH_README.md b/AUTH_README.md index d3e2a07..c22af65 100644 --- a/AUTH_README.md +++ b/AUTH_README.md @@ -8,11 +8,19 @@ Diese Authentifizierungslösung kann einfach in andere Next.js Projekte übernom - `/lib/auth.ts` - Authentifizierungslogik (Benutzerverwaltung über .env) - `/lib/session.ts` - JWT-basiertes Session-Management - `/middleware.ts` - Route-Schutz Middleware +- `/lib/webauthn.ts` - Passkey-Logik (Registrierung & Anmeldung, WebAuthn) +- `/lib/passkeys.ts` - Datenbankzugriff auf die Tabelle `ausgaben_passkeys` ### 2. UI Komponenten (wiederverwendbar) -- `/app/login/page.tsx` - Login-Seite +- `/app/login/page.tsx` - Login-Seite (Passwort + „Mit Passkey anmelden") - `/app/login/actions.ts` - Server Actions für Login/Logout - `/components/LogoutButton.tsx` - Logout-Button Komponente +- `/components/Passkeys.tsx` - Verwaltung der eigenen Passkeys (Tab *Einstellungen*) + +### 3. API-Routen (Passkeys) +- `/app/api/passkey/route.ts` - eigene Passkeys auflisten (GET) / löschen (DELETE) +- `/app/api/passkey/register/route.ts` - Passkey registrieren (Session erforderlich) +- `/app/api/passkey/authenticate/route.ts` - Anmeldung per Passkey (öffentlich) ## Installation in neuen Projekten @@ -20,6 +28,9 @@ Diese Authentifizierungslösung kann einfach in andere Next.js Projekte übernom ```bash npm install jose bcryptjs npm install --save-dev @types/bcryptjs + +# Für Passkeys (WebAuthn): +npm install @simplewebauthn/server @simplewebauthn/browser ``` ### 2. Dateien kopieren @@ -55,6 +66,11 @@ AUTH_USERS=admin:$2b$10$DKLO7uQPmdAw9Z64NChro...,user1:$2b$10$K613Z70Hodr6xyEh10 # Secret Key für JWT (unbedingt ändern in Production!) AUTH_SECRET=your-super-secret-key-change-this + +# Passkeys (WebAuthn) — RP_ID = nur der Host, RP_ORIGIN = volle URL +RP_ID=ausgaben.fuerst-stuttgart.de +RP_ORIGIN=https://ausgaben.fuerst-stuttgart.de +RP_NAME=Ausgaben-Log ``` ### 5. Logout-Button einbinden (optional) @@ -94,8 +110,21 @@ const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 Tage ### Geschützte Routen anpassen Editiere in `middleware.ts` die `publicPaths`: ```ts -const publicPaths = ['/login', '/public-page']; +const publicPaths = ['/login', '/api/passkey/authenticate', '/public-page']; ``` +Die Passkey-Anmeldung muss ohne Session erreichbar sein. + +### Passkeys verwalten +- **Registrieren**: angemeldet im Tab *Einstellungen* → „Passkey hinzufügen". +- **Anmelden**: auf der Login-Seite „Mit Passkey anmelden". +- **Speicherung**: Tabelle `ausgaben_passkeys` (siehe `create_table.sql`), wird beim + ersten Zugriff automatisch angelegt, sofern der DB-Benutzer CREATE-Rechte hat. + **Wichtig bei Übernahme in ein anderes Projekt**: Teilen sich mehrere Apps eine + Datenbank, braucht jede App einen eigenen Tabellennamen (`TABLE` in + `lib/passkeys.ts`) — sonst sehen und löschen sie gegenseitig ihre Passkeys. +- `RP_ID` muss exakt dem Hostnamen entsprechen (ohne Protokoll/Port). Ändert sich + `RP_ID`, werden bereits registrierte Passkeys ungültig. +- WebAuthn funktioniert nur über **HTTPS** oder auf `localhost`. ## Sicherheitshinweise diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 6c5f48c..afb4841 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -66,6 +66,33 @@ Falls die Tabelle `Ausgaben` noch nicht existiert: mysql -u root -p RXF < create_table.sql ``` +Das Script legt auch die Tabelle `ausgaben_passkeys` für die Passkey-Anmeldung an. Die +Anwendung erstellt diese Tabelle beim ersten Zugriff selbst — hat der DB-Benutzer +keine CREATE-Rechte, das SQL aus `create_table.sql` manuell ausführen. + +### Passkeys (WebAuthn) konfigurieren + +Im Container erwartet die Anwendung `RP_ID`, `RP_ORIGIN` und `RP_NAME`. In der +Compose-Datei werden diese aus den **app-eigenen** Variablen `AUSGABEN_RP_*` +befüllt — mit den Produktionswerten als Default: + +```yaml +- RP_ID=${AUSGABEN_RP_ID:-ausgaben.fuerst-stuttgart.de} +- RP_ORIGIN=${AUSGABEN_RP_ORIGIN:-https://ausgaben.fuerst-stuttgart.de} +- RP_NAME=${AUSGABEN_RP_NAME:-Ausgaben-Log} +``` + +**Warum eigene Namen**: Auf dem Server (`/opt/stacks/myapp/`) laufen mehrere Apps im +selben Stack und teilen sich eine `.env`. Dort sind `RP_ID`/`RP_ORIGIN` bereits für +werte-next gesetzt. Würde diese App dieselben Namen lesen, bekäme sie werte's Host — +und die Passkey-Anmeldung schlüge fehl. Die Defaults oben greifen, solange in der +geteilten `.env` keine `AUSGABEN_RP_*` stehen; ein Eintrag dort überschreibt sie. + +**Wichtig**: `RP_ID` muss exakt dem Hostnamen entsprechen (ohne `https://` und ohne +Port), `RP_ORIGIN` der vollen URL. Passen die Werte nicht zur aufgerufenen Adresse, +schlägt die Passkey-Anmeldung fehl. Bereits registrierte Passkeys werden bei einer +Änderung von `RP_ID` ungültig. WebAuthn setzt HTTPS voraus (via Traefik). + ### Datenbank-Verbindung Die Anwendung verwendet die gleiche MySQL-Datenbank wie die alte Ausgaben-Anwendung: diff --git a/README.md b/README.md index c7b51ac..22061ea 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,27 @@ Dies ist die modernisierte Version des alten PHP/jQuery-basierten Ausgaben-Progr - Bearbeiten-Funktion: Klick auf Eintrag lädt ihn ins Formular - Filterung nach aktivem TYP (Haushalt/Privat) +- **Anmeldung per Passkey** (WebAuthn) alternativ zu Benutzername/Passwort + +## Anmeldung & Passkeys + +Benutzer und bcrypt-Passworthashes stehen in `AUTH_USERS` +(Format `name:hash,name2:hash2`), die Session ist ein JWT-Cookie (`AUTH_SECRET`). + +Zusätzlich kann sich jeder Benutzer per **Passkey** anmelden (Fingerabdruck, +Gesichtserkennung oder Geräte-PIN). Das Passwort bleibt als Alternative bestehen. + +- **Registrieren**: nach der Anmeldung im Tab *Einstellungen* → „Passkey hinzufügen". + Der Passkey wird dem angemeldeten Benutzer zugeordnet; mehrere Geräte sind möglich. +- **Anmelden**: auf der Login-Seite „Mit Passkey anmelden". +- **Speicherung**: Tabelle `ausgaben_passkeys` (siehe `create_table.sql`); sie wird beim + ersten Zugriff automatisch angelegt, falls der DB-Benutzer CREATE-Rechte hat. Der + app-spezifische Name ist nötig, weil sich mehrere Apps auf dem Server die Datenbank + `RXF` teilen und werte-next dort die Tabelle `passkeys` belegt. +- **Konfiguration**: `RP_ID` (nur der Host), `RP_ORIGIN` (volle URL), `RP_NAME`. + Lokal sind `localhost` / `http://localhost:3005` voreingestellt. +- **Wichtig**: WebAuthn funktioniert nur über **HTTPS** oder auf `localhost`. + ## Technologie-Stack - **Frontend**: Next.js 16, React 19, TypeScript diff --git a/app/api/passkey/authenticate/route.ts b/app/api/passkey/authenticate/route.ts new file mode 100644 index 0000000..2d61442 --- /dev/null +++ b/app/api/passkey/authenticate/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { buildAuthenticationOptions, finishAuthentication } from '@/lib/webauthn'; +import { createSession } from '@/lib/session'; + +// Öffentlich (kein Login): Optionen für die Anmeldung per Passkey. +export async function GET() { + try { + return NextResponse.json(await buildAuthenticationOptions()); + } catch (error) { + console.error('GET /api/passkey/authenticate:', error); + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }); + } +} + +// Öffentlich: Verifiziert die Anmelde-Antwort und erstellt bei Erfolg die Session. +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const result = await finishAuthentication(body?.response); + if (!result.ok) return NextResponse.json({ error: result.error }, { status: 401 }); + + await createSession(result.username); + return NextResponse.json({ ok: true }); + } catch (error) { + console.error('POST /api/passkey/authenticate:', error); + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }); + } +} diff --git a/app/api/passkey/register/route.ts b/app/api/passkey/register/route.ts new file mode 100644 index 0000000..af684c7 --- /dev/null +++ b/app/api/passkey/register/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSession } from '@/lib/session'; +import { buildRegistrationOptions, finishRegistration } from '@/lib/webauthn'; + +// Optionen für die Registrierung eines neuen Passkeys (Session erforderlich). +export async function GET() { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); + + try { + return NextResponse.json(await buildRegistrationOptions(session.username)); + } catch (error) { + console.error('GET /api/passkey/register:', error); + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }); + } +} + +// Verifiziert die Registrierungs-Antwort und speichert den Passkey. +export async function POST(request: NextRequest) { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); + + try { + const body = await request.json(); + const result = await finishRegistration( + session.username, + body?.response, + String(body?.label ?? '') + ); + if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 }); + return NextResponse.json({ ok: true }); + } catch (error) { + console.error('POST /api/passkey/register:', error); + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }); + } +} diff --git a/app/api/passkey/route.ts b/app/api/passkey/route.ts new file mode 100644 index 0000000..86291ec --- /dev/null +++ b/app/api/passkey/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getSession } from '@/lib/session'; +import { listPasskeys, deletePasskey } from '@/lib/passkeys'; + +// Liste der eigenen Passkeys (ohne öffentlichen Schlüssel). +export async function GET() { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); + + try { + const passkeys = (await listPasskeys(session.username)).map((pk) => ({ + credentialId: pk.credentialId, + label: pk.label, + createdAt: pk.createdAt, + lastUsedAt: pk.lastUsedAt, + })); + return NextResponse.json({ passkeys }); + } catch (error) { + console.error('GET /api/passkey:', error); + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }); + } +} + +// Löscht einen eigenen Passkey anhand seiner credentialId (?id=...). +export async function DELETE(request: NextRequest) { + const session = await getSession(); + if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 }); + + const id = request.nextUrl.searchParams.get('id'); + if (!id) return NextResponse.json({ error: 'Keine ID angegeben.' }, { status: 400 }); + + try { + const removed = await deletePasskey(id, session.username); + if (!removed) return NextResponse.json({ error: 'Passkey nicht gefunden.' }, { status: 404 }); + return NextResponse.json({ ok: true }); + } catch (error) { + console.error('DELETE /api/passkey:', error); + return NextResponse.json({ error: 'Serverfehler' }, { status: 500 }); + } +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 2a2a41a..9933a06 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,12 +1,47 @@ 'use client'; import { useActionState, useState } from 'react'; +import { startAuthentication } from '@simplewebauthn/browser'; import { login } from './actions'; import packageJson from '@/package.json'; export default function LoginPage() { const [state, loginAction, isPending] = useActionState(login, undefined); const [showPassword, setShowPassword] = useState(false); + const [passkeyBusy, setPasskeyBusy] = useState(false); + const [passkeyError, setPasskeyError] = useState(null); + + async function handlePasskeyLogin() { + setPasskeyError(null); + setPasskeyBusy(true); + try { + const optRes = await fetch('/api/passkey/authenticate'); + if (!optRes.ok) throw new Error('Optionen konnten nicht geladen werden.'); + const optionsJSON = await optRes.json(); + + const response = await startAuthentication({ optionsJSON }); + + const verifyRes = await fetch('/api/passkey/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ response }), + }); + if (!verifyRes.ok) { + const data = await verifyRes.json().catch(() => null); + throw new Error(data?.error ?? 'Anmeldung mit Passkey fehlgeschlagen.'); + } + window.location.href = '/'; + } catch (err) { + // Abbruch durch Nutzer (NotAllowedError) nicht als Fehler anzeigen. + if (err instanceof Error && err.name === 'NotAllowedError') { + setPasskeyError(null); + } else { + setPasskeyError(err instanceof Error ? err.message : 'Anmeldung mit Passkey fehlgeschlagen.'); + } + } finally { + setPasskeyBusy(false); + } + } const version = packageJson.version; const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE || new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); @@ -95,6 +130,30 @@ export default function LoginPage() { {isPending ? 'Anmeldung läuft...' : 'Anmelden'} + +
+
+ oder +
+
+ + + + {passkeyError && ( +
+ {passkeyError} +
+ )}
diff --git a/app/page.tsx b/app/page.tsx index 2f89259..1582d64 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,7 +4,8 @@ import { useState, useEffect } from 'react'; import AusgabenForm from '@/components/AusgabenForm'; import AusgabenList from '@/components/AusgabenList'; import MonatsStatistik from '@/components/MonatsStatistik'; -import TabLayout from '@/components/TabLayout'; +import TabLayout, { SETTINGS_TAB } from '@/components/TabLayout'; +import Passkeys from '@/components/Passkeys'; import { AusgabenEntry } from '@/types/ausgaben'; const MAX_ENTRIES = 15; @@ -17,8 +18,9 @@ export default function Home() { const [statsRefreshKey, setStatsRefreshKey] = useState(0); useEffect(() => { - fetchRecentEntries(); setSelectedEntry(null); // Clear selected entry when switching tabs + if (activeTab === SETTINGS_TAB) return; // Einstellungen laden keine Einträge + fetchRecentEntries(); }, [activeTab]); const fetchRecentEntries = async () => { @@ -60,6 +62,17 @@ export default function Home() { window.scrollTo({ top: 0, behavior: 'smooth' }); }; + if (activeTab === SETTINGS_TAB) { + return ( + +
+

Einstellungen

+ +
+
+ ); + } + return (
diff --git a/components/Passkeys.tsx b/components/Passkeys.tsx new file mode 100644 index 0000000..d6eb7d6 --- /dev/null +++ b/components/Passkeys.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { startRegistration } from '@simplewebauthn/browser'; + +interface Passkey { + credentialId: string; + label: string; + createdAt: string | null; + lastUsedAt: string | null; +} + +function formatDate(iso: string | null): string { + if (!iso) return '—'; + const d = new Date(iso); + if (isNaN(d.getTime())) return '—'; + return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); +} + +export default function Passkeys() { + const [passkeys, setPasskeys] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [label, setLabel] = useState(''); + + function load() { + return fetch('/api/passkey') + .then((r) => (r.ok ? r.json() : Promise.reject(new Error()))) + .then((data) => setPasskeys(data.passkeys ?? [])) + .catch(() => setError('Passkeys konnten nicht geladen werden.')) + .finally(() => setLoading(false)); + } + + useEffect(() => { + load(); + }, []); + + async function handleRegister() { + setError(null); + setBusy(true); + try { + const optRes = await fetch('/api/passkey/register'); + if (!optRes.ok) throw new Error('Optionen konnten nicht geladen werden.'); + const optionsJSON = await optRes.json(); + + const response = await startRegistration({ optionsJSON }); + + const verifyRes = await fetch('/api/passkey/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ response, label }), + }); + if (!verifyRes.ok) { + const data = await verifyRes.json().catch(() => null); + throw new Error(data?.error ?? 'Passkey konnte nicht registriert werden.'); + } + setLabel(''); + await load(); + } catch (err) { + if (err instanceof Error && err.name === 'NotAllowedError') { + setError(null); // Abbruch durch Nutzer + } else { + setError(err instanceof Error ? err.message : 'Passkey konnte nicht registriert werden.'); + } + } finally { + setBusy(false); + } + } + + async function handleDelete(credentialId: string) { + if (!confirm('Diesen Passkey wirklich entfernen?')) return; + setError(null); + try { + const res = await fetch(`/api/passkey?id=${encodeURIComponent(credentialId)}`, { + method: 'DELETE', + }); + if (!res.ok) throw new Error(); + await load(); + } catch { + setError('Passkey konnte nicht entfernt werden.'); + } + } + + return ( +
+

Passkeys

+

+ Melde dich künftig per Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Das Passwort + bleibt als Alternative bestehen. +

+ + {loading ? ( +

Lädt…

+ ) : passkeys.length === 0 ? ( +

Noch keine Passkeys registriert.

+ ) : ( +
    + {passkeys.map((pk) => ( +
  • +
    +
    {pk.label}
    +
    + Erstellt {formatDate(pk.createdAt)} · Zuletzt {formatDate(pk.lastUsedAt)} +
    +
    + +
  • + ))} +
+ )} + +
+
+ + setLabel(e.target.value)} + placeholder="z. B. iPhone" + maxLength={80} + className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 text-sm focus:border-blue-500 focus:outline-none" + /> +
+ +
+ + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/components/TabLayout.tsx b/components/TabLayout.tsx index fb36480..fd3ac2e 100644 --- a/components/TabLayout.tsx +++ b/components/TabLayout.tsx @@ -9,6 +9,9 @@ interface Tab { index: number; } +/** Tab-Index der Einstellungen (0/1 sind die Ausgaben-Typen Haushalt/Privat). */ +export const SETTINGS_TAB = 2; + interface TabLayoutProps { children: ReactNode; activeTab: number; @@ -18,6 +21,7 @@ interface TabLayoutProps { const TABS: Tab[] = [ { label: 'Haushalt', index: 0 }, { label: 'Privat', index: 1 }, + { label: 'Einstellungen', index: SETTINGS_TAB }, ]; export default function TabLayout({ children, activeTab, onTabChange }: TabLayoutProps) { diff --git a/create_table.sql b/create_table.sql index 8824eda..c580302 100644 --- a/create_table.sql +++ b/create_table.sql @@ -22,3 +22,20 @@ ALTER TABLE `Ausgaben` ADD INDEX IF NOT EXISTS `idx_typ` (`TYP`); -- Remove WochTag column if it exists (no longer stored in DB, calculated from Datum) -- ALTER TABLE `Ausgaben` DROP COLUMN IF EXISTS `WochTag`; + +-- WebAuthn-Passkeys je Benutzer (Benutzer stammen aus AUTH_USERS). +-- Die Anwendung legt diese Tabelle beim ersten Zugriff selbst an (lib/passkeys.ts), +-- sofern der DB-Benutzer CREATE-Rechte hat. +-- Der Name ist bewusst app-spezifisch: Auf dem Server teilen sich mehrere Apps +-- die Datenbank RXF, werte-next belegt dort bereits die Tabelle `passkeys`. +CREATE TABLE IF NOT EXISTS `ausgaben_passkeys` ( + `credential_id` VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY, + `username` VARCHAR(64) NOT NULL, + `public_key` VARBINARY(1024) NOT NULL, + `counter` INT UNSIGNED NOT NULL DEFAULT 0, + `transports` VARCHAR(255) NOT NULL DEFAULT '[]', + `label` VARCHAR(80) NOT NULL DEFAULT '', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `last_used_at` DATETIME NULL, + INDEX `idx_ausgaben_passkeys_username` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 8da2708..cb99d85 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -17,3 +17,9 @@ services: - DB_NAME=${DB_NAME} - AUTH_USERS=${AUTH_USERS} - AUTH_SECRET=${AUTH_SECRET} + # Passkeys (WebAuthn) — WebAuthn braucht HTTPS oder localhost. + # App-eigene Namen (AUSGABEN_RP_*) wie in docker-compose.prod.yml, damit + # eine geteilte .env mit anderen Apps nicht kollidiert. + - RP_ID=${AUSGABEN_RP_ID:-localhost} + - RP_ORIGIN=${AUSGABEN_RP_ORIGIN:-http://localhost:3005} + - RP_NAME=${AUSGABEN_RP_NAME:-Ausgaben-Log} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4e3ffa4..a4dd15f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -14,6 +14,13 @@ services: - DB_NAME=${DB_NAME} - AUTH_USERS=${AUTH_USERS} - AUTH_SECRET=${AUTH_SECRET} + # Passkeys (WebAuthn): Host bzw. volle URL der Anwendung. + # Achtung: Die .env auf dem Server wird von mehreren Apps im selben Stack + # geteilt und belegt RP_ID/RP_ORIGIN bereits für werte-next. Deshalb hier + # die app-eigenen Namen AUSGABEN_RP_* verwenden. + - RP_ID=${AUSGABEN_RP_ID:-ausgaben.fuerst-stuttgart.de} + - RP_ORIGIN=${AUSGABEN_RP_ORIGIN:-https://ausgaben.fuerst-stuttgart.de} + - RP_NAME=${AUSGABEN_RP_NAME:-Ausgaben-Log} labels: - traefik.enable=true - traefik.http.routers.ausgaben.entrypoints=http diff --git a/eslint.config.mjs b/eslint.config.mjs index c85fb67..05e726d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,16 +1,18 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); - -const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), -]; +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); export default eslintConfig; diff --git a/lib/auth.ts b/lib/auth.ts index b494be3..94abb10 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -29,6 +29,11 @@ export async function verifyCredentials(username: string, password: string): Pro return bcrypt.compare(password, user.password); } +/** Prüft, ob der Benutzername (noch) in AUTH_USERS hinterlegt ist. */ +export function isKnownUser(username: string): boolean { + return getUsers().some(u => u.username === username); +} + export function isAuthEnabled(): boolean { return !!process.env.AUTH_USERS; } diff --git a/lib/passkeys.ts b/lib/passkeys.ts new file mode 100644 index 0000000..4439477 --- /dev/null +++ b/lib/passkeys.ts @@ -0,0 +1,146 @@ +import { getDbPool } from './db'; +import type { RowDataPacket, ResultSetHeader } from 'mysql2/promise'; + +// Eigene Tabelle je Anwendung: Auf dem Server teilen sich mehrere Apps die +// Datenbank RXF. werte-next verwendet `passkeys` — würde diese App denselben +// Namen nutzen, sähe sie fremde Passkeys, könnte sie löschen und über +// excludeCredentials die eigene Registrierung blockieren. +const TABLE = 'ausgaben_passkeys'; + +/** Ein gespeicherter Passkey (WebAuthn-Credential), einem Benutzer zugeordnet. */ +export interface PasskeyRecord { + credentialId: string; // base64url + username: string; + publicKey: Uint8Array; + counter: number; + transports: string[]; + label: string; + createdAt: Date; + lastUsedAt: Date | null; +} + +interface PasskeyRow extends RowDataPacket { + credential_id: string; + username: string; + public_key: Buffer; + counter: number; + transports: string; + label: string; + created_at: Date; + last_used_at: Date | null; +} + +let schemaReady: Promise | null = null; + +/** + * Legt die Passkey-Tabelle beim ersten Zugriff an (einmal pro Prozess). + * Das Schema steht zusätzlich in create_table.sql. + */ +function ensureSchema(): Promise { + if (!schemaReady) { + schemaReady = getDbPool() + .query( + `CREATE TABLE IF NOT EXISTS ${TABLE} ( + credential_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY, + username VARCHAR(64) NOT NULL, + public_key VARBINARY(1024) NOT NULL, + counter INT UNSIGNED NOT NULL DEFAULT 0, + transports VARCHAR(255) NOT NULL DEFAULT '[]', + label VARCHAR(80) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME NULL, + INDEX idx_ausgaben_passkeys_username (username) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci` + ) + .then(() => undefined) + .catch((error) => { + schemaReady = null; // nächster Aufruf darf es erneut versuchen + throw error; + }); + } + return schemaReady; +} + +function fromRow(row: PasskeyRow): PasskeyRecord { + let transports: string[] = []; + try { + const parsed = JSON.parse(row.transports); + if (Array.isArray(parsed)) transports = parsed; + } catch { + /* fehlerhafte transports ignorieren */ + } + return { + credentialId: row.credential_id, + username: row.username, + // Uint8Array.from kopiert in einen frischen ArrayBuffer (passt zum von + // @simplewebauthn erwarteten Uint8Array). + publicKey: Uint8Array.from(row.public_key), + counter: row.counter, + transports, + label: row.label, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + }; +} + +/** Alle Passkeys eines Benutzers, neueste zuerst. */ +export async function listPasskeys(username: string): Promise { + await ensureSchema(); + const [rows] = await getDbPool().execute( + `SELECT * FROM ${TABLE} WHERE username = ? ORDER BY created_at DESC`, + [username] + ); + return rows.map(fromRow); +} + +/** Passkey anhand der Credential-ID — benutzerübergreifend, für die Anmeldung. */ +export async function getPasskey(credentialId: string): Promise { + await ensureSchema(); + const [rows] = await getDbPool().execute( + `SELECT * FROM ${TABLE} WHERE credential_id = ?`, + [credentialId] + ); + return rows.length > 0 ? fromRow(rows[0]) : undefined; +} + +export async function insertPasskey(record: { + credentialId: string; + username: string; + publicKey: Uint8Array; + counter: number; + transports: string[]; + label: string; +}): Promise { + await ensureSchema(); + await getDbPool().execute( + `INSERT INTO ${TABLE} (credential_id, username, public_key, counter, transports, label) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + record.credentialId, + record.username, + Buffer.from(record.publicKey), + record.counter, + JSON.stringify(record.transports ?? []), + record.label, + ] + ); +} + +/** Aktualisiert Counter und last_used_at nach erfolgreicher Anmeldung (Replay-Schutz). */ +export async function updatePasskeyUsage(credentialId: string, counter: number): Promise { + await ensureSchema(); + await getDbPool().execute( + `UPDATE ${TABLE} SET counter = ?, last_used_at = NOW() WHERE credential_id = ?`, + [counter, credentialId] + ); +} + +/** Löscht einen Passkey — nur wenn er dem angegebenen Benutzer gehört. */ +export async function deletePasskey(credentialId: string, username: string): Promise { + await ensureSchema(); + const [result] = await getDbPool().execute( + `DELETE FROM ${TABLE} WHERE credential_id = ? AND username = ?`, + [credentialId, username] + ); + return result.affectedRows > 0; +} diff --git a/lib/webauthn.ts b/lib/webauthn.ts new file mode 100644 index 0000000..9a2e1d9 --- /dev/null +++ b/lib/webauthn.ts @@ -0,0 +1,176 @@ +import { cookies } from 'next/headers'; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from '@simplewebauthn/server'; +import type { + RegistrationResponseJSON, + AuthenticationResponseJSON, + AuthenticatorTransportFuture, +} from '@simplewebauthn/server'; +import { isKnownUser } from './auth'; +import { listPasskeys, getPasskey, insertPasskey, updatePasskeyUsage } from './passkeys'; + +const REG_CHALLENGE_COOKIE = 'ausgaben_pk_reg'; +const AUTH_CHALLENGE_COOKIE = 'ausgaben_pk_auth'; +const CHALLENGE_TTL_S = 300; // 5 Minuten + +/** + * Relying-Party-Konfiguration. In Produktion über Env-Vars gesetzt + * (RP_ID = Host, RP_ORIGIN = volle URL). Default-Werte für lokale Entwicklung. + */ +export function getRpConfig() { + const rpID = process.env.RP_ID || 'localhost'; + const rpName = process.env.RP_NAME || 'Ausgaben-Log'; + const origin = process.env.RP_ORIGIN || 'http://localhost:3005'; + return { rpID, rpName, origin }; +} + +// --- Challenge-Cookie (kurzlebig, httpOnly) ------------------------------ + +async function setChallengeCookie(name: string, challenge: string): Promise { + const cookieStore = await cookies(); + cookieStore.set(name, challenge, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: CHALLENGE_TTL_S, + }); +} + +async function takeChallengeCookie(name: string): Promise { + const cookieStore = await cookies(); + const value = cookieStore.get(name)?.value ?? null; + cookieStore.delete(name); + return value; +} + +// --- Registrierung (Enrollment, Session erforderlich) -------------------- + +export async function buildRegistrationOptions(username: string) { + const { rpID, rpName } = getRpConfig(); + const existing = await listPasskeys(username); + + const options = await generateRegistrationOptions({ + rpName, + rpID, + userName: username, + userDisplayName: username, + userID: new TextEncoder().encode(username), + attestationType: 'none', + excludeCredentials: existing.map((pk) => ({ + id: pk.credentialId, + transports: pk.transports as AuthenticatorTransportFuture[], + })), + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + }, + }); + + await setChallengeCookie(REG_CHALLENGE_COOKIE, options.challenge); + return options; +} + +/** Verifiziert die Registrierungs-Antwort und speichert den Passkey für den Benutzer. */ +export async function finishRegistration( + username: string, + response: RegistrationResponseJSON, + label: string +): Promise<{ ok: true } | { ok: false; error: string }> { + const expectedChallenge = await takeChallengeCookie(REG_CHALLENGE_COOKIE); + if (!expectedChallenge) return { ok: false, error: 'Challenge abgelaufen. Bitte erneut versuchen.' }; + + const { rpID, origin } = getRpConfig(); + let verification; + try { + verification = await verifyRegistrationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: false, + }); + } catch { + return { ok: false, error: 'Passkey konnte nicht verifiziert werden.' }; + } + + if (!verification.verified || !verification.registrationInfo) { + return { ok: false, error: 'Passkey-Registrierung fehlgeschlagen.' }; + } + + const { credential } = verification.registrationInfo; + if (await getPasskey(credential.id)) { + return { ok: false, error: 'Dieser Passkey ist bereits registriert.' }; + } + + await insertPasskey({ + credentialId: credential.id, + username, + publicKey: credential.publicKey, + counter: credential.counter, + transports: credential.transports ?? [], + label: label.trim().slice(0, 80) || 'Passkey', + }); + return { ok: true }; +} + +// --- Anmeldung (öffentlich, keine Session) ------------------------------- + +export async function buildAuthenticationOptions() { + const { rpID } = getRpConfig(); + const options = await generateAuthenticationOptions({ + rpID, + userVerification: 'preferred', + // allowCredentials leer lassen → erlaubt Discoverable Credentials / Browser-Auswahl. + }); + await setChallengeCookie(AUTH_CHALLENGE_COOKIE, options.challenge); + return options; +} + +/** + * Verifiziert die Anmelde-Antwort gegen einen gespeicherten Passkey und liefert + * den zugehörigen Benutzer zurück. + */ +export async function finishAuthentication( + response: AuthenticationResponseJSON +): Promise<{ ok: true; username: string } | { ok: false; error: string }> { + const expectedChallenge = await takeChallengeCookie(AUTH_CHALLENGE_COOKIE); + if (!expectedChallenge) return { ok: false, error: 'Challenge abgelaufen. Bitte erneut versuchen.' }; + + const passkey = await getPasskey(response.id); + if (!passkey) return { ok: false, error: 'Unbekannter Passkey.' }; + + // Benutzer könnte inzwischen aus AUTH_USERS entfernt worden sein. + if (!isKnownUser(passkey.username)) { + return { ok: false, error: 'Benutzer existiert nicht mehr.' }; + } + + const { rpID, origin } = getRpConfig(); + let verification; + try { + verification = await verifyAuthenticationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + requireUserVerification: false, + credential: { + id: passkey.credentialId, + publicKey: passkey.publicKey, + counter: passkey.counter, + transports: passkey.transports as AuthenticatorTransportFuture[], + }, + }); + } catch { + return { ok: false, error: 'Anmeldung mit Passkey fehlgeschlagen.' }; + } + + if (!verification.verified) return { ok: false, error: 'Anmeldung mit Passkey fehlgeschlagen.' }; + + await updatePasskeyUsage(passkey.credentialId, verification.authenticationInfo.newCounter); + return { ok: true, username: passkey.username }; +} diff --git a/package-lock.json b/package-lock.json index e970bd5..b1df964 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,15 @@ { "name": "ausgaben_next", - "version": "1.2.0", + "version": "2.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ausgaben_next", - "version": "1.2.0", + "version": "2.1.1", "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", "bcryptjs": "^3.0.3", "jose": "^6.1.3", "mysql2": "^3.17.4", @@ -458,6 +460,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.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1026,6 +1034,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": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1231,6 +1245,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", @@ -1238,6 +1420,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", @@ -2387,6 +2594,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", @@ -5551,6 +5772,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.2.0", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.2.0.tgz", + "integrity": "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==", + "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", @@ -5602,6 +5841,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", @@ -6363,6 +6608,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 5a435af..76fa561 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ausgaben_next", - "version": "2.1.1", + "version": "2.2.1", "private": true, "scripts": { "dev": "next dev -p 3005", @@ -10,6 +10,8 @@ "generate-password": "node scripts/generate-password.js" }, "dependencies": { + "@simplewebauthn/browser": "^13.3.0", + "@simplewebauthn/server": "^13.3.2", "bcryptjs": "^3.0.3", "jose": "^6.1.3", "mysql2": "^3.17.4", diff --git a/proxy.ts b/proxy.ts index 6ce7c61..9563590 100644 --- a/proxy.ts +++ b/proxy.ts @@ -19,7 +19,8 @@ export async function proxy(request: NextRequest) { } // Public paths that don't require authentication - const publicPaths = ['/login']; + // (Passkey-Anmeldung muss ohne Session erreichbar sein) + const publicPaths = ['/login', '/api/passkey/authenticate']; const isPublicPath = publicPaths.some(path => pathname.startsWith(path)); if (isPublicPath) {