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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user