Add support for admin-initiated password resets with YubiKey verification and enforce user password change on login

This commit is contained in:
2026-07-27 22:05:32 +02:00
parent e422bf6506
commit aef223da7e
4 changed files with 387 additions and 0 deletions
@@ -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 });
}
@@ -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<typeof verifyAuthenticationResponse>[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;
}
+76
View File
@@ -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<Record<string, string | string[] | undefined>>;
}) {
const user = await getSessionUser();
if (!user) redirect("/login");
if (!user.mustChangePassword) redirect("/");
const params = await searchParams;
return (
<main className="login-page">
<section className="login-card">
<div className="brand login-brand">
<span className="brand-mark">FG</span>
<span>
<strong>Familiengarage</strong>
<small>Sicherheitsprüfung</small>
</span>
</div>
<div>
<p className="eyebrow">Passwort zurückgesetzt</p>
<h1>Neues Passwort vergeben</h1>
<p className="muted">
Hallo {user.name}. Bevor du fortfahren kannst, musst du das temporäre
Passwort durch ein eigenes ersetzen.
</p>
</div>
<Flash error={queryMessage(params, "error")} />
<form action={completeRequiredPasswordChangeAction} className="stack-form">
<label>
Neues Passwort * (min. 10 Zeichen)
<input
name="new_password"
type="password"
required
minLength={10}
maxLength={200}
autoComplete="new-password"
autoFocus
/>
</label>
<label>
Neues Passwort bestätigen *
<input
name="confirm_password"
type="password"
required
minLength={10}
maxLength={200}
autoComplete="new-password"
/>
</label>
<button className="button button-primary" type="submit">
Passwort speichern
</button>
</form>
<form action={logoutAction}>
<button className="link-button" type="submit">Abmelden</button>
</form>
</section>
</main>
);
}
+132
View File
@@ -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<string | null>(null);
const [mfaBusy, setMfaBusy] = useState(false);
const confirmedSubmission = useRef(false);
async function confirmAndSubmit(event: React.FormEvent<HTMLFormElement>) {
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<typeof startAuthentication>[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 (
<div className="password-reset">
<form
action={action}
className="inline-form"
onSubmit={confirmAndSubmit}
>
<input type="hidden" name="id" value={userId} />
<button className="link-button" type="submit" disabled={pending || mfaBusy}>
{mfaBusy
? "Warte auf YubiKey …"
: pending
? "Wird zurückgesetzt …"
: "Passwort zurücksetzen"}
</button>
</form>
{(mfaError || state.error) && (
<p className="password-reset-error" role="alert">
{mfaError ?? state.error}
</p>
)}
{state.password && (
<div className="temporary-password" role="status">
<strong>Neues Passwort für {state.userName}:</strong>
<code>{state.password}</code>
<small>
Jetzt kopieren. Es wird nur hier angezeigt und muss bei der nächsten
Anmeldung geändert werden.
</small>
</div>
)}
</div>
);
}