diff --git a/prisma/migrations/20260910103056_fundament/migration.sql b/prisma/migrations/20260910104400_fundament/migration.sql similarity index 82% rename from prisma/migrations/20260910103056_fundament/migration.sql rename to prisma/migrations/20260910104400_fundament/migration.sql index 59096f8..5db03f3 100644 --- a/prisma/migrations/20260910103056_fundament/migration.sql +++ b/prisma/migrations/20260910104400_fundament/migration.sql @@ -36,23 +36,6 @@ CREATE TABLE "konto" ( CONSTRAINT "konto_pkey" PRIMARY KEY ("id") ); --- CreateTable -CREATE TABLE "sitzung" ( - "id" TEXT NOT NULL, - "sessionToken" TEXT NOT NULL, - "benutzerId" TEXT NOT NULL, - "expires" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "sitzung_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "verifizierungs_token" ( - "identifier" TEXT NOT NULL, - "token" TEXT NOT NULL, - "expires" TIMESTAMP(3) NOT NULL -); - -- CreateTable CREATE TABLE "passkey" ( "id" TEXT NOT NULL, @@ -130,18 +113,6 @@ CREATE INDEX "konto_benutzerId_idx" ON "konto"("benutzerId"); -- CreateIndex CREATE UNIQUE INDEX "konto_provider_providerAccountId_key" ON "konto"("provider", "providerAccountId"); --- CreateIndex -CREATE UNIQUE INDEX "sitzung_sessionToken_key" ON "sitzung"("sessionToken"); - --- CreateIndex -CREATE INDEX "sitzung_benutzerId_idx" ON "sitzung"("benutzerId"); - --- CreateIndex -CREATE UNIQUE INDEX "verifizierungs_token_token_key" ON "verifizierungs_token"("token"); - --- CreateIndex -CREATE UNIQUE INDEX "verifizierungs_token_identifier_token_key" ON "verifizierungs_token"("identifier", "token"); - -- CreateIndex CREATE UNIQUE INDEX "passkey_credentialId_key" ON "passkey"("credentialId"); @@ -169,9 +140,6 @@ CREATE INDEX "audit_eintrag_benutzerId_idx" ON "audit_eintrag"("benutzerId"); -- AddForeignKey ALTER TABLE "konto" ADD CONSTRAINT "konto_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; --- AddForeignKey -ALTER TABLE "sitzung" ADD CONSTRAINT "sitzung_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; - -- AddForeignKey ALTER TABLE "passkey" ADD CONSTRAINT "passkey_benutzerId_fkey" FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 313b933..da39cac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,7 +32,6 @@ model Benutzer { aktualisiertAm DateTime @updatedAt konten Konto[] - sitzungen Sitzung[] passkeys Passkey[] audit AuditEintrag[] @@ -62,27 +61,6 @@ model Konto { @@map("konto") } -model Sitzung { - id String @id @default(cuid()) - sessionToken String @unique - benutzerId String - expires DateTime - - benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade) - - @@index([benutzerId]) - @@map("sitzung") -} - -model VerifizierungsToken { - identifier String - token String @unique - expires DateTime - - @@unique([identifier, token]) - @@map("verifizierungs_token") -} - /// Ein registrierter Passkey (WebAuthn) eines Benutzers. model Passkey { id String @id @default(cuid()) diff --git a/src/app/anmelden/anmelde-formular.tsx b/src/app/anmelden/anmelde-formular.tsx new file mode 100644 index 0000000..90f4014 --- /dev/null +++ b/src/app/anmelden/anmelde-formular.tsx @@ -0,0 +1,193 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { startAuthentication } from '@simplewebauthn/browser' +import { Fingerprint, KeyRound, Loader2 } from 'lucide-react' +import { signIn } from 'next-auth/react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Card, CardInhalt } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' + +type Eigenschaften = { + ziel: string + oidcAktiv: boolean + oidcName: string + passwortAktiv: boolean + fehlerAusUrl?: string | undefined +} + +/** + * Bewusst dieselbe Meldung für „gibt es nicht", „falsches Passwort" und + * „gesperrt". Sonst lässt sich abfragen, wer hier ein Konto hat. + */ +const ABGELEHNT = 'Anmeldung nicht möglich. Bitte Angaben prüfen.' + +export function AnmeldeFormular({ + ziel, + oidcAktiv, + oidcName, + passwortAktiv, + fehlerAusUrl, +}: Eigenschaften) { + const router = useRouter() + const [email, setEmail] = useState('') + const [passwort, setPasswort] = useState('') + const [laeuft, setLaeuft] = useState(null) + + useEffect(() => { + if (fehlerAusUrl) toast.error(ABGELEHNT) + }, [fehlerAusUrl]) + + async function mitOidc() { + setLaeuft('oidc') + await signIn('oidc', { callbackUrl: ziel }) + } + + async function mitPasskey(ereignis: React.FormEvent) { + ereignis.preventDefault() + if (!email.trim()) { + toast.error('Bitte zuerst die E-Mail-Adresse eintragen.') + return + } + + setLaeuft('passkey') + try { + const antwortOptionen = await fetch('/api/passkey/anmeldung', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }) + if (!antwortOptionen.ok) { + toast.error(ABGELEHNT) + return + } + + const optionen = (await antwortOptionen.json()) as Parameters< + typeof startAuthentication + >[0]['optionsJSON'] + + const antwort = await startAuthentication({ optionsJSON: optionen }) + + const ergebnis = await signIn('passkey', { + email, + antwort: JSON.stringify(antwort), + redirect: false, + }) + + if (ergebnis?.error) { + toast.error(ABGELEHNT) + return + } + router.push(ziel) + router.refresh() + } catch (fehler) { + // Bricht der Benutzer den Systemdialog ab, ist das kein Fehler. + if (fehler instanceof Error && fehler.name === 'NotAllowedError') return + toast.error('Die Anmeldung mit Passkey hat nicht geklappt.') + } finally { + setLaeuft(null) + } + } + + async function mitPasswort(ereignis: React.FormEvent) { + ereignis.preventDefault() + setLaeuft('passwort') + try { + const ergebnis = await signIn('passwort', { + email, + passwort, + redirect: false, + }) + if (ergebnis?.error) { + toast.error(ABGELEHNT) + return + } + router.push(ziel) + router.refresh() + } finally { + setLaeuft(null) + } + } + + const gesperrt = laeuft !== null + + return ( + + + {oidcAktiv && ( + <> + +
+ + oder + +
+ + )} + +
void mitPasskey(e)}> +
+ + setEmail(e.target.value)} + disabled={gesperrt} + /> +
+ + +
+ + {passwortAktiv && ( +
void mitPasswort(e)}> +
+ + setPasswort(e.target.value)} + disabled={gesperrt} + /> +
+ +
+ )} +
+
+ ) +} diff --git a/src/app/anmelden/page.tsx b/src/app/anmelden/page.tsx new file mode 100644 index 0000000..3d2683b --- /dev/null +++ b/src/app/anmelden/page.tsx @@ -0,0 +1,56 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { AnmeldeFormular } from '@/app/anmelden/anmelde-formular' +import { INSTANZ_NAME } from '@/lib/instanz' +import { versionAnzeige } from '@/lib/version' +import { angemeldet } from '@/server/berechtigung' +import { aktuelleInstanz } from '@/server/instanz' +import { umgebung } from '@/server/umgebung' + +export const metadata: Metadata = { title: 'Anmelden' } + +/** Nur Pfade innerhalb der App sind als Ziel erlaubt – keine fremden Adressen. */ +function sicheresZiel(weiter: string | undefined): string { + if (!weiter) return '/' + if (!weiter.startsWith('/') || weiter.startsWith('//')) return '/' + return weiter +} + +export default async function AnmeldeSeite({ + searchParams, +}: { + searchParams: Promise<{ weiter?: string; error?: string }> +}) { + const parameter = await searchParams + const ziel = sicheresZiel(parameter.weiter) + + if (await angemeldet()) { + redirect(ziel) + } + + const einst = umgebung() + const instanz = aktuelleInstanz() + + return ( +
+
+
+

Planer

+

+ {INSTANZ_NAME[instanz]} +

+
+ + +
+ +

{versionAnzeige()}

+
+ ) +} diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..7ed0211 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from '@/server/auth' + +export const { GET, POST } = handlers diff --git a/src/app/api/passkey/anmeldung/route.ts b/src/app/api/passkey/anmeldung/route.ts new file mode 100644 index 0000000..cbcc186 --- /dev/null +++ b/src/app/api/passkey/anmeldung/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { anmeldeOptionen } from '@/server/auth/passkey' + +export const dynamic = 'force-dynamic' + +const eingabe = z.object({ email: z.email() }) + +/** + * Liefert die WebAuthn-Optionen fuer die Anmeldung. + * + * Antwortet fuer unbekannte Adressen genauso wie fuer bekannte – sonst + * liesse sich hier abfragen, wer ein Konto hat. + */ +export async function POST(anfrage: Request) { + let inhalt: unknown + try { + inhalt = await anfrage.json() + } catch { + return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 }) + } + + const gelesen = eingabe.safeParse(inhalt) + if (!gelesen.success) { + return NextResponse.json( + { fehler: 'Bitte eine gültige E-Mail-Adresse angeben.' }, + { status: 400 }, + ) + } + + const optionen = await anmeldeOptionen(gelesen.data.email) + return NextResponse.json(optionen, { headers: { 'Cache-Control': 'no-store' } }) +} diff --git a/src/app/api/passkey/registrierung/route.ts b/src/app/api/passkey/registrierung/route.ts new file mode 100644 index 0000000..392167f --- /dev/null +++ b/src/app/api/passkey/registrierung/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import type { RegistrationResponseJSON } from '@simplewebauthn/server' +import { auditSchreiben } from '@/server/audit' +import { registrierungPruefen, registrierungsOptionen } from '@/server/auth/passkey' +import { verlangeAnmeldungFuerApi } from '@/server/berechtigung' + +export const dynamic = 'force-dynamic' + +const abschluss = z.object({ + bezeichnung: z.string().trim().min(1).max(60), + antwort: z.looseObject({ id: z.string().min(1) }), +}) + +/** Schritt 1: Optionen fuer einen neuen Passkey des angemeldeten Benutzers. */ +export async function GET() { + const benutzer = await verlangeAnmeldungFuerApi() + if (!benutzer) { + return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 }) + } + + const optionen = await registrierungsOptionen(benutzer.id) + return NextResponse.json(optionen, { headers: { 'Cache-Control': 'no-store' } }) +} + +/** Schritt 2: Antwort des Browsers pruefen und den Passkey speichern. */ +export async function POST(anfrage: Request) { + const benutzer = await verlangeAnmeldungFuerApi() + if (!benutzer) { + return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 }) + } + + let inhalt: unknown + try { + inhalt = await anfrage.json() + } catch { + return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 }) + } + + const gelesen = abschluss.safeParse(inhalt) + if (!gelesen.success) { + return NextResponse.json( + { fehler: 'Bitte einen Namen für das Gerät angeben.' }, + { status: 400 }, + ) + } + + try { + const ergebnis = await registrierungPruefen( + benutzer.id, + gelesen.data.antwort as unknown as RegistrationResponseJSON, + gelesen.data.bezeichnung, + ) + + await auditSchreiben({ + akteur: { id: benutzer.id, email: benutzer.email }, + aktion: 'passkey.angelegt', + objektTyp: 'Passkey', + objektId: ergebnis.passkeyId, + details: { bezeichnung: ergebnis.bezeichnung }, + }) + + return NextResponse.json({ ok: true, bezeichnung: ergebnis.bezeichnung }) + } catch (fehler) { + const meldung = + fehler instanceof Error + ? fehler.message + : 'Der Passkey konnte nicht gespeichert werden.' + return NextResponse.json({ fehler: meldung }, { status: 400 }) + } +} diff --git a/src/lib/passwort.test.ts b/src/lib/passwort.test.ts new file mode 100644 index 0000000..28596be --- /dev/null +++ b/src/lib/passwort.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + PASSWORT_HOECHSTLAENGE, + PASSWORT_MINDESTLAENGE, + passwortIstGut, + passwortMaengel, +} from './passwort' + +describe('passwortMaengel', () => { + it('nimmt ein ausreichend langes Passwort an', () => { + expect(passwortMaengel('richtiges pferd batterie klammer')).toEqual([]) + }) + + it('bemaengelt zu kurze Passwoerter', () => { + expect(passwortMaengel('kurz')).toContain( + `Mindestens ${PASSWORT_MINDESTLAENGE} Zeichen.`, + ) + }) + + it('setzt die Grenze bei genau der Mindestlaenge', () => { + expect( + passwortMaengel('a'.repeat(PASSWORT_MINDESTLAENGE - 1)).length, + ).toBeGreaterThan(0) + // Genau die Mindestlaenge, aber ein einziges wiederholtes Zeichen. + expect(passwortMaengel('a'.repeat(PASSWORT_MINDESTLAENGE))).toEqual([ + 'Darf nicht aus einem einzigen wiederholten Zeichen bestehen.', + ]) + expect(passwortMaengel('abcdefghijkl')).toEqual([]) + }) + + it('weist ueberlange Eingaben ab, damit das Hashen begrenzt bleibt', () => { + expect(passwortMaengel('a'.repeat(PASSWORT_HOECHSTLAENGE + 1))).toContain( + `Höchstens ${PASSWORT_HOECHSTLAENGE} Zeichen.`, + ) + }) + + it('weist reine Leerzeichen ab', () => { + expect(passwortMaengel(' '.repeat(20))).toContain( + 'Darf nicht nur aus Leerzeichen bestehen.', + ) + }) + + it('weist ein einzelnes wiederholtes Zeichen ab', () => { + expect(passwortMaengel('xxxxxxxxxxxxxxxx')).toContain( + 'Darf nicht aus einem einzigen wiederholten Zeichen bestehen.', + ) + }) + + it('meldet mehrere Maengel gleichzeitig', () => { + expect(passwortMaengel('aaa').length).toBe(2) + }) +}) + +describe('passwortIstGut', () => { + it('fasst das Ergebnis zusammen', () => { + expect(passwortIstGut('richtiges pferd batterie')).toBe(true) + expect(passwortIstGut('kurz')).toBe(false) + }) +}) diff --git a/src/lib/passwort.ts b/src/lib/passwort.ts new file mode 100644 index 0000000..67ac1cc --- /dev/null +++ b/src/lib/passwort.ts @@ -0,0 +1,37 @@ +/** + * Regeln fuer Passwoerter. Reine Logik, damit sie in Formular und Server + * Action dieselbe Antwort gibt. + */ + +export const PASSWORT_MINDESTLAENGE = 12 +export const PASSWORT_HOECHSTLAENGE = 200 + +/** + * Prueft ein Passwort und gibt alle Verstoesse zurueck. Eine leere Liste + * heisst: in Ordnung. + * + * Bewusst keine Pflicht zu Sonderzeichen – Laenge traegt mehr zur Sicherheit + * bei als erzwungene Zeichenklassen, die zu "Passwort1!" fuehren. + */ +export function passwortMaengel(passwort: string): string[] { + const maengel: string[] = [] + + if (passwort.length < PASSWORT_MINDESTLAENGE) { + maengel.push(`Mindestens ${PASSWORT_MINDESTLAENGE} Zeichen.`) + } + if (passwort.length > PASSWORT_HOECHSTLAENGE) { + maengel.push(`Höchstens ${PASSWORT_HOECHSTLAENGE} Zeichen.`) + } + if (passwort.trim().length === 0) { + maengel.push('Darf nicht nur aus Leerzeichen bestehen.') + } + if (/^(.)\1*$/.test(passwort) && passwort.length > 0) { + maengel.push('Darf nicht aus einem einzigen wiederholten Zeichen bestehen.') + } + + return maengel +} + +export function passwortIstGut(passwort: string): boolean { + return passwortMaengel(passwort).length === 0 +} diff --git a/src/server/auth/adapter.ts b/src/server/auth/adapter.ts new file mode 100644 index 0000000..62317e5 --- /dev/null +++ b/src/server/auth/adapter.ts @@ -0,0 +1,123 @@ +import 'server-only' +import type { Adapter, AdapterAccount, AdapterUser } from 'next-auth/adapters' +import type { PrismaClient } from '@/generated/prisma/client' + +/** + * Adapter fuer Auth.js auf unsere deutschen Modellnamen. + * + * Der mitgelieferte @auth/prisma-adapter greift fest auf prisma.user, + * prisma.account und prisma.session zu. Unsere Modelle heissen Benutzer und + * Konto, deshalb dieser schmale Zwischenschritt. + * + * Sitzungen fuehrt Auth.js hier als JWT – das verlangt der Credentials-Provider. + * Die Sitzungsmethoden des Adapters bleiben daher bewusst unbesetzt. + */ + +type BenutzerZeile = { + id: string + email: string + name: string | null + bildUrl: string | null + emailGeprueft: Date | null +} + +const AUSWAHL = { + id: true, + email: true, + name: true, + bildUrl: true, + emailGeprueft: true, +} as const + +function alsAdapterBenutzer(zeile: BenutzerZeile): AdapterUser { + return { + id: zeile.id, + email: zeile.email, + name: zeile.name, + image: zeile.bildUrl, + emailVerified: zeile.emailGeprueft, + } +} + +export function prismaAdapter(db: PrismaClient): Adapter { + return { + async createUser(daten) { + // Neue Benutzer entstehen ueber OIDC. Die Rolle bleibt der Standard; + // hochgestuft wird nur von Hand in der Verwaltung. + const zeile = await db.benutzer.create({ + data: { + email: daten.email, + name: daten.name ?? null, + bildUrl: daten.image ?? null, + emailGeprueft: daten.emailVerified ?? null, + }, + select: AUSWAHL, + }) + return alsAdapterBenutzer(zeile) + }, + + async getUser(id) { + const zeile = await db.benutzer.findUnique({ where: { id }, select: AUSWAHL }) + return zeile ? alsAdapterBenutzer(zeile) : null + }, + + async getUserByEmail(email) { + const zeile = await db.benutzer.findUnique({ where: { email }, select: AUSWAHL }) + return zeile ? alsAdapterBenutzer(zeile) : null + }, + + async getUserByAccount({ provider, providerAccountId }) { + const konto = await db.konto.findUnique({ + where: { provider_providerAccountId: { provider, providerAccountId } }, + select: { benutzer: { select: AUSWAHL } }, + }) + return konto ? alsAdapterBenutzer(konto.benutzer) : null + }, + + async updateUser(daten) { + if (!daten.id) throw new Error('updateUser ohne id aufgerufen.') + const zeile = await db.benutzer.update({ + where: { id: daten.id }, + data: { + ...(daten.email === undefined ? {} : { email: daten.email }), + ...(daten.name === undefined ? {} : { name: daten.name }), + ...(daten.image === undefined ? {} : { bildUrl: daten.image }), + ...(daten.emailVerified === undefined + ? {} + : { emailGeprueft: daten.emailVerified }), + }, + select: AUSWAHL, + }) + return alsAdapterBenutzer(zeile) + }, + + async deleteUser(id) { + await db.benutzer.delete({ where: { id } }) + }, + + async linkAccount(konto: AdapterAccount) { + await db.konto.create({ + data: { + benutzerId: konto.userId, + type: konto.type, + provider: konto.provider, + providerAccountId: konto.providerAccountId, + refresh_token: konto.refresh_token ?? null, + access_token: konto.access_token ?? null, + expires_at: konto.expires_at ?? null, + token_type: konto.token_type ?? null, + scope: konto.scope ?? null, + id_token: konto.id_token ?? null, + session_state: + typeof konto.session_state === 'string' ? konto.session_state : null, + }, + }) + }, + + async unlinkAccount({ provider, providerAccountId }) { + await db.konto.delete({ + where: { provider_providerAccountId: { provider, providerAccountId } }, + }) + }, + } +} diff --git a/src/server/auth/config.ts b/src/server/auth/config.ts new file mode 100644 index 0000000..8a1a3b8 --- /dev/null +++ b/src/server/auth/config.ts @@ -0,0 +1,171 @@ +import 'server-only' +import type { NextAuthConfig } from 'next-auth' +import Credentials from 'next-auth/providers/credentials' +import { z } from 'zod' +import type { Rolle } from '@/generated/prisma/enums' +import { db } from '@/server/db' +import { prismaAdapter } from '@/server/auth/adapter' +import { passwortStimmt } from '@/server/auth/passwort' +import { passkeyAnmeldungPruefen } from '@/server/auth/passkey' +import { umgebung } from '@/server/umgebung' + +const zugangsdaten = z.object({ + email: z.email(), + passwort: z.string().min(1).max(200), +}) + +const passkeyEingabe = z.object({ + email: z.email(), + antwort: z.string().min(1).max(20_000), +}) + +/** Anzeigename fuer den OIDC-Knopf, mit brauchbarem Rueckfallwert. */ +function oidcName(): string { + return umgebung().OIDC_NAME || 'Unternehmensanmeldung' +} + +function providerZusammenstellen(): NextAuthConfig['providers'] { + const einst = umgebung() + const provider: NextAuthConfig['providers'] = [] + + if (einst.oidcAktiv) { + provider.push({ + id: 'oidc', + name: oidcName(), + type: 'oidc', + issuer: einst.OIDC_ISSUER, + clientId: einst.OIDC_CLIENT_ID, + clientSecret: einst.OIDC_CLIENT_SECRET, + // Je Instanz gibt es genau einen vertrauenswuerdigen Anbieter, der die + // E-Mail-Adresse selbst prueft. Deshalb darf ein bereits angelegter + // Benutzer beim ersten OIDC-Login mit derselben Adresse verknuepft + // werden, statt an OAuthAccountNotLinked zu scheitern. + allowDangerousEmailAccountLinking: true, + }) + } + + // Passkeys laufen ueber SimpleWebAuthn, nicht ueber den experimentellen + // WebAuthn-Provider von Auth.js. + provider.push( + Credentials({ + id: 'passkey', + name: 'Passkey', + credentials: { + email: { label: 'E-Mail', type: 'email' }, + antwort: { label: 'Antwort', type: 'text' }, + }, + authorize: async (eingabe) => { + const gelesen = passkeyEingabe.safeParse(eingabe) + if (!gelesen.success) return null + return passkeyAnmeldungPruefen(gelesen.data.email, gelesen.data.antwort) + }, + }), + ) + + if (einst.ANMELDUNG_PASSWORT) { + provider.push( + Credentials({ + id: 'passwort', + name: 'E-Mail und Passwort', + credentials: { + email: { label: 'E-Mail', type: 'email' }, + passwort: { label: 'Passwort', type: 'password' }, + }, + authorize: async (eingabe) => { + const gelesen = zugangsdaten.safeParse(eingabe) + if (!gelesen.success) return null + + const benutzer = await db.benutzer.findUnique({ + where: { email: gelesen.data.email.toLowerCase() }, + select: { + id: true, + email: true, + name: true, + bildUrl: true, + aktiv: true, + passwortHash: true, + }, + }) + + const stimmt = await passwortStimmt( + gelesen.data.passwort, + benutzer?.passwortHash ?? null, + ) + // Gleiche Antwort fuer "gibt es nicht", "falsches Passwort" und + // "gesperrt" – sonst laesst sich abfragen, wer ein Konto hat. + if (!benutzer || !benutzer.aktiv || !stimmt) return null + + return { + id: benutzer.id, + email: benutzer.email, + name: benutzer.name, + image: benutzer.bildUrl, + } + }, + }), + ) + } + + return provider +} + +export function authKonfiguration(): NextAuthConfig { + return { + adapter: prismaAdapter(db), + // Credentials-Provider verlangen JWT. Damit eine Sperre trotzdem sofort + // wirkt, wird die Rolle bei jeder Anfrage frisch aus der Datenbank + // gelesen (siehe session-Callback). + session: { strategy: 'jwt', maxAge: 60 * 60 * 24 * 30 }, + trustHost: umgebung().AUTH_TRUST_HOST, + pages: { + signIn: '/anmelden', + error: '/anmelden', + }, + callbacks: { + signIn: async ({ user }) => { + if (!user.email) return false + const benutzer = await db.benutzer.findUnique({ + where: { email: user.email.toLowerCase() }, + select: { aktiv: true }, + }) + // Unbekannte Adressen legt der Adapter ueber OIDC neu an; gesperrte + // Konten kommen nicht durch. + return benutzer ? benutzer.aktiv : true + }, + + jwt: ({ token, user }) => { + if (user?.id) token.sub = user.id + return token + }, + + session: async ({ session, token }) => { + if (!token.sub) return session + const benutzer = await db.benutzer.findUnique({ + where: { id: token.sub }, + select: { + id: true, + email: true, + name: true, + bildUrl: true, + rolle: true, + aktiv: true, + }, + }) + if (!benutzer?.aktiv) { + // Gesperrt oder geloescht: Sitzung sofort entwerten. + return { ...session, user: undefined } as unknown as typeof session + } + session.user = { + ...session.user, + id: benutzer.id, + email: benutzer.email, + name: benutzer.name, + image: benutzer.bildUrl, + rolle: benutzer.rolle satisfies Rolle, + } + return session + }, + }, + providers: providerZusammenstellen(), + } +} diff --git a/src/server/auth/index.ts b/src/server/auth/index.ts new file mode 100644 index 0000000..b36beeb --- /dev/null +++ b/src/server/auth/index.ts @@ -0,0 +1,10 @@ +import 'server-only' +import NextAuth from 'next-auth' +import { authKonfiguration } from '@/server/auth/config' + +/** + * Einstiegspunkt fuer Auth.js. Die Konfiguration wird erst hier ausgewertet, + * damit fehlende Umgebungsvariablen beim Start auffallen und nicht beim + * Einlesen des Moduls. + */ +export const { handlers, auth, signIn, signOut } = NextAuth(authKonfiguration()) diff --git a/src/server/auth/passkey.ts b/src/server/auth/passkey.ts new file mode 100644 index 0000000..ffe69c4 --- /dev/null +++ b/src/server/auth/passkey.ts @@ -0,0 +1,281 @@ +import 'server-only' +import { + generateAuthenticationOptions, + generateRegistrationOptions, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from '@simplewebauthn/server' +import type { + AuthenticationResponseJSON, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, +} from '@simplewebauthn/server' +import { db } from '@/server/db' +import { umgebung } from '@/server/umgebung' + +/** + * Passkeys mit SimpleWebAuthn. + * + * Die Herausforderung wird serverseitig abgelegt und nach einem Versuch + * geloescht – gleich ob er geglueckt ist. Ohne das liesse sich eine Antwort + * beliebig oft einreichen. + */ + +const CHALLENGE_GUELTIG_MINUTEN = 5 + +type Zweck = 'anmeldung' | 'registrierung' + +export type AngemeldeterBenutzer = { + id: string + email: string + name: string | null + image: string | null +} + +function frist(jetzt: Date): Date { + return new Date(jetzt.getTime() + CHALLENGE_GUELTIG_MINUTEN * 60_000) +} + +async function challengeMerken( + kennung: string, + zweck: Zweck, + challenge: string, + jetzt: Date, +): Promise { + // Aeltere Herausforderungen desselben Zwecks verfallen sofort, damit nie + // mehrere gleichzeitig gueltig sind. + await db.webauthnChallenge.deleteMany({ where: { kennung, zweck } }) + await db.webauthnChallenge.create({ + data: { kennung, zweck, challenge, gueltigBis: frist(jetzt) }, + }) +} + +/** Holt die Herausforderung und verbraucht sie im selben Zug. */ +async function challengeVerbrauchen( + kennung: string, + zweck: Zweck, + jetzt: Date, +): Promise { + const eintrag = await db.webauthnChallenge.findFirst({ + where: { kennung, zweck }, + orderBy: { gueltigBis: 'desc' }, + }) + if (!eintrag) return null + + await db.webauthnChallenge.deleteMany({ where: { kennung, zweck } }) + if (eintrag.gueltigBis.getTime() < jetzt.getTime()) return null + return eintrag.challenge +} + +/** Abgelaufene Herausforderungen aufraeumen; laeuft spaeter im Hintergrundjob mit. */ +export async function abgelaufeneChallengesEntfernen(jetzt: Date): Promise { + const ergebnis = await db.webauthnChallenge.deleteMany({ + where: { gueltigBis: { lt: jetzt } }, + }) + return ergebnis.count +} + +function alsErlaubteZugangsdaten( + passkeys: { credentialId: string; transports: string | null }[], +): { id: string; transports?: string[] }[] { + return passkeys.map((p) => + p.transports + ? { id: p.credentialId, transports: p.transports.split(',') } + : { id: p.credentialId }, + ) +} + +// --- Registrierung --------------------------------------------------------- + +export async function registrierungsOptionen( + benutzerId: string, + jetzt: Date = new Date(), +): Promise { + const einst = umgebung() + const benutzer = await db.benutzer.findUnique({ + where: { id: benutzerId }, + select: { + id: true, + email: true, + name: true, + passkeys: { select: { credentialId: true, transports: true } }, + }, + }) + if (!benutzer) throw new Error('Benutzer nicht gefunden.') + + const optionen = await generateRegistrationOptions({ + rpName: einst.WEBAUTHN_RP_NAME, + rpID: einst.WEBAUTHN_RP_ID, + userName: benutzer.email, + userDisplayName: benutzer.name ?? benutzer.email, + userID: new TextEncoder().encode(benutzer.id), + attestationType: 'none', + // Bereits hinterlegte Passkeys ausschliessen, damit dasselbe Geraet nicht + // zweimal registriert wird. + excludeCredentials: alsErlaubteZugangsdaten(benutzer.passkeys), + authenticatorSelection: { + residentKey: 'preferred', + userVerification: 'preferred', + }, + }) + + await challengeMerken(benutzer.id, 'registrierung', optionen.challenge, jetzt) + return optionen +} + +export async function registrierungPruefen( + benutzerId: string, + antwort: RegistrationResponseJSON, + bezeichnung: string, + jetzt: Date = new Date(), +): Promise<{ passkeyId: string; bezeichnung: string }> { + const einst = umgebung() + const erwartet = await challengeVerbrauchen(benutzerId, 'registrierung', jetzt) + if (!erwartet) { + throw new Error('Die Anfrage ist abgelaufen. Bitte noch einmal beginnen.') + } + + const ergebnis = await verifyRegistrationResponse({ + response: antwort, + expectedChallenge: erwartet, + expectedOrigin: einst.WEBAUTHN_ORIGIN, + expectedRPID: einst.WEBAUTHN_RP_ID, + requireUserVerification: false, + }) + + if (!ergebnis.verified || !ergebnis.registrationInfo) { + throw new Error('Der Passkey konnte nicht geprüft werden.') + } + + const { credential, credentialDeviceType, credentialBackedUp } = + ergebnis.registrationInfo + const name = bezeichnung.trim() || 'Unbenanntes Gerät' + + const gespeichert = await db.passkey.create({ + data: { + benutzerId, + credentialId: credential.id, + publicKey: Buffer.from(credential.publicKey), + counter: BigInt(credential.counter), + transports: credential.transports?.join(',') ?? null, + geraetetyp: credentialDeviceType, + gesichert: credentialBackedUp, + bezeichnung: name, + }, + select: { id: true }, + }) + + return { passkeyId: gespeichert.id, bezeichnung: name } +} + +// --- Anmeldung ------------------------------------------------------------- + +export async function anmeldeOptionen( + email: string, + jetzt: Date = new Date(), +): Promise { + const einst = umgebung() + const kennung = email.trim().toLowerCase() + + const benutzer = await db.benutzer.findUnique({ + where: { email: kennung }, + select: { + aktiv: true, + passkeys: { select: { credentialId: true, transports: true } }, + }, + }) + + // Auch fuer unbekannte Adressen werden Optionen erzeugt. Sonst liesse sich + // hier abfragen, wer ein Konto mit Passkey hat. + const erlaubte = + benutzer?.aktiv === true ? alsErlaubteZugangsdaten(benutzer.passkeys) : [] + + const optionen = await generateAuthenticationOptions({ + rpID: einst.WEBAUTHN_RP_ID, + allowCredentials: erlaubte, + userVerification: 'preferred', + }) + + await challengeMerken(kennung, 'anmeldung', optionen.challenge, jetzt) + return optionen +} + +/** + * Prueft die Antwort des Browsers und gibt den Benutzer zurueck, wenn alles + * stimmt. Wird vom Credentials-Provider "passkey" aufgerufen. + */ +export async function passkeyAnmeldungPruefen( + email: string, + antwortRoh: string, + jetzt: Date = new Date(), +): Promise { + const einst = umgebung() + const kennung = email.trim().toLowerCase() + + const erwartet = await challengeVerbrauchen(kennung, 'anmeldung', jetzt) + if (!erwartet) return null + + let antwort: AuthenticationResponseJSON + try { + antwort = JSON.parse(antwortRoh) as AuthenticationResponseJSON + } catch { + return null + } + if (typeof antwort.id !== 'string') return null + + const passkey = await db.passkey.findUnique({ + where: { credentialId: antwort.id }, + select: { + id: true, + publicKey: true, + counter: true, + transports: true, + benutzer: { + select: { id: true, email: true, name: true, bildUrl: true, aktiv: true }, + }, + }, + }) + + // Der Passkey muss zu genau der Adresse gehoeren, fuer die die + // Herausforderung ausgestellt wurde. + if (!passkey || !passkey.benutzer.aktiv || passkey.benutzer.email !== kennung) { + return null + } + + let ergebnis + try { + ergebnis = await verifyAuthenticationResponse({ + response: antwort, + expectedChallenge: erwartet, + expectedOrigin: einst.WEBAUTHN_ORIGIN, + expectedRPID: einst.WEBAUTHN_RP_ID, + requireUserVerification: false, + credential: { + id: antwort.id, + publicKey: new Uint8Array(passkey.publicKey), + counter: Number(passkey.counter), + ...(passkey.transports ? { transports: passkey.transports.split(',') } : {}), + }, + }) + } catch { + return null + } + + if (!ergebnis.verified) return null + + await db.passkey.update({ + where: { id: passkey.id }, + data: { + counter: BigInt(ergebnis.authenticationInfo.newCounter), + zuletztBenutzt: jetzt, + }, + }) + + return { + id: passkey.benutzer.id, + email: passkey.benutzer.email, + name: passkey.benutzer.name, + image: passkey.benutzer.bildUrl, + } +} diff --git a/src/server/auth/passwort.ts b/src/server/auth/passwort.ts new file mode 100644 index 0000000..abd7b6b --- /dev/null +++ b/src/server/auth/passwort.ts @@ -0,0 +1,30 @@ +import 'server-only' +import bcrypt from 'bcryptjs' +import { PASSWORT_HOECHSTLAENGE } from '@/lib/passwort' + +const RUNDEN = 12 + +export async function passwortHashen(passwort: string): Promise { + if (passwort.length > PASSWORT_HOECHSTLAENGE) { + throw new Error('Passwort ist zu lang.') + } + return bcrypt.hash(passwort, RUNDEN) +} + +/** + * Vergleicht Passwort und Hash. Ist kein Hash hinterlegt, wird trotzdem + * gerechnet: sonst verraet die Antwortzeit, ob es das Konto gibt. + */ +export async function passwortStimmt( + passwort: string, + hash: string | null, +): Promise { + if (!hash) { + await bcrypt.compare( + passwort, + '$2b$12$ungueltigerHashAlsPlatzhalterXXXXXXXXXXXXXXXXXXXXXXXXXXXX', + ) + return false + } + return bcrypt.compare(passwort, hash) +} diff --git a/src/server/berechtigung.ts b/src/server/berechtigung.ts new file mode 100644 index 0000000..5a2ef6d --- /dev/null +++ b/src/server/berechtigung.ts @@ -0,0 +1,74 @@ +import 'server-only' +import { redirect } from 'next/navigation' +import type { Session } from 'next-auth' +import type { Rolle } from '@/generated/prisma/enums' +import { rolleReichtAus } from '@/lib/rollen' +import { auth } from '@/server/auth' + +/** + * Rollenpruefung. + * + * Jede schreibende Aktion prueft serverseitig – in der Seite und noch einmal + * in der Server Action. Die Oberflaeche blendet Knoepfe nur zusaetzlich aus; + * verlassen wird sich darauf nie. + */ + +export type Angemeldet = Session['user'] + +/** Fehler, den Server Actions dem Aufrufer zurueckgeben. */ +export class NichtErlaubt extends Error { + constructor(nachricht = 'Dazu fehlt dir die Berechtigung.') { + super(nachricht) + this.name = 'NichtErlaubt' + } +} + +/** Die angemeldete Person, oder null. */ +export async function angemeldet(): Promise { + const sitzung = await auth() + return sitzung?.user?.id ? sitzung.user : null +} + +/** + * Riegel fuer Seiten: ohne Anmeldung geht es zur Anmeldeseite zurueck, + * mit Ziel, damit es danach dort weitergeht, wo man hinwollte. + */ +export async function verlangeAnmeldung(zielPfad?: string): Promise { + const benutzer = await angemeldet() + if (!benutzer) { + const ziel = zielPfad ? `?weiter=${encodeURIComponent(zielPfad)}` : '' + redirect(`/anmelden${ziel}`) + } + return benutzer +} + +/** Riegel fuer Seiten, die eine bestimmte Rolle verlangen. */ +export async function verlangeRolle( + verlangt: Rolle, + zielPfad?: string, +): Promise { + const benutzer = await verlangeAnmeldung(zielPfad) + if (!rolleReichtAus(benutzer.rolle, verlangt)) { + redirect('/keine-berechtigung') + } + return benutzer +} + +/** + * Riegel fuer Server Actions. Hier wird nicht umgeleitet, sondern geworfen – + * der Aufrufer wandelt das in eine Meldung fuer die Oberflaeche. + */ +export async function pruefeRolle(verlangt: Rolle): Promise { + const benutzer = await angemeldet() + if (!benutzer) throw new NichtErlaubt('Bitte zuerst anmelden.') + if (!rolleReichtAus(benutzer.rolle, verlangt)) throw new NichtErlaubt() + return benutzer +} + +/** + * Riegel fuer Route Handler: gibt null zurueck, statt umzuleiten. Der + * Aufrufer antwortet dann mit 401. + */ +export async function verlangeAnmeldungFuerApi(): Promise { + return angemeldet() +} diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts new file mode 100644 index 0000000..a72f305 --- /dev/null +++ b/src/types/next-auth.d.ts @@ -0,0 +1,20 @@ +import type { Rolle } from '@/generated/prisma/enums' + +/** + * Die Sitzung traegt zusaetzlich Kennung und Rolle. Beides wird im + * session-Callback bei jeder Anfrage frisch aus der Datenbank gelesen, damit + * eine Sperre oder ein Rollenwechsel sofort wirkt. + */ +declare module 'next-auth' { + interface Session { + user: { + id: string + rolle: Rolle + email: string + name?: string | null + image?: string | null + } + } +} + +export {}