Compare commits
2 Commits
da79543d54
...
ee1c06cb5a
| Author | SHA1 | Date | |
|---|---|---|---|
| ee1c06cb5a | |||
| 31b31c779b |
@@ -6,11 +6,4 @@ 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!
|
||||||
|
|||||||
@@ -41,22 +41,10 @@ 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
|
||||||
|
|||||||
@@ -16,24 +16,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { buildAuthenticationOptions, finishAuthentication } from '@/lib/webauthn';
|
|
||||||
import { createSession } from '@/lib/session';
|
|
||||||
|
|
||||||
// Öffentlich (kein Login): Optionen für die Anmeldung per Passkey.
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
return NextResponse.json(await buildAuthenticationOptions());
|
|
||||||
} catch (error) {
|
|
||||||
console.error('GET /api/passkey/authenticate:', error);
|
|
||||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Öffentlich: Verifiziert die Anmelde-Antwort und erstellt bei Erfolg die Session.
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const result = await finishAuthentication(body?.response);
|
|
||||||
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 401 });
|
|
||||||
|
|
||||||
await createSession(result.username);
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('POST /api/passkey/authenticate:', error);
|
|
||||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getSession } from '@/lib/session';
|
|
||||||
import { buildRegistrationOptions, finishRegistration } from '@/lib/webauthn';
|
|
||||||
|
|
||||||
// Optionen für die Registrierung eines neuen Passkeys (Session erforderlich).
|
|
||||||
export async function GET() {
|
|
||||||
const session = await getSession();
|
|
||||||
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
|
|
||||||
|
|
||||||
try {
|
|
||||||
return NextResponse.json(await buildRegistrationOptions(session.username));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('GET /api/passkey/register:', error);
|
|
||||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verifiziert die Registrierungs-Antwort und speichert den Passkey.
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const session = await getSession();
|
|
||||||
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
const result = await finishRegistration(
|
|
||||||
session.username,
|
|
||||||
body?.response,
|
|
||||||
String(body?.label ?? '')
|
|
||||||
);
|
|
||||||
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 });
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('POST /api/passkey/register:', error);
|
|
||||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { getSession } from '@/lib/session';
|
|
||||||
import { listPasskeys, deletePasskey } from '@/lib/passkeys';
|
|
||||||
|
|
||||||
// Liste der eigenen Passkeys (ohne öffentlichen Schlüssel).
|
|
||||||
export async function GET() {
|
|
||||||
const session = await getSession();
|
|
||||||
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const passkeys = (await listPasskeys(session.username)).map((pk) => ({
|
|
||||||
credentialId: pk.credentialId,
|
|
||||||
label: pk.label,
|
|
||||||
createdAt: pk.createdAt,
|
|
||||||
lastUsedAt: pk.lastUsedAt,
|
|
||||||
}));
|
|
||||||
return NextResponse.json({ passkeys });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('GET /api/passkey:', error);
|
|
||||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Löscht einen eigenen Passkey anhand seiner credentialId (?id=...).
|
|
||||||
export async function DELETE(request: NextRequest) {
|
|
||||||
const session = await getSession();
|
|
||||||
if (!session) return NextResponse.json({ error: 'Nicht angemeldet' }, { status: 401 });
|
|
||||||
|
|
||||||
const id = request.nextUrl.searchParams.get('id');
|
|
||||||
if (!id) return NextResponse.json({ error: 'Keine ID angegeben.' }, { status: 400 });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const removed = await deletePasskey(id, session.username);
|
|
||||||
if (!removed) return NextResponse.json({ error: 'Passkey nicht gefunden.' }, { status: 404 });
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('DELETE /api/passkey:', error);
|
|
||||||
return NextResponse.json({ error: 'Serverfehler' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import { verifyCredentials } from '@/lib/auth';
|
|
||||||
import { createSession, deleteSession } from '@/lib/session';
|
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
export async function login(prevState: any, formData: FormData) {
|
|
||||||
const username = formData.get('username') as string;
|
|
||||||
const password = formData.get('password') as string;
|
|
||||||
|
|
||||||
if (!username || !password) {
|
|
||||||
return { error: 'Bitte Benutzername und Passwort eingeben' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = await verifyCredentials(username, password);
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return { error: 'Ungültige Anmeldedaten' };
|
|
||||||
}
|
|
||||||
|
|
||||||
await createSession(username);
|
|
||||||
redirect('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout() {
|
|
||||||
await deleteSession();
|
|
||||||
redirect('/login');
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useActionState, useState } from 'react';
|
|
||||||
import { startAuthentication } from '@simplewebauthn/browser';
|
|
||||||
import { login } from './actions';
|
|
||||||
import packageJson from '@/package.json';
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
|
||||||
const [state, loginAction, isPending] = useActionState(login, undefined);
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [passkeyBusy, setPasskeyBusy] = useState(false);
|
|
||||||
const [passkeyError, setPasskeyError] = useState<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 buildDate = process.env.NEXT_PUBLIC_BUILD_DATE || new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-white py-4 px-4">
|
|
||||||
<main className="max-w-6xl mx-auto border-2 border-black rounded-lg p-6 bg-[#FFFFDD]">
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<h1 className="text-3xl font-bold">Werte - Log</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-center py-10">
|
|
||||||
<div className="w-full max-w-sm bg-white border border-gray-300 rounded-xl shadow-md p-8">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-6 text-center">Anmeldung</h2>
|
|
||||||
|
|
||||||
<form action={loginAction} className="space-y-5">
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="username"
|
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Benutzername
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
autoComplete="off"
|
|
||||||
className="w-full px-3 py-2 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
|
|
||||||
placeholder="Benutzername"
|
|
||||||
disabled={isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
htmlFor="password"
|
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
|
||||||
>
|
|
||||||
Passwort
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
required
|
|
||||||
autoComplete="new-password"
|
|
||||||
className="w-full px-3 py-2 pr-10 border-2 border-gray-400 rounded-lg bg-white text-gray-900 focus:border-blue-500 focus:outline-none text-sm"
|
|
||||||
placeholder="Passwort"
|
|
||||||
disabled={isPending}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(v => !v)}
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
|
||||||
className="absolute inset-y-0 right-0 px-3 flex items-center text-gray-500 hover:text-gray-800"
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 4.411m0 0L21 21" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<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 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{state?.error && (
|
|
||||||
<div className="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded-lg text-sm">
|
|
||||||
{state.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full py-2 px-4 bg-[#85B7D7] hover:bg-[#6a9fc5] text-black font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
|
||||||
>
|
|
||||||
{isPending ? 'Anmeldung läuft...' : 'Anmelden'}
|
|
||||||
</button>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="mt-8 flex justify-between items-center text-sm text-gray-600 px-4 ">
|
|
||||||
<div>
|
|
||||||
<a href="mailto:rxf@gmx.de" className="text-blue-600 hover:underline">
|
|
||||||
mailto:rxf@gmx.de
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
Version {version} - {buildDate}
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { logout } from '@/app/login/actions';
|
|
||||||
|
|
||||||
interface LogoutButtonProps {
|
|
||||||
className?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LogoutButton({ className, children }: LogoutButtonProps) {
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await logout();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className={className || "px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"}
|
|
||||||
>
|
|
||||||
{children || 'Abmelden'}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { startRegistration } from '@simplewebauthn/browser';
|
|
||||||
|
|
||||||
interface Passkey {
|
|
||||||
credentialId: string;
|
|
||||||
label: string;
|
|
||||||
createdAt: string | null;
|
|
||||||
lastUsedAt: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string | null): string {
|
|
||||||
if (!iso) return '—';
|
|
||||||
const d = new Date(iso);
|
|
||||||
if (isNaN(d.getTime())) return '—';
|
|
||||||
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Passkeys() {
|
|
||||||
const [passkeys, setPasskeys] = useState<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+18
-25
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import LogoutButton from '@/components/LogoutButton';
|
|
||||||
import packageJson from '@/package.json';
|
import packageJson from '@/package.json';
|
||||||
|
|
||||||
interface TabLayoutProps {
|
interface TabLayoutProps {
|
||||||
@@ -12,7 +11,6 @@ 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) {
|
||||||
@@ -32,29 +30,24 @@ export default function TabLayout({ children }: TabLayoutProps) {
|
|||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
|
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
<div className="flex justify-between items-end">
|
<div className="flex items-end">
|
||||||
<div className="flex">
|
{TABS.map(tab => {
|
||||||
{TABS.map(tab => {
|
const isActive = pathname === tab.href;
|
||||||
const isActive = pathname === tab.href;
|
return (
|
||||||
return (
|
<Link
|
||||||
<Link
|
key={tab.href}
|
||||||
key={tab.href}
|
href={tab.href}
|
||||||
href={tab.href}
|
className="px-6 py-2 text-sm font-semibold border-t-2 border-l-2 border-r-2 rounded-tl-lg rounded-tr-lg mr-1 transition-colors"
|
||||||
className="px-6 py-2 text-sm font-semibold border-t-2 border-l-2 border-r-2 rounded-tl-lg rounded-tr-lg mr-1 transition-colors"
|
style={
|
||||||
style={
|
isActive
|
||||||
isActive
|
? { backgroundColor: '#FFFFDD', color: '#000000', borderColor: '#000000', borderBottom: '2px solid #FFFFDD', marginBottom: '-2px', position: 'relative', zIndex: 10 }
|
||||||
? { backgroundColor: '#FFFFDD', color: '#000000', borderColor: '#000000', borderBottom: '2px solid #FFFFDD', marginBottom: '-2px', position: 'relative', zIndex: 10 }
|
: { backgroundColor: '#85B7D7', color: '#374151', borderColor: '#000000' }
|
||||||
: { backgroundColor: '#85B7D7', color: '#374151', borderColor: '#000000' }
|
}
|
||||||
}
|
>
|
||||||
>
|
{tab.label}
|
||||||
{tab.label}
|
</Link>
|
||||||
</Link>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="pb-1">
|
|
||||||
<LogoutButton className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white text-sm rounded-lg shadow-md" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content panel */}
|
{/* Content panel */}
|
||||||
|
|||||||
@@ -16,18 +16,3 @@ 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;
|
|
||||||
|
|||||||
@@ -18,12 +18,6 @@ services:
|
|||||||
- DB_USER=${DB_USER}
|
- DB_USER=${DB_USER}
|
||||||
- DB_PASS=${DB_PASS}
|
- DB_PASS=${DB_PASS}
|
||||||
- DB_NAME=${DB_NAME}
|
- DB_NAME=${DB_NAME}
|
||||||
- AUTH_USERS=${AUTH_USERS}
|
|
||||||
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,6 @@ services:
|
|||||||
- DB_USER=${DB_USER}
|
- DB_USER=${DB_USER}
|
||||||
- DB_PASS=${DB_PASS}
|
- DB_PASS=${DB_PASS}
|
||||||
- DB_NAME=${DB_NAME}
|
- DB_NAME=${DB_NAME}
|
||||||
- AUTH_USERS=${AUTH_USERS}
|
|
||||||
- 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
|
||||||
|
|||||||
-39
@@ -1,39 +0,0 @@
|
|||||||
import bcrypt from 'bcryptjs';
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUsers(): User[] {
|
|
||||||
const usersString = process.env.AUTH_USERS || '';
|
|
||||||
if (!usersString) {
|
|
||||||
console.warn('AUTH_USERS not configured in .env');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return usersString
|
|
||||||
.split(',')
|
|
||||||
.map((userPair) => {
|
|
||||||
const [username, password] = userPair.trim().split(':');
|
|
||||||
return { username: username?.trim(), password: password?.trim() };
|
|
||||||
})
|
|
||||||
.filter((user) => user.username && user.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyCredentials(username: string, password: string): Promise<boolean> {
|
|
||||||
const users = getUsers();
|
|
||||||
const user = users.find(u => u.username === username);
|
|
||||||
if (!user) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return bcrypt.compare(password, user.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Prüft, ob der Benutzername (noch) in AUTH_USERS hinterlegt ist. */
|
|
||||||
export function isKnownUser(username: string): boolean {
|
|
||||||
return getUsers().some(u => u.username === username);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isAuthEnabled(): boolean {
|
|
||||||
return !!process.env.AUTH_USERS;
|
|
||||||
}
|
|
||||||
-142
@@ -1,142 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
-102
@@ -1,102 +0,0 @@
|
|||||||
import { cookies } from 'next/headers';
|
|
||||||
import { SignJWT, jwtVerify } from 'jose';
|
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = 'auth_session';
|
|
||||||
const SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
||||||
|
|
||||||
const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production';
|
|
||||||
const key = new TextEncoder().encode(secretKey);
|
|
||||||
|
|
||||||
export interface SessionData {
|
|
||||||
username: string;
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt session data to JWT
|
|
||||||
*/
|
|
||||||
async function encrypt(payload: SessionData): Promise<string> {
|
|
||||||
return await new SignJWT(payload as any)
|
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
|
||||||
.setIssuedAt()
|
|
||||||
.setExpirationTime(new Date(payload.expiresAt))
|
|
||||||
.sign(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt JWT to session data
|
|
||||||
*/
|
|
||||||
async function decrypt(token: string): Promise<SessionData | null> {
|
|
||||||
try {
|
|
||||||
const { payload } = await jwtVerify(token, key, {
|
|
||||||
algorithms: ['HS256'],
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
username: payload.username as string,
|
|
||||||
isAuthenticated: payload.isAuthenticated as boolean,
|
|
||||||
expiresAt: payload.expiresAt as number,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new session
|
|
||||||
*/
|
|
||||||
export async function createSession(username: string): Promise<void> {
|
|
||||||
const expiresAt = Date.now() + SESSION_DURATION;
|
|
||||||
const session: SessionData = {
|
|
||||||
username,
|
|
||||||
isAuthenticated: true,
|
|
||||||
expiresAt,
|
|
||||||
};
|
|
||||||
|
|
||||||
const encryptedSession = await encrypt(session);
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
|
|
||||||
cookieStore.set(SESSION_COOKIE_NAME, encryptedSession, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
expires: expiresAt,
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current session
|
|
||||||
*/
|
|
||||||
export async function getSession(): Promise<SessionData | null> {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
const cookie = cookieStore.get(SESSION_COOKIE_NAME);
|
|
||||||
|
|
||||||
if (!cookie?.value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await decrypt(cookie.value);
|
|
||||||
|
|
||||||
if (!session || session.expiresAt < Date.now()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete session (logout)
|
|
||||||
*/
|
|
||||||
export async function deleteSession(): Promise<void> {
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
cookieStore.delete(SESSION_COOKIE_NAME);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify if user is authenticated
|
|
||||||
*/
|
|
||||||
export async function isAuthenticated(): Promise<boolean> {
|
|
||||||
const session = await getSession();
|
|
||||||
return session?.isAuthenticated ?? false;
|
|
||||||
}
|
|
||||||
-176
@@ -1,176 +0,0 @@
|
|||||||
import { cookies } from 'next/headers';
|
|
||||||
import {
|
|
||||||
generateRegistrationOptions,
|
|
||||||
verifyRegistrationResponse,
|
|
||||||
generateAuthenticationOptions,
|
|
||||||
verifyAuthenticationResponse,
|
|
||||||
} from '@simplewebauthn/server';
|
|
||||||
import type {
|
|
||||||
RegistrationResponseJSON,
|
|
||||||
AuthenticationResponseJSON,
|
|
||||||
AuthenticatorTransportFuture,
|
|
||||||
} from '@simplewebauthn/server';
|
|
||||||
import { isKnownUser } from './auth';
|
|
||||||
import { listPasskeys, getPasskey, insertPasskey, updatePasskeyUsage } from './passkeys';
|
|
||||||
|
|
||||||
const REG_CHALLENGE_COOKIE = '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 };
|
|
||||||
}
|
|
||||||
Generated
+2
-293
@@ -1,19 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "werte_next",
|
"name": "werte_next",
|
||||||
"version": "2.1.1",
|
"version": "2.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "werte_next",
|
"name": "werte_next",
|
||||||
"version": "2.1.1",
|
"version": "2.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
|
||||||
"@simplewebauthn/server": "^13.3.2",
|
|
||||||
"bcryptjs": "^3.0.3",
|
|
||||||
"highcharts": "^12.5.0",
|
"highcharts": "^12.5.0",
|
||||||
"highcharts-react-official": "^3.2.3",
|
"highcharts-react-official": "^3.2.3",
|
||||||
"jose": "^6.1.3",
|
|
||||||
"mysql2": "^3.17.4",
|
"mysql2": "^3.17.4",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -21,7 +17,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
@@ -462,12 +457,6 @@
|
|||||||
"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",
|
||||||
@@ -1036,12 +1025,6 @@
|
|||||||
"@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",
|
||||||
@@ -1247,174 +1230,6 @@
|
|||||||
"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",
|
||||||
@@ -1422,31 +1237,6 @@
|
|||||||
"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",
|
||||||
@@ -1738,13 +1528,6 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/bcryptjs": {
|
|
||||||
"version": "2.4.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
|
||||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -2596,20 +2379,6 @@
|
|||||||
"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",
|
||||||
@@ -2691,15 +2460,6 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bcryptjs": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"bin": {
|
|
||||||
"bcrypt": "bin/bcrypt"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
@@ -4762,15 +4522,6 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jose": {
|
|
||||||
"version": "6.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
|
|
||||||
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/panva"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -5800,24 +5551,6 @@
|
|||||||
"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",
|
||||||
@@ -5869,12 +5602,6 @@
|
|||||||
"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",
|
||||||
@@ -6636,24 +6363,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -9,12 +9,8 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
|
||||||
"@simplewebauthn/server": "^13.3.2",
|
|
||||||
"bcryptjs": "^3.0.3",
|
|
||||||
"highcharts": "^12.5.0",
|
"highcharts": "^12.5.0",
|
||||||
"highcharts-react-official": "^3.2.3",
|
"highcharts-react-official": "^3.2.3",
|
||||||
"jose": "^6.1.3",
|
|
||||||
"mysql2": "^3.17.4",
|
"mysql2": "^3.17.4",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -22,7 +18,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { jwtVerify } from 'jose';
|
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME = 'auth_session';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proxy to protect routes with authentication
|
|
||||||
* Reusable for other projects - just copy this file
|
|
||||||
*/
|
|
||||||
export async function proxy(request: NextRequest) {
|
|
||||||
const { pathname } = request.nextUrl;
|
|
||||||
|
|
||||||
// Check if authentication is enabled
|
|
||||||
const authEnabled = !!process.env.AUTH_USERS;
|
|
||||||
|
|
||||||
// If auth is not enabled, allow all requests
|
|
||||||
if (!authEnabled) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public paths that don't require authentication
|
|
||||||
// (Passkey-Anmeldung muss ohne Session erreichbar sein)
|
|
||||||
const publicPaths = ['/login', '/api/passkey/authenticate'];
|
|
||||||
const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
|
|
||||||
|
|
||||||
if (isPublicPath) {
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for session cookie
|
|
||||||
const sessionCookie = request.cookies.get(SESSION_COOKIE_NAME);
|
|
||||||
|
|
||||||
if (!sessionCookie) {
|
|
||||||
return NextResponse.redirect(new URL('/login', request.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify session token
|
|
||||||
try {
|
|
||||||
const secretKey = process.env.AUTH_SECRET || 'default-secret-change-in-production';
|
|
||||||
const key = new TextEncoder().encode(secretKey);
|
|
||||||
|
|
||||||
const { payload } = await jwtVerify(sessionCookie.value, key, {
|
|
||||||
algorithms: ['HS256'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if session is expired
|
|
||||||
if (payload.expiresAt && (payload.expiresAt as number) < Date.now()) {
|
|
||||||
const response = NextResponse.redirect(new URL('/login', request.url));
|
|
||||||
response.cookies.delete(SESSION_COOKIE_NAME);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
|
||||||
} catch (error) {
|
|
||||||
// Invalid token - redirect to login
|
|
||||||
const response = NextResponse.redirect(new URL('/login', request.url));
|
|
||||||
response.cookies.delete(SESSION_COOKIE_NAME);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
/*
|
|
||||||
* Match all request paths except for the ones starting with:
|
|
||||||
* - _next/static (static files)
|
|
||||||
* - _next/image (image optimization files)
|
|
||||||
* - favicon.ico (favicon file)
|
|
||||||
* - public folder
|
|
||||||
*/
|
|
||||||
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user