V 1.3.0 Anmeldung per Passkey (WebAuthn)
Passkey-Login wie in ausgaben-next/werte-next: auf der Login-Seite "Mit Passkey anmelden", Verwaltung der eigenen Passkeys unter /einstellungen (Button oben rechts). Das Passwort bleibt als Alternative bestehen. - lib/webauthn.ts, lib/passkeys.ts sowie API-Routen unter /api/passkey - proxy.ts: /api/passkey/authenticate ohne Session erreichbar - Styles als eigene CSS-Klassen in globals.css (kein Tailwind in diesem Projekt), passend zum vorhandenen login-*/app-*-Schema - Tabelle tabletten_passkeys in der DB medizin: app-spezifischer Name, damit sich Apps desselben Stacks nicht gegenseitig Passkeys anzeigen oder loeschen - RP-Konfiguration ueber TABLETTEN_RP_*, da sich die .env auf dem Server mit anderen Apps teilt, die RP_ID/RP_ORIGIN bereits belegen Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,3 +34,22 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
|
|||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
|
|
||||||
|
## 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 oben rechts auf *Einstellungen* → „Passkey hinzufügen".
|
||||||
|
- **Anmelden**: auf der Login-Seite „Mit Passkey anmelden".
|
||||||
|
- **Speicherung**: Tabelle `tabletten_passkeys` in der Datenbank `medizin`; sie wird beim
|
||||||
|
ersten Zugriff automatisch angelegt, falls der DB-Benutzer CREATE-Rechte hat. Der
|
||||||
|
app-spezifische Name verhindert Kollisionen mit anderen Apps desselben Stacks.
|
||||||
|
- **Konfiguration**: Im Container `RP_ID` (nur der Host), `RP_ORIGIN` (volle URL) und
|
||||||
|
`RP_NAME`. In den Compose-Dateien werden diese aus `TABLETTEN_RP_*` befüllt, weil sich
|
||||||
|
die `.env` auf dem Server mit anderen Apps teilt, die `RP_ID`/`RP_ORIGIN` bereits belegen.
|
||||||
|
- **Wichtig**: WebAuthn funktioniert nur über **HTTPS** oder auf `localhost`. Ändert sich
|
||||||
|
`RP_ID`, werden bereits registrierte Passkeys ungültig.
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import AppLayout from '@/components/AppLayout';
|
||||||
|
import Passkeys from '@/components/Passkeys';
|
||||||
|
|
||||||
|
export default function EinstellungenPage() {
|
||||||
|
return (
|
||||||
|
<AppLayout>
|
||||||
|
<h2 className="settings-title">Einstellungen</h2>
|
||||||
|
<Passkeys />
|
||||||
|
<p className="settings-back">
|
||||||
|
<Link href="/">← Zurück zur Übersicht</Link>
|
||||||
|
</p>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
+150
@@ -146,9 +146,22 @@ main {
|
|||||||
.app-logout-bar {
|
.app-logout-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
margin-bottom: -2px;
|
margin-bottom: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-settings {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: #85B7D7;
|
||||||
|
color: black;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn-settings:hover { background-color: #6a9fc5; }
|
||||||
|
|
||||||
.btn-logout {
|
.btn-logout {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
background-color: #dc2626;
|
background-color: #dc2626;
|
||||||
@@ -314,3 +327,140 @@ main {
|
|||||||
}
|
}
|
||||||
.login-footer a { color: #2563eb; }
|
.login-footer a { color: #2563eb; }
|
||||||
.login-footer a:hover { text-decoration: underline; }
|
.login-footer a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ---------- Passkeys / Anmeldung per Passkey ---------- */
|
||||||
|
|
||||||
|
.login-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 20px 0;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.login-divider::before,
|
||||||
|
.login-divider::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-passkey-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: none;
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border: 2px solid #9ca3af;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.login-passkey-btn:hover:not(:disabled) { border-color: #3b82f6; }
|
||||||
|
.login-passkey-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.login-error-passkey { margin-top: 16px; }
|
||||||
|
|
||||||
|
.settings-title {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.settings-back { margin-top: 20px; font-size: 0.875rem; }
|
||||||
|
|
||||||
|
.passkeys { max-width: 28rem; }
|
||||||
|
.passkeys-title {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.passkeys-hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.passkeys-empty {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passkeys-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.passkeys-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #d1d5db;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.passkeys-item-info { min-width: 0; }
|
||||||
|
.passkeys-item-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.passkeys-item-meta { font-size: 0.75rem; color: #6b7280; }
|
||||||
|
.passkeys-remove {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.passkeys-remove:hover { color: #991b1b; }
|
||||||
|
|
||||||
|
.passkeys-add {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.passkeys-add-field { flex: 1; }
|
||||||
|
.passkeys-add-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.passkeys-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #9ca3af;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.passkeys-input:focus { border-color: #3b82f6; outline: none; }
|
||||||
|
.passkeys-add-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: #85B7D7;
|
||||||
|
color: black;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.passkeys-add-btn:hover:not(:disabled) { background-color: #6a9fc5; }
|
||||||
|
.passkeys-add-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.passkeys-error { margin-top: 12px; }
|
||||||
|
|||||||
@@ -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' });
|
||||||
@@ -89,6 +124,28 @@ export default function LoginPage() {
|
|||||||
{isPending ? 'Anmeldung läuft...' : 'Anmelden'}
|
{isPending ? 'Anmeldung läuft...' : 'Anmelden'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div className="login-divider">
|
||||||
|
<span>oder</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePasskeyLogin}
|
||||||
|
disabled={passkeyBusy}
|
||||||
|
className="login-passkey-btn"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" 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="login-error login-error-passkey">
|
||||||
|
{passkeyError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import LogoutButton from '@/components/LogoutButton';
|
import LogoutButton from '@/components/LogoutButton';
|
||||||
import packageJson from '@/package.json';
|
import packageJson from '@/package.json';
|
||||||
|
|
||||||
@@ -9,6 +11,7 @@ interface AppLayoutProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AppLayout({ children }: AppLayoutProps) {
|
export default function AppLayout({ children }: AppLayoutProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
const version = packageJson.version;
|
const version = packageJson.version;
|
||||||
const [buildDate] = useState(() =>
|
const [buildDate] = useState(() =>
|
||||||
process.env.NEXT_PUBLIC_BUILD_DATE || new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
process.env.NEXT_PUBLIC_BUILD_DATE || new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
@@ -23,8 +26,13 @@ export default function AppLayout({ children }: AppLayoutProps) {
|
|||||||
|
|
||||||
<div className="app-inner">
|
<div className="app-inner">
|
||||||
|
|
||||||
{/* Logout-Button oben rechts */}
|
{/* Einstellungen und Logout oben rechts */}
|
||||||
<div className="app-logout-bar">
|
<div className="app-logout-bar">
|
||||||
|
{pathname !== '/einstellungen' && (
|
||||||
|
<Link href="/einstellungen" className="btn-settings">
|
||||||
|
Einstellungen
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<LogoutButton className="btn-logout" />
|
<LogoutButton className="btn-logout" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
'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="passkeys">
|
||||||
|
<h3 className="passkeys-title">Passkeys</h3>
|
||||||
|
<p className="passkeys-hint">
|
||||||
|
Melde dich künftig per Fingerabdruck, Gesichtserkennung oder Geräte-PIN an. Das Passwort
|
||||||
|
bleibt als Alternative bestehen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="passkeys-empty">Lädt…</p>
|
||||||
|
) : passkeys.length === 0 ? (
|
||||||
|
<p className="passkeys-empty">Noch keine Passkeys registriert.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="passkeys-list">
|
||||||
|
{passkeys.map((pk) => (
|
||||||
|
<li key={pk.credentialId} className="passkeys-item">
|
||||||
|
<div className="passkeys-item-info">
|
||||||
|
<div className="passkeys-item-label">{pk.label}</div>
|
||||||
|
<div className="passkeys-item-meta">
|
||||||
|
Erstellt {formatDate(pk.createdAt)} · Zuletzt {formatDate(pk.lastUsedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleDelete(pk.credentialId)} className="passkeys-remove">
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="passkeys-add">
|
||||||
|
<div className="passkeys-add-field">
|
||||||
|
<label className="passkeys-add-label">Bezeichnung (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
|
placeholder="z. B. iPhone"
|
||||||
|
maxLength={80}
|
||||||
|
className="passkeys-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleRegister} disabled={busy} className="passkeys-add-btn">
|
||||||
|
{busy ? 'Läuft…' : 'Passkey hinzufügen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="login-error passkeys-error">{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,12 @@ 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.
|
||||||
|
# Eigene Variablennamen, weil sich mehrere Apps im Stack eine .env teilen
|
||||||
|
# und RP_ID/RP_ORIGIN dort bereits von werte-next belegt sind.
|
||||||
|
- RP_ID=${TABLETTEN_RP_ID:-tabletten.fuerst-stuttgart.de}
|
||||||
|
- RP_ORIGIN=${TABLETTEN_RP_ORIGIN:-https://tabletten.fuerst-stuttgart.de}
|
||||||
|
- RP_NAME=${TABLETTEN_RP_NAME:-Tabletten-Check}
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.http.routers.tabletten.entrypoints=http
|
- traefik.http.routers.tabletten.entrypoints=http
|
||||||
|
|||||||
@@ -42,5 +42,9 @@ services:
|
|||||||
CHECK_SECRET: ${CHECK_SECRET}
|
CHECK_SECRET: ${CHECK_SECRET}
|
||||||
AUTH_USERS: ${AUTH_USERS}
|
AUTH_USERS: ${AUTH_USERS}
|
||||||
AUTH_SECRET: ${AUTH_SECRET}
|
AUTH_SECRET: ${AUTH_SECRET}
|
||||||
|
# Passkeys (WebAuthn) — braucht HTTPS oder localhost
|
||||||
|
RP_ID: ${TABLETTEN_RP_ID:-localhost}
|
||||||
|
RP_ORIGIN: ${TABLETTEN_RP_ORIGIN:-http://localhost:3000}
|
||||||
|
RP_NAME: ${TABLETTEN_RP_NAME:-Tabletten-Check}
|
||||||
env_file:
|
env_file:
|
||||||
- .env.local
|
- .env.local
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
import pool from './mysql';
|
||||||
|
import type { RowDataPacket, ResultSetHeader } from 'mysql2/promise';
|
||||||
|
|
||||||
|
// Eigener Tabellenname je Anwendung: Die DB-Zugangsdaten sind auf dem Server für
|
||||||
|
// alle Apps im Stack dieselben. Ein generisches `passkeys` würde sich mit anderen
|
||||||
|
// Apps überschneiden — sie sähen und löschten dann gegenseitig ihre Passkeys.
|
||||||
|
const TABLE = 'tabletten_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).
|
||||||
|
*/
|
||||||
|
function ensureSchema(): Promise<void> {
|
||||||
|
if (!schemaReady) {
|
||||||
|
schemaReady = pool
|
||||||
|
.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_tabletten_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<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 pool.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 pool.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 pool.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 pool.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 pool.execute<ResultSetHeader>(
|
||||||
|
`DELETE FROM ${TABLE} WHERE credential_id = ? AND username = ?`,
|
||||||
|
[credentialId, username]
|
||||||
|
);
|
||||||
|
return result.affectedRows > 0;
|
||||||
|
}
|
||||||
+176
@@ -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 = 'tabletten_pk_reg';
|
||||||
|
const AUTH_CHALLENGE_COOKIE = 'tabletten_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 || 'Tabletten-Check';
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Generated
+265
-2
@@ -1,13 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "tabletten_next",
|
"name": "tabletten_next",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tabletten_next",
|
"name": "tabletten_next",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"@simplewebauthn/server": "^13.3.2",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"jose": "^6.2.1",
|
"jose": "^6.2.1",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
@@ -449,6 +451,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",
|
||||||
@@ -1017,6 +1025,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/@mongodb-js/saslprep": {
|
"node_modules/@mongodb-js/saslprep": {
|
||||||
"version": "1.4.6",
|
"version": "1.4.6",
|
||||||
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz",
|
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz",
|
||||||
@@ -1232,6 +1246,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",
|
||||||
@@ -1239,6 +1421,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",
|
||||||
@@ -2154,6 +2361,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",
|
||||||
@@ -5124,6 +5345,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.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": {
|
"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",
|
||||||
@@ -5175,6 +5414,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",
|
||||||
@@ -5944,6 +6189,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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tabletten_next",
|
"name": "tabletten_next",
|
||||||
"version": "1.2.2",
|
"version": "1.3.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",
|
||||||
"jose": "^6.2.1",
|
"jose": "^6.2.1",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export async function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// /login und /api/check sind öffentlich
|
// /login und /api/check sind öffentlich
|
||||||
const publicPaths = ['/login', '/api/check'];
|
// (Passkey-Anmeldung muss ohne Session erreichbar sein)
|
||||||
|
const publicPaths = ['/login', '/api/check', '/api/passkey/authenticate'];
|
||||||
if (publicPaths.some(p => pathname.startsWith(p))) {
|
if (publicPaths.some(p => pathname.startsWith(p))) {
|
||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user