V 2.2.0 Anmeldung per Passkey (WebAuthn)

Zusätzlich zu Benutzername/Passwort ist die Anmeldung per Passkey möglich
(Fingerabdruck, Gesichtserkennung, Geräte-PIN). Das Passwort bleibt als
Alternative bestehen.

- lib/webauthn.ts kapselt @simplewebauthn/server, RP-Config über
  RP_ID/RP_ORIGIN/RP_NAME, Challenge in kurzlebigem httpOnly-Cookie
- lib/passkeys.ts speichert Credentials je Benutzer in der MySQL-Tabelle
  passkeys und legt sie beim ersten Zugriff an
- API unter app/api/passkey/: register (Session nötig), authenticate
  (öffentlich, in proxy.ts von der Session-Pflicht ausgenommen), GET/DELETE
  zum Verwalten
- Neuer Tab "Einstellungen" zum Registrieren und Entfernen von Passkeys
- Button "Mit Passkey anmelden" auf der Login-Seite

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 09:25:06 +02:00
parent 475c804ce7
commit da79543d54
19 changed files with 981 additions and 4 deletions
+7
View File
@@ -6,4 +6,11 @@ DB_USER=root
DB_PASS=MyPassword DB_PASS=MyPassword
DB_NAME=RXF DB_NAME=RXF
# Passkeys (WebAuthn) — Relying Party
# RP_ID = nur der Host (ohne Protokoll/Port), RP_ORIGIN = volle URL
# Lokal: RP_ID=localhost / RP_ORIGIN=http://localhost:3000
RP_ID=werte.fuerst-stuttgart.de
RP_ORIGIN=https://werte.fuerst-stuttgart.de
RP_NAME=Werte-Log
# Build-Datum wird automatisch beim Build gesetzt - nicht hier definieren! # Build-Datum wird automatisch beim Build gesetzt - nicht hier definieren!
+12
View File
@@ -41,10 +41,22 @@ Passen Sie die Werte in der `.env`-Datei an:
DB_USER=root DB_USER=root
DB_PASS=IhrMySQLPasswort DB_PASS=IhrMySQLPasswort
DB_NAME=RXF DB_NAME=RXF
# Passkeys (WebAuthn): Host bzw. volle URL, unter der die App erreichbar ist
RP_ID=werte.fuerst-stuttgart.de
RP_ORIGIN=https://werte.fuerst-stuttgart.de
RP_NAME=Werte-Log
``` ```
**Wichtig**: Die `.env`-Datei enthält sensible Daten und wird NICHT ins Git-Repository committed! **Wichtig**: Die `.env`-Datei enthält sensible Daten und wird NICHT ins Git-Repository committed!
**Hinweis zu Passkeys**: `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).
Die Tabelle `passkeys` legt die Anwendung beim ersten Zugriff selbst an — hat der
DB-Benutzer keine CREATE-Rechte, das SQL aus `create_table.sql` manuell ausführen.
### 3. Docker Images bauen und Container starten ### 3. Docker Images bauen und Container starten
```bash ```bash
+18
View File
@@ -16,6 +16,24 @@ Eine moderne Next.js-Anwendung zur Erfassung und Verwaltung von Gesundheitsdaten
-**Responsive Design** mit Tailwind CSS -**Responsive Design** mit Tailwind CSS
-**TypeScript** für Type-Safety -**TypeScript** für Type-Safety
-**MySQL-Datenbankanbindung** (Docker-Container) -**MySQL-Datenbankanbindung** (Docker-Container)
-**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 `passkeys` (siehe `create_table.sql`); sie wird beim ersten
Zugriff automatisch angelegt, falls der DB-Benutzer CREATE-Rechte hat.
- **Konfiguration**: `RP_ID` (nur der Host), `RP_ORIGIN` (volle URL), `RP_NAME`.
Lokal sind `localhost` / `http://localhost:3000` voreingestellt.
- **Wichtig**: WebAuthn funktioniert nur über **HTTPS** oder auf `localhost`.
## Technologie-Stack ## Technologie-Stack
+28
View File
@@ -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 });
}
}
+36
View File
@@ -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 });
}
}
+40
View File
@@ -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 });
}
}
+13
View File
@@ -0,0 +1,13 @@
'use client';
import TabLayout from '@/components/TabLayout';
import Passkeys from '@/components/Passkeys';
export default function EinstellungenPage() {
return (
<TabLayout>
<h2 className="text-xl font-semibold mb-4">Einstellungen</h2>
<Passkeys />
</TabLayout>
);
}
+59
View File
@@ -1,12 +1,47 @@
'use client'; 'use client';
import { useActionState, useState } from 'react'; import { useActionState, useState } from 'react';
import { startAuthentication } from '@simplewebauthn/browser';
import { login } from './actions'; import { login } from './actions';
import packageJson from '@/package.json'; import packageJson from '@/package.json';
export default function LoginPage() { export default function LoginPage() {
const [state, loginAction, isPending] = useActionState(login, undefined); const [state, loginAction, isPending] = useActionState(login, undefined);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [passkeyBusy, setPasskeyBusy] = useState(false);
const [passkeyError, setPasskeyError] = useState<string | null>(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 version = packageJson.version;
const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE || new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); 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'} {isPending ? 'Anmeldung läuft...' : 'Anmelden'}
</button> </button>
</form> </form>
<div className="flex items-center gap-3 my-5">
<div className="h-px flex-1 bg-gray-200" />
<span className="text-xs text-gray-400">oder</span>
<div className="h-px flex-1 bg-gray-200" />
</div>
<button
type="button"
onClick={handlePasskeyLogin}
disabled={passkeyBusy}
className="w-full py-2 px-4 flex items-center justify-center gap-2 border-2 border-gray-400 hover:border-blue-500 text-gray-900 font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
{passkeyBusy ? 'Anmeldung läuft...' : 'Mit Passkey anmelden'}
</button>
{passkeyError && (
<div className="mt-4 bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
{passkeyError}
</div>
)}
</div> </div>
</div> </div>
+151
View File
@@ -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<Passkey[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="max-w-md">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Passkeys</h3>
<p className="text-xs text-gray-500 mb-3">
Melde dich künftig per Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Das Passwort
bleibt als Alternative bestehen.
</p>
{loading ? (
<p className="text-sm text-gray-500">Lädt</p>
) : passkeys.length === 0 ? (
<p className="text-sm text-gray-500 mb-3">Noch keine Passkeys registriert.</p>
) : (
<ul className="space-y-2 mb-4">
{passkeys.map((pk) => (
<li
key={pk.credentialId}
className="flex items-center justify-between gap-3 border-2 border-gray-300 rounded-lg px-3 py-2 bg-white"
>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">{pk.label}</div>
<div className="text-xs text-gray-500">
Erstellt {formatDate(pk.createdAt)} · Zuletzt {formatDate(pk.lastUsedAt)}
</div>
</div>
<button
onClick={() => handleDelete(pk.credentialId)}
className="text-xs px-2 py-1 text-red-600 hover:text-red-800 shrink-0"
>
Entfernen
</button>
</li>
))}
</ul>
)}
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1">
Bezeichnung (optional)
</label>
<input
type="text"
value={label}
onChange={(e) => 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"
/>
</div>
<button
onClick={handleRegister}
disabled={busy}
className="px-4 py-2 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 text-sm shrink-0"
>
{busy ? 'Läuft…' : 'Passkey hinzufügen'}
</button>
</div>
{error && (
<div className="mt-3 bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
{error}
</div>
)}
</div>
);
}
+1
View File
@@ -12,6 +12,7 @@ interface TabLayoutProps {
const TABS = [ const TABS = [
{ href: '/', label: 'Eingabe' }, { href: '/', label: 'Eingabe' },
{ href: '/charts', label: 'Verlauf' }, { href: '/charts', label: 'Verlauf' },
{ href: '/einstellungen', label: 'Einstellungen' },
]; ];
export default function TabLayout({ children }: TabLayoutProps) { export default function TabLayout({ children }: TabLayoutProps) {
+15
View File
@@ -16,3 +16,18 @@ CREATE TABLE IF NOT EXISTS Werte_BZG (
Puls INT NULL COMMENT 'Pulse rate', Puls INT NULL COMMENT 'Pulse rate',
INDEX idx_datum_zeit (Datum, Zeit) INDEX idx_datum_zeit (Datum, Zeit)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
-- 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.
CREATE TABLE IF NOT EXISTS 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_passkeys_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
+4
View File
@@ -20,6 +20,10 @@ services:
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- AUTH_USERS=${AUTH_USERS} - AUTH_USERS=${AUTH_USERS}
- AUTH_SECRET=${AUTH_SECRET} - AUTH_SECRET=${AUTH_SECRET}
# Passkeys (WebAuthn) — WebAuthn braucht HTTPS oder localhost
- RP_ID=${RP_ID:-localhost}
- RP_ORIGIN=${RP_ORIGIN:-http://localhost:3000}
- RP_NAME=${RP_NAME:-Werte-Log}
networks: networks:
- sternwarte_default - sternwarte_default
+4
View File
@@ -14,6 +14,10 @@ services:
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- AUTH_USERS=${AUTH_USERS} - AUTH_USERS=${AUTH_USERS}
- AUTH_SECRET=${AUTH_SECRET} - AUTH_SECRET=${AUTH_SECRET}
# Passkeys (WebAuthn): Host bzw. volle URL der Anwendung
- RP_ID=${RP_ID:-werte.fuerst-stuttgart.de}
- RP_ORIGIN=${RP_ORIGIN:-https://werte.fuerst-stuttgart.de}
- RP_NAME=${RP_NAME:-Werte-Log}
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.werte.entrypoints=http - traefik.http.routers.werte.entrypoints=http
+5
View File
@@ -29,6 +29,11 @@ export async function verifyCredentials(username: string, password: string): Pro
return bcrypt.compare(password, user.password); 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 { export function isAuthEnabled(): boolean {
return !!process.env.AUTH_USERS; return !!process.env.AUTH_USERS;
} }
+142
View File
@@ -0,0 +1,142 @@
import { getPool } from './db';
import type { RowDataPacket, ResultSetHeader } from 'mysql2/promise';
const TABLE = 'passkeys';
/** Ein gespeicherter Passkey (WebAuthn-Credential), einem Benutzer zugeordnet. */
export interface PasskeyRecord {
credentialId: string; // base64url
username: string;
publicKey: Uint8Array<ArrayBuffer>;
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<void> | 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<void> {
if (!schemaReady) {
schemaReady = getPool()
.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_passkeys_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_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<ArrayBuffer>).
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<PasskeyRecord[]> {
await ensureSchema();
const [rows] = await getPool().execute<PasskeyRow[]>(
`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<PasskeyRecord | undefined> {
await ensureSchema();
const [rows] = await getPool().execute<PasskeyRow[]>(
`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<void> {
await ensureSchema();
await getPool().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<void> {
await ensureSchema();
await getPool().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<boolean> {
await ensureSchema();
const [result] = await getPool().execute<ResultSetHeader>(
`DELETE FROM ${TABLE} WHERE credential_id = ? AND username = ?`,
[credentialId, username]
);
return result.affectedRows > 0;
}
+176
View File
@@ -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 = 'werte_pk_reg';
const AUTH_CHALLENGE_COOKIE = 'werte_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 || 'Werte-Log';
const origin = process.env.RP_ORIGIN || 'http://localhost:3000';
return { rpID, rpName, origin };
}
// --- Challenge-Cookie (kurzlebig, httpOnly) ------------------------------
async function setChallengeCookie(name: string, challenge: string): Promise<void> {
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<string | null> {
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 };
}
+265 -2
View File
@@ -1,13 +1,15 @@
{ {
"name": "werte_next", "name": "werte_next",
"version": "2.0.3", "version": "2.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "werte_next", "name": "werte_next",
"version": "2.0.3", "version": "2.1.1",
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"highcharts": "^12.5.0", "highcharts": "^12.5.0",
"highcharts-react-official": "^3.2.3", "highcharts-react-official": "^3.2.3",
@@ -460,6 +462,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@hexagon/base64": {
"version": "1.1.28",
"resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz",
"integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==",
"license": "MIT"
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -1028,6 +1036,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@levischuck/tiny-cbor": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
"integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -1233,6 +1247,174 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@peculiar/asn1-android": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.8.0.tgz",
"integrity": "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
"integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
"integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
"integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
"integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-rsa": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
"integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
"integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pfx": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
"integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
"integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
"integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
"integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1240,6 +1422,31 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@simplewebauthn/browser": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"license": "MIT"
},
"node_modules/@simplewebauthn/server": {
"version": "13.3.2",
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz",
"integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==",
"license": "MIT",
"dependencies": {
"@hexagon/base64": "^1.1.27",
"@levischuck/tiny-cbor": "^0.2.2",
"@peculiar/asn1-android": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.1",
"@peculiar/asn1-rsa": "^2.6.1",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.1",
"@peculiar/x509": "^1.14.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@swc/helpers": { "node_modules/@swc/helpers": {
"version": "0.5.15", "version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -2389,6 +2596,20 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/ast-types-flow": { "node_modules/ast-types-flow": {
"version": "0.0.8", "version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -5579,6 +5800,24 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5630,6 +5869,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -6391,6 +6636,24 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "werte_next", "name": "werte_next",
"version": "2.1.1", "version": "2.2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -9,6 +9,8 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@simplewebauthn/server": "^13.3.2",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"highcharts": "^12.5.0", "highcharts": "^12.5.0",
"highcharts-react-official": "^3.2.3", "highcharts-react-official": "^3.2.3",
+2 -1
View File
@@ -19,7 +19,8 @@ export async function proxy(request: NextRequest) {
} }
// Public paths that don't require authentication // 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)); const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
if (isPublicPath) { if (isPublicPath) {