feat(anmeldung): OIDC, Passkeys und Passwort mit Auth.js v5
Drei Wege hinein, je Instanz zuschaltbar: generischer OIDC-Anbieter (Entra ID beziehungsweise authentik), Passkeys und wahlweise E-Mail mit Passwort. Entscheidungen: - Passkeys laufen direkt über SimpleWebAuthn 14, nicht über den experimentellen WebAuthn-Provider von Auth.js, der noch auf Fassung 9 festgelegt ist. - Eigener Prisma-Adapter, weil der mitgelieferte fest auf prisma.user und prisma.account zugreift, unsere Modelle aber Benutzer und Konto heißen. - Sitzungen als JWT, weil Credentials-Provider das verlangen. Damit eine Sperre trotzdem sofort wirkt, liest der session-Callback Rolle und Status bei jeder Anfrage frisch aus der Datenbank. Die Tabellen sitzung und verifizierungs_token entfallen deshalb wieder. - allowDangerousEmailAccountLinking ist bewusst an: je Instanz gibt es genau einen vertrauenswürdigen Anbieter, der die Adresse selbst prüft. Anmeldung, Passwort und Passkey antworten bei Fehlschlag immer gleich, damit sich nicht abfragen lässt, wer hier ein Konto hat. Die WebAuthn-Herausforderung wird nach einem Versuch verworfen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4ff67c15c2
commit
cc013fd6a1
@@ -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 | 'oidc' | 'passkey' | 'passwort'>(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 (
|
||||
<Card>
|
||||
<CardInhalt className="flex flex-col gap-4 pt-6">
|
||||
{oidcAktiv && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
groesse="gross"
|
||||
onClick={() => void mitOidc()}
|
||||
disabled={gesperrt}
|
||||
>
|
||||
{laeuft === 'oidc' ? (
|
||||
<Loader2 className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<KeyRound aria-hidden />
|
||||
)}
|
||||
Mit {oidcName} anmelden
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-gedaempft-vordergrund text-xs">oder</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<form className="flex flex-col gap-4" onSubmit={(e) => void mitPasskey(e)}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="email">E-Mail-Adresse</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="username webauthn"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={gesperrt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" art="umriss" groesse="gross" disabled={gesperrt}>
|
||||
{laeuft === 'passkey' ? (
|
||||
<Loader2 className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Fingerprint aria-hidden />
|
||||
)}
|
||||
Mit Passkey anmelden
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{passwortAktiv && (
|
||||
<form className="flex flex-col gap-4" onSubmit={(e) => void mitPasswort(e)}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="passwort">Passwort</Label>
|
||||
<Input
|
||||
id="passwort"
|
||||
name="passwort"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={passwort}
|
||||
onChange={(e) => setPasswort(e.target.value)}
|
||||
disabled={gesperrt}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" art="leise" disabled={gesperrt}>
|
||||
{laeuft === 'passwort' && <Loader2 className="animate-spin" aria-hidden />}
|
||||
Mit Passwort anmelden
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<main className="flex min-h-dvh flex-col items-center justify-center gap-6 px-4 py-10">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Planer</h1>
|
||||
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||
{INSTANZ_NAME[instanz]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AnmeldeFormular
|
||||
ziel={ziel}
|
||||
oidcAktiv={einst.oidcAktiv}
|
||||
oidcName={einst.OIDC_NAME || 'Unternehmensanmeldung'}
|
||||
passwortAktiv={einst.ANMELDUNG_PASSWORT}
|
||||
fehlerAusUrl={parameter.error}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-gedaempft-vordergrund text-xs">{versionAnzeige()}</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handlers } from '@/server/auth'
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
@@ -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' } })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 } },
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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<void> {
|
||||
// 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<string | null> {
|
||||
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<number> {
|
||||
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<PublicKeyCredentialCreationOptionsJSON> {
|
||||
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<PublicKeyCredentialRequestOptionsJSON> {
|
||||
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<AngemeldeterBenutzer | null> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<boolean> {
|
||||
if (!hash) {
|
||||
await bcrypt.compare(
|
||||
passwort,
|
||||
'$2b$12$ungueltigerHashAlsPlatzhalterXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
|
||||
)
|
||||
return false
|
||||
}
|
||||
return bcrypt.compare(passwort, hash)
|
||||
}
|
||||
@@ -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<Angemeldet | null> {
|
||||
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<Angemeldet> {
|
||||
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<Angemeldet> {
|
||||
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<Angemeldet> {
|
||||
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<Angemeldet | null> {
|
||||
return angemeldet()
|
||||
}
|
||||
Vendored
+20
@@ -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 {}
|
||||
Reference in New Issue
Block a user