Add support for admin-initiated password resets with YubiKey verification and enforce user password change on login
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user