diff --git a/.env.example b/.env.example index 2fb092f..6ba6239 100644 --- a/.env.example +++ b/.env.example @@ -2,29 +2,3 @@ DB_HOST=localhost DB_USER=root DB_PASSWORD=your_password DB_NAME=RXF - -# Authentication Configuration -# Format: username:passwordHash,username2:passwordHash2 (max 5 users) -# Use 'node scripts/generate-password.js [password]' to generate hashes -# Leave empty to disable authentication -# Example hashes below (passwords: admin123, pass1): -AUTH_USERS=admin:$2b$10$DKLO7uQPmdAw9Z64NChro.8mOsnqZQaRZjctWDojIkK926ROBVyJW,user1:$2b$10$K613Z70Hodr6xyEh10Mw2uoRZMV3U4LIB09929JUWw2n/pXKoUqaW - -# 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 deleted file mode 100644 index c22af65..0000000 --- a/AUTH_README.md +++ /dev/null @@ -1,203 +0,0 @@ -# Wiederverwendbare Authentifizierung - -Diese Authentifizierungslösung kann einfach in andere Next.js Projekte übernommen werden. - -## Komponenten - -### 1. Core Libraries (wiederverwendbar) -- `/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 (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 - -### 1. Dependencies installieren -```bash -npm install jose bcryptjs -npm install --save-dev @types/bcryptjs - -# Für Passkeys (WebAuthn): -npm install @simplewebauthn/server @simplewebauthn/browser -``` - -### 2. Dateien kopieren -Kopiere folgende Dateien in dein neues Projekt: -- `lib/auth.ts` -- `lib/session.ts` -- `middleware.ts` -- `app/login/` (gesamter Ordner) -- `scripts/generate-password.js` (Passwort-Hash Generator) -- `components/LogoutButton.tsx` (optional) - -### 3. Passwort-Hashes generieren -Verwende das mitgelieferte Script, um sichere Passwort-Hashes zu erstellen: - -```bash -# Interactive Mode -node scripts/generate-password.js - -# Mit Passwort als Argument -node scripts/generate-password.js meinPasswort123 -``` - -Das Script gibt einen bcrypt-Hash aus, den du in der `.env` verwenden kannst. - -### 4. Umgebungsvariablen einrichten -Füge zu deiner `.env` hinzu: - -```env -# Authentifizierung -# Format: username:passwordHash,username2:passwordHash2 -# Verwende 'node scripts/generate-password.js' um Hashes zu generieren -AUTH_USERS=admin:$2b$10$DKLO7uQPmdAw9Z64NChro...,user1:$2b$10$K613Z70Hodr6xyEh10Mw2u... - -# 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) -```tsx -import LogoutButton from '@/components/LogoutButton'; - -// In deiner Komponente: - -``` - -## Konfiguration - -### Benutzer hinzufügen/entfernen - -1. Generiere einen Passwort-Hash: -```bash -node scripts/generate-password.js neuesPasswort -``` - -2. Editiere die `AUTH_USERS` Variable in der `.env`: -```env -AUTH_USERS=user1:$2b$10$hash1...,user2:$2b$10$hash2...,user3:$2b$10$hash3... -``` - -### Authentifizierung deaktivieren -Entferne die `AUTH_USERS` Variable oder setze sie auf einen leeren String: -```env -AUTH_USERS= -``` - -### Session-Dauer anpassen -Editiere in `lib/session.ts`: -```ts -const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 Tage -``` - -### Geschützte Routen anpassen -Editiere in `middleware.ts` die `publicPaths`: -```ts -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 - -1. **AUTH_SECRET ändern**: Verwende in Production einen starken, zufälligen Schlüssel -2. **HTTPS verwenden**: In Production immer HTTPS aktivieren -3. **Passwort-Hashing**: Passwörter werden mit bcrypt gehashed (10 Salt Rounds) -4. **Keine Klartext-Passwörter**: Verwende immer das Script zur Hash-Generierung - -## Passwort-Hash Generator - -Das Script `scripts/generate-password.js` verwendet bcrypt mit 10 Salt Rounds, um sichere Passwort-Hashes zu erstellen. - -### Verwendung - -Interactive Mode (empfohlen für sensible Passwörter): -```bash -npm run generate-password -# oder -node scripts/generate-password.js -# Passwort wird interaktiv abgefragt -``` - -Mit Argument: -```bash -npm run generate-password -- meinPasswort -# oder -node scripts/generate-password.js meinPasswort -``` - -### Ausgabe -``` -🔐 Generiere Passwort-Hash... - -✅ Hash generiert: -──────────────────────────────────────────────────────────────────────────────── -$2b$10$DKLO7uQPmdAw9Z64NChro.8mOsnqZQaRZjctWDojIkK926ROBVyJW -──────────────────────────────────────────────────────────────────────────────── - -📝 Verwende diesen Hash in der .env Datei: -AUTH_USERS=username:$2b$10$DKLO7uQPmdAw9Z64NChro.8mOsnqZQaRZjctWDojIkK926ROBVyJW -``` - -## Erweiterte Verwendung - -### Session-Informationen abrufen -```ts -import { getSession } from '@/lib/session'; - -const session = await getSession(); -if (session) { - console.log('Eingeloggt als:', session.username); -} -``` - -### Programmatisch prüfen, ob authentifiziert -```ts -import { isAuthenticated } from '@/lib/session'; - -const authenticated = await isAuthenticated(); -``` - -### In Server Components -```tsx -import { getSession } from '@/lib/session'; -import { redirect } from 'next/navigation'; - -export default async function ProtectedPage() { - const session = await getSession(); - - if (!session) { - redirect('/login'); - } - - return
Hallo {session.username}!
; -} -``` diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index afb4841..6c5f48c 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -66,33 +66,6 @@ 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 22061ea..c7b51ac 100644 --- a/README.md +++ b/README.md @@ -23,27 +23,6 @@ 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 deleted file mode 100644 index 2d61442..0000000 --- a/app/api/passkey/authenticate/route.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index af684c7..0000000 --- a/app/api/passkey/register/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 86291ec..0000000 --- a/app/api/passkey/route.ts +++ /dev/null @@ -1,40 +0,0 @@ -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/actions.ts b/app/login/actions.ts deleted file mode 100644 index 5438a3c..0000000 --- a/app/login/actions.ts +++ /dev/null @@ -1,28 +0,0 @@ -'use server'; - -import { verifyCredentials } from '@/lib/auth'; -import { createSession, deleteSession } from '@/lib/session'; -import { redirect } from 'next/navigation'; - -export async function login(prevState: any, formData: FormData) { - const username = formData.get('username') as string; - const password = formData.get('password') as string; - - if (!username || !password) { - return { error: 'Bitte Benutzername und Passwort eingeben' }; - } - - const isValid = await verifyCredentials(username, password); - - if (!isValid) { - return { error: 'Ungültige Anmeldedaten' }; - } - - await createSession(username); - redirect('/'); -} - -export async function logout() { - await deleteSession(); - redirect('/login'); -} diff --git a/app/login/page.tsx b/app/login/page.tsx deleted file mode 100644 index 9933a06..0000000 --- a/app/login/page.tsx +++ /dev/null @@ -1,175 +0,0 @@ -'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' }); - - return ( -
-
-
-

Ausgaben - Log

-
- -
-
-

Anmeldung

- -
-
- - -
- -
- -
- - -
-
- - {state?.error && ( -
- {state.error} -
- )} - - -
- -
-
- oder -
-
- - - - {passkeyError && ( -
- {passkeyError} -
- )} -
-
- - {/* Footer */} - -
-
- ); -} - diff --git a/app/login/page.tsx_xx b/app/login/page.tsx_xx deleted file mode 100644 index adbc23e..0000000 --- a/app/login/page.tsx_xx +++ /dev/null @@ -1,83 +0,0 @@ -'use client'; - -import { useActionState } from 'react'; -import { login } from './actions'; - -export default function LoginPage() { - const [state, loginAction, isPending] = useActionState(login, undefined); - - return ( -
-
-

Ausgaben - Log

- -
-
-
-

Anmeldung

-

- Bitte melden Sie sich an, um fortzufahren -

-
- -
-
-
- - -
- -
- - -
-
- - {state?.error && ( -
- {state.error} -
- )} - - -
-
-
-
-
- ); -} diff --git a/app/page.tsx b/app/page.tsx index 1582d64..3920083 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,8 +4,7 @@ import { useState, useEffect } from 'react'; import AusgabenForm from '@/components/AusgabenForm'; import AusgabenList from '@/components/AusgabenList'; import MonatsStatistik from '@/components/MonatsStatistik'; -import TabLayout, { SETTINGS_TAB } from '@/components/TabLayout'; -import Passkeys from '@/components/Passkeys'; +import TabLayout from '@/components/TabLayout'; import { AusgabenEntry } from '@/types/ausgaben'; const MAX_ENTRIES = 15; @@ -19,7 +18,6 @@ export default function Home() { useEffect(() => { setSelectedEntry(null); // Clear selected entry when switching tabs - if (activeTab === SETTINGS_TAB) return; // Einstellungen laden keine Einträge fetchRecentEntries(); }, [activeTab]); @@ -62,17 +60,6 @@ export default function Home() { window.scrollTo({ top: 0, behavior: 'smooth' }); }; - if (activeTab === SETTINGS_TAB) { - return ( - -
-

Einstellungen

- -
-
- ); - } - return (
diff --git a/components/LogoutButton.tsx b/components/LogoutButton.tsx deleted file mode 100644 index 1423a9e..0000000 --- a/components/LogoutButton.tsx +++ /dev/null @@ -1,23 +0,0 @@ -'use client'; - -import { logout } from '@/app/login/actions'; - -interface LogoutButtonProps { - className?: string; - children?: React.ReactNode; -} - -export default function LogoutButton({ className, children }: LogoutButtonProps) { - const handleLogout = async () => { - await logout(); - }; - - return ( - - ); -} diff --git a/components/Passkeys.tsx b/components/Passkeys.tsx deleted file mode 100644 index d6eb7d6..0000000 --- a/components/Passkeys.tsx +++ /dev/null @@ -1,151 +0,0 @@ -'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 fd3ac2e..9cf0d83 100644 --- a/components/TabLayout.tsx +++ b/components/TabLayout.tsx @@ -1,7 +1,6 @@ 'use client'; import { ReactNode } from 'react'; -import LogoutButton from '@/components/LogoutButton'; import packageJson from '@/package.json'; interface Tab { @@ -9,9 +8,6 @@ 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; @@ -21,7 +17,6 @@ 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) { @@ -42,29 +37,24 @@ export default function TabLayout({ children, activeTab, onTabChange }: TabLayou
{/* Tab bar */} -
-
- {TABS.map(tab => { - const isActive = activeTab === tab.index; - return ( - - ); - })} -
-
- -
+
+ {TABS.map(tab => { + const isActive = activeTab === tab.index; + return ( + + ); + })}
{/* Content panel */} diff --git a/create_table.sql b/create_table.sql index c580302..8824eda 100644 --- a/create_table.sql +++ b/create_table.sql @@ -22,20 +22,3 @@ 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 cb99d85..4a9f8e2 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -15,11 +15,3 @@ services: - DB_USER=${DB_USER} - DB_PASS=${DB_PASS} - 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 a4dd15f..70b1cd5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -12,15 +12,6 @@ services: - DB_USER=${DB_USER} - DB_PASS=${DB_PASS} - 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/lib/auth.ts b/lib/auth.ts deleted file mode 100644 index 94abb10..0000000 --- a/lib/auth.ts +++ /dev/null @@ -1,39 +0,0 @@ -import bcrypt from 'bcryptjs'; - -export interface User { - username: string; - password: string; -} - -export function getUsers(): User[] { - const usersString = process.env.AUTH_USERS || ''; - if (!usersString) { - console.warn('AUTH_USERS not configured in .env'); - return []; - } - return usersString - .split(',') - .map((userPair) => { - const [username, password] = userPair.trim().split(':'); - return { username: username?.trim(), password: password?.trim() }; - }) - .filter((user) => user.username && user.password); -} - -export async function verifyCredentials(username: string, password: string): Promise { - const users = getUsers(); - const user = users.find(u => u.username === username); - if (!user) { - return false; - } - 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 deleted file mode 100644 index 4439477..0000000 --- a/lib/passkeys.ts +++ /dev/null @@ -1,146 +0,0 @@ -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/session.ts b/lib/session.ts deleted file mode 100644 index d9bd8e0..0000000 --- a/lib/session.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { cookies } from 'next/headers'; -import { SignJWT, jwtVerify } from 'jose'; - -const SESSION_COOKIE_NAME = 'auth_session'; -const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days - -const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production'; -const key = new TextEncoder().encode(secretKey); - -export interface SessionData { - username: string; - isAuthenticated: boolean; - expiresAt: number; -} - -/** - * Encrypt session data to JWT - */ -async function encrypt(payload: SessionData): Promise { - return await new SignJWT(payload as any) - .setProtectedHeader({ alg: 'HS256' }) - .setIssuedAt() - .setExpirationTime(new Date(payload.expiresAt)) - .sign(key); -} - -/** - * Decrypt JWT to session data - */ -async function decrypt(token: string): Promise { - try { - const { payload } = await jwtVerify(token, key, { - algorithms: ['HS256'], - }); - return { - username: payload.username as string, - isAuthenticated: payload.isAuthenticated as boolean, - expiresAt: payload.expiresAt as number, - }; - } catch (error) { - return null; - } -} - -/** - * Create a new session - */ -export async function createSession(username: string): Promise { - const expiresAt = Date.now() + SESSION_DURATION; - const session: SessionData = { - username, - isAuthenticated: true, - expiresAt, - }; - - const encryptedSession = await encrypt(session); - const cookieStore = await cookies(); - - cookieStore.set(SESSION_COOKIE_NAME, encryptedSession, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - expires: expiresAt, - sameSite: 'lax', - path: '/', - }); -} - -/** - * Get current session - */ -export async function getSession(): Promise { - const cookieStore = await cookies(); - const cookie = cookieStore.get(SESSION_COOKIE_NAME); - - if (!cookie?.value) { - return null; - } - - const session = await decrypt(cookie.value); - - if (!session || session.expiresAt < Date.now()) { - return null; - } - - return session; -} - -/** - * Delete session (logout) - */ -export async function deleteSession(): Promise { - const cookieStore = await cookies(); - cookieStore.delete(SESSION_COOKIE_NAME); -} - -/** - * Verify if user is authenticated - */ -export async function isAuthenticated(): Promise { - const session = await getSession(); - return session?.isAuthenticated ?? false; -} diff --git a/lib/webauthn.ts b/lib/webauthn.ts deleted file mode 100644 index 9a2e1d9..0000000 --- a/lib/webauthn.ts +++ /dev/null @@ -1,176 +0,0 @@ -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 b1df964..c7df1ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,13 @@ { "name": "ausgaben_next", - "version": "2.1.1", + "version": "2.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ausgaben_next", - "version": "2.1.1", + "version": "2.2.1", "dependencies": { - "@simplewebauthn/browser": "^13.3.0", - "@simplewebauthn/server": "^13.3.2", - "bcryptjs": "^3.0.3", - "jose": "^6.1.3", "mysql2": "^3.17.4", "next": "16.1.6", "react": "19.2.3", @@ -19,7 +15,6 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", - "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -460,12 +455,6 @@ "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", @@ -1034,12 +1023,6 @@ "@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", @@ -1245,174 +1228,6 @@ "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", @@ -1420,31 +1235,6 @@ "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", @@ -1736,13 +1526,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/bcryptjs": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", - "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2594,20 +2377,6 @@ "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", @@ -2689,15 +2458,6 @@ "node": ">=6.0.0" } }, - "node_modules/bcryptjs": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", - "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", - "license": "BSD-3-Clause", - "bin": { - "bcrypt": "bin/bcrypt" - } - }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -4731,15 +4491,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5772,24 +5523,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.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", @@ -5841,12 +5574,6 @@ "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", @@ -6608,24 +6335,6 @@ "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 76fa561..068f7bb 100644 --- a/package.json +++ b/package.json @@ -6,14 +6,9 @@ "dev": "next dev -p 3005", "build": "next build", "start": "next start -p 3005", - "lint": "eslint", - "generate-password": "node scripts/generate-password.js" + "lint": "eslint" }, "dependencies": { - "@simplewebauthn/browser": "^13.3.0", - "@simplewebauthn/server": "^13.3.2", - "bcryptjs": "^3.0.3", - "jose": "^6.1.3", "mysql2": "^3.17.4", "next": "16.1.6", "react": "19.2.3", @@ -21,7 +16,6 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", - "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/proxy.ts b/proxy.ts deleted file mode 100644 index 9563590..0000000 --- a/proxy.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { jwtVerify } from 'jose'; - -const SESSION_COOKIE_NAME = 'auth_session'; - -/** - * Proxy to protect routes with authentication - * Reusable for other projects - just copy this file - */ -export async function proxy(request: NextRequest) { - const { pathname } = request.nextUrl; - - // Check if authentication is enabled - const authEnabled = !!process.env.AUTH_USERS; - - // If auth is not enabled, allow all requests - if (!authEnabled) { - return NextResponse.next(); - } - - // Public paths that don't require authentication - // (Passkey-Anmeldung muss ohne Session erreichbar sein) - const publicPaths = ['/login', '/api/passkey/authenticate']; - const isPublicPath = publicPaths.some(path => pathname.startsWith(path)); - - if (isPublicPath) { - return NextResponse.next(); - } - - // Check for session cookie - const sessionCookie = request.cookies.get(SESSION_COOKIE_NAME); - - if (!sessionCookie) { - return NextResponse.redirect(new URL('/login', request.url)); - } - - // Verify session token - try { - const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production'; - const key = new TextEncoder().encode(secretKey); - - const { payload } = await jwtVerify(sessionCookie.value, key, { - algorithms: ['HS256'], - }); - - // Check if session is expired - if (payload.expiresAt && (payload.expiresAt as number) < Date.now()) { - const response = NextResponse.redirect(new URL('/login', request.url)); - response.cookies.delete(SESSION_COOKIE_NAME); - return response; - } - - return NextResponse.next(); - } catch (error) { - // Invalid token - redirect to login - const response = NextResponse.redirect(new URL('/login', request.url)); - response.cookies.delete(SESSION_COOKIE_NAME); - return response; - } -} - -export default proxy; - -export const config = { - matcher: [ - /* - * Match all request paths except for the ones starting with: - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - * - public folder - */ - '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', - ], -}; diff --git a/scripts/generate-password.js b/scripts/generate-password.js deleted file mode 100755 index 15a1c00..0000000 --- a/scripts/generate-password.js +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node - -/** - * Password Hash Generator - * - * Usage: - * node scripts/generate-password.js [password] - * - * If no password is provided, you'll be prompted to enter one. - */ - -const bcrypt = require('bcryptjs'); -const readline = require('readline'); - -function generateHash(password) { - const saltRounds = 10; - const hash = bcrypt.hashSync(password, saltRounds); - return hash; -} - -function promptPassword() { - return new Promise((resolve) => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); - - rl.question('Passwort eingeben: ', (password) => { - rl.close(); - resolve(password); - }); - }); -} - -async function main() { - let password = process.argv[2]; - - if (!password) { - password = await promptPassword(); - } - - if (!password) { - console.error('❌ Kein Passwort angegeben!'); - process.exit(1); - } - - console.log('\n🔐 Generiere Passwort-Hash...\n'); - - const hash = generateHash(password); - - console.log('✅ Hash generiert:'); - console.log('─'.repeat(80)); - console.log(hash); - console.log('─'.repeat(80)); - console.log('\n📝 Verwende diesen Hash in der .env Datei:'); - console.log(`AUTH_USERS=username:${hash}`); - console.log('\n💡 Beispiel für mehrere Benutzer:'); - console.log(`AUTH_USERS=admin:${hash},user2:$2a$10$...\n`); -} - -main().catch(console.error);