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