diff --git a/src/app/api/auth/webauthn/password-reset/options/route.ts b/src/app/api/auth/webauthn/password-reset/options/route.ts new file mode 100644 index 0000000..1591377 --- /dev/null +++ b/src/app/api/auth/webauthn/password-reset/options/route.ts @@ -0,0 +1,57 @@ +import { generateAuthenticationOptions } from "@simplewebauthn/server"; +import { NextResponse } from "next/server"; +import { requireAdminUser } from "@/lib/auth"; +import { db, initDatabase } from "@/lib/db"; +import { + createWebAuthnChallenge, + getUserWebAuthnCredentials, + webauthnRpId, +} from "@/lib/webauthn"; + +export const runtime = "nodejs"; + +type OptionsBody = { + targetUserId?: unknown; +}; + +export async function POST(request: Request) { + const admin = await requireAdminUser(); + initDatabase(); + const body = await request.json() as OptionsBody; + const targetUserId = Number(body.targetUserId ?? 0); + if ( + !Number.isSafeInteger(targetUserId) + || targetUserId < 1 + || targetUserId === admin.id + ) { + return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); + } + const target = db + .prepare("SELECT 1 FROM users WHERE id = ? AND active = 1") + .get(targetUserId); + if (!target) { + return NextResponse.json({ error: "Benutzer wurde nicht gefunden." }, { status: 404 }); + } + + const credentials = getUserWebAuthnCredentials(admin.id); + if (credentials.length === 0) { + return NextResponse.json({ error: "Kein YubiKey hinterlegt." }, { status: 400 }); + } + + const options = await generateAuthenticationOptions({ + rpID: webauthnRpId(request), + userVerification: "preferred", + allowCredentials: credentials.map((credential) => ({ + id: credential.credential_id, + transports: credential.transports + ? JSON.parse(credential.transports) as AuthenticatorTransport[] + : undefined, + })), + }); + const challengeId = createWebAuthnChallenge( + admin.id, + "authentication", + options.challenge, + ); + return NextResponse.json({ options, challengeId }); +} diff --git a/src/app/api/auth/webauthn/password-reset/verify/route.ts b/src/app/api/auth/webauthn/password-reset/verify/route.ts new file mode 100644 index 0000000..a3df589 --- /dev/null +++ b/src/app/api/auth/webauthn/password-reset/verify/route.ts @@ -0,0 +1,122 @@ +import { verifyAuthenticationResponse } from "@simplewebauthn/server"; +import { NextResponse } from "next/server"; +import { + createPasswordResetMfaToken, + PASSWORD_RESET_MFA_COOKIE, + requireAdminUser, +} from "@/lib/auth"; +import { db, initDatabase } from "@/lib/db"; +import { + consumeWebAuthnChallenge, + getUserWebAuthnCredentials, + webauthnExpectedOrigin, + webauthnRpId, +} from "@/lib/webauthn"; + +export const runtime = "nodejs"; + +type VerifyBody = { + targetUserId?: unknown; + challengeId?: unknown; + authenticationResponse?: unknown; +}; + +const MFA_TTL_SECONDS = 60 * 5; + +export async function POST(request: Request) { + const admin = await requireAdminUser(); + initDatabase(); + const body = await request.json() as VerifyBody; + const targetUserId = Number(body.targetUserId ?? 0); + const challengeId = Number(body.challengeId ?? 0); + if ( + !Number.isSafeInteger(targetUserId) + || targetUserId < 1 + || targetUserId === admin.id + || !Number.isSafeInteger(challengeId) + || challengeId < 1 + || !body.authenticationResponse + ) { + return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); + } + const target = db + .prepare("SELECT 1 FROM users WHERE id = ? AND active = 1") + .get(targetUserId); + if (!target) { + return NextResponse.json({ error: "Benutzer wurde nicht gefunden." }, { status: 404 }); + } + + const expectedChallenge = consumeWebAuthnChallenge( + admin.id, + challengeId, + "authentication", + ); + if (!expectedChallenge) { + return NextResponse.json( + { error: "Anfrage ist abgelaufen. Bitte erneut versuchen." }, + { status: 400 }, + ); + } + + const credentials = getUserWebAuthnCredentials(admin.id); + const credentialId = (body.authenticationResponse as { id?: string })?.id; + if (!credentialId) { + return NextResponse.json( + { error: "Ungültige Antwort vom YubiKey." }, + { status: 400 }, + ); + } + const matchingCredential = credentials.find( + (credential) => credential.credential_id === credentialId, + ); + if (!matchingCredential) { + return NextResponse.json( + { error: "YubiKey ist nicht registriert." }, + { status: 400 }, + ); + } + + const verification = await verifyAuthenticationResponse({ + response: + body.authenticationResponse as Parameters[0]["response"], + expectedChallenge, + expectedOrigin: webauthnExpectedOrigin(request), + expectedRPID: webauthnRpId(request), + credential: { + id: matchingCredential.credential_id, + publicKey: Buffer.from(matchingCredential.public_key, "base64url"), + counter: matchingCredential.counter, + transports: matchingCredential.transports + ? JSON.parse(matchingCredential.transports) as AuthenticatorTransport[] + : undefined, + }, + requireUserVerification: true, + }); + if (!verification.verified) { + return NextResponse.json( + { error: "YubiKey-Bestätigung fehlgeschlagen." }, + { status: 401 }, + ); + } + + db.prepare( + `UPDATE webauthn_credentials + SET counter = ?, last_used_at = CURRENT_TIMESTAMP + WHERE id = ?`, + ).run( + verification.authenticationInfo.newCounter, + matchingCredential.id, + ); + + const token = await createPasswordResetMfaToken(admin.id, targetUserId); + const response = NextResponse.json({ ok: true }); + response.cookies.set(PASSWORD_RESET_MFA_COOKIE, token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: MFA_TTL_SECONDS, + path: "/", + priority: "high", + }); + return response; +} diff --git a/src/app/change-password/page.tsx b/src/app/change-password/page.tsx new file mode 100644 index 0000000..a40923f --- /dev/null +++ b/src/app/change-password/page.tsx @@ -0,0 +1,76 @@ +import { redirect } from "next/navigation"; +import { + completeRequiredPasswordChangeAction, + logoutAction, +} from "@/app/actions/auth"; +import { Flash } from "@/components/flash"; +import { getSessionUser } from "@/lib/auth"; +import { queryMessage } from "@/lib/format"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export default async function RequiredPasswordChangePage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const user = await getSessionUser(); + if (!user) redirect("/login"); + if (!user.mustChangePassword) redirect("/"); + const params = await searchParams; + + return ( +
+
+
+ FG + + Familiengarage + Sicherheitsprüfung + +
+
+

Passwort zurückgesetzt

+

Neues Passwort vergeben

+

+ Hallo {user.name}. Bevor du fortfahren kannst, musst du das temporäre + Passwort durch ein eigenes ersetzen. +

+
+ +
+ + + +
+
+ +
+
+
+ ); +} diff --git a/src/components/reset-user-password.tsx b/src/components/reset-user-password.tsx new file mode 100644 index 0000000..c7e6049 --- /dev/null +++ b/src/components/reset-user-password.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { startAuthentication } from "@simplewebauthn/browser"; +import { useActionState } from "react"; +import { useRef, useState } from "react"; +import { + resetUserPasswordAction, + type ResetUserPasswordState, +} from "@/app/actions/users"; + +const initialState: ResetUserPasswordState = {}; + +type AuthenticationOptionsResponse = { + options: unknown; + challengeId: number; +}; + +export function ResetUserPassword({ + userId, + requiresYubiKey, +}: { + userId: number; + requiresYubiKey: boolean; +}) { + const [state, action, pending] = useActionState( + resetUserPasswordAction, + initialState, + ); + const [mfaError, setMfaError] = useState(null); + const [mfaBusy, setMfaBusy] = useState(false); + const confirmedSubmission = useRef(false); + + async function confirmAndSubmit(event: React.FormEvent) { + if (confirmedSubmission.current) { + confirmedSubmission.current = false; + return; + } + if (!requiresYubiKey) { + if (!window.confirm("Passwort zurücksetzen und alle Sitzungen dieses Benutzers beenden?")) { + event.preventDefault(); + } + return; + } + + event.preventDefault(); + if (!window.confirm("Passwort zurücksetzen und alle Sitzungen dieses Benutzers beenden?")) { + return; + } + + const form = event.currentTarget; + setMfaError(null); + setMfaBusy(true); + try { + const optionsRes = await fetch("/api/auth/webauthn/password-reset/options", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ targetUserId: userId }), + }); + const optionsPayload = await optionsRes.json() as + | AuthenticationOptionsResponse + | { error: string }; + if (!optionsRes.ok || !("challengeId" in optionsPayload)) { + throw new Error( + "error" in optionsPayload + ? optionsPayload.error + : "YubiKey-Bestätigung fehlgeschlagen.", + ); + } + + const authenticationResponse = await startAuthentication({ + optionsJSON: + optionsPayload.options as Parameters[0]["optionsJSON"], + }); + const verifyRes = await fetch("/api/auth/webauthn/password-reset/verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + targetUserId: userId, + challengeId: optionsPayload.challengeId, + authenticationResponse, + }), + }); + const verifyPayload = await verifyRes.json() as { error?: string }; + if (!verifyRes.ok) { + throw new Error(verifyPayload.error ?? "YubiKey-Bestätigung fehlgeschlagen."); + } + + confirmedSubmission.current = true; + form.requestSubmit(); + } catch (cause) { + setMfaError( + cause instanceof Error ? cause.message : "YubiKey-Bestätigung fehlgeschlagen.", + ); + } finally { + setMfaBusy(false); + } + } + + return ( +
+
+ + +
+ {(mfaError || state.error) && ( +

+ {mfaError ?? state.error} +

+ )} + {state.password && ( +
+ Neues Passwort für {state.userName}: + {state.password} + + Jetzt kopieren. Es wird nur hier angezeigt und muss bei der nächsten + Anmeldung geändert werden. + +
+ )} +
+ ); +}