feat(web): MFA and passkey sign-in on the login page
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import QRCode from "qrcode";
|
||||
import { auth } from "@/lib/api";
|
||||
import { isPasskeySupported, toCreateOptions, credentialToJSON } from "@/lib/webauthn";
|
||||
import { Button } from "@/components/ui";
|
||||
|
||||
/**
|
||||
* The calls a wizard step makes to set up a second factor. Defaults to the
|
||||
* ticket-scoped `/auth/mfa/enrol/*` endpoints (an in-progress, unauthenticated
|
||||
* sign-in identified by the server's ticket cookie) for `mode="ticket"`.
|
||||
*
|
||||
* `mode="session"` is for an already-authenticated user managing their own
|
||||
* factors (the account security page) and has no safe default: that page's
|
||||
* endpoints do not exist yet, so its caller must pass `endpoints` explicitly.
|
||||
*/
|
||||
export interface MfaEnrolEndpoints {
|
||||
totpSetup: () => Promise<{ secret: string; otpauth_url: string }>;
|
||||
totpConfirm: (code: string) => Promise<{ ok: true; recovery_codes?: string[] }>;
|
||||
passkeyBegin: () => Promise<{ publicKey: any; ceremony_id: string }>;
|
||||
passkeyFinish: (ceremonyId: string, credential: unknown, name?: string) => Promise<{ ok: true; recovery_codes?: string[] }>;
|
||||
}
|
||||
|
||||
const TICKET_ENDPOINTS: MfaEnrolEndpoints = {
|
||||
totpSetup: () => auth.enrolTotpSetup(),
|
||||
totpConfirm: (code) => auth.enrolTotpConfirm(code),
|
||||
passkeyBegin: () => auth.enrolPasskeyBegin(),
|
||||
passkeyFinish: (ceremonyId, credential, name) => auth.enrolPasskeyFinish(ceremonyId, credential, name),
|
||||
};
|
||||
|
||||
interface MfaEnrolWizardProps {
|
||||
mode: "session" | "ticket";
|
||||
/** Required for mode="session"; defaults to the ticket-scoped endpoints for mode="ticket". */
|
||||
endpoints?: MfaEnrolEndpoints;
|
||||
onComplete: (recoveryCodes: string[]) => void;
|
||||
/** Omit to make the wizard mandatory, as in forced enrolment during sign-in. */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
type Step = "choose" | "totp" | "passkey" | "recovery";
|
||||
|
||||
export function MfaEnrolWizard({ mode, endpoints, onComplete, onCancel }: MfaEnrolWizardProps) {
|
||||
const api = endpoints ?? (mode === "ticket" ? TICKET_ENDPOINTS : undefined);
|
||||
const [step, setStep] = useState<Step>("choose");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// TOTP setup state
|
||||
const [secret, setSecret] = useState("");
|
||||
const [qrDataUrl, setQrDataUrl] = useState("");
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
|
||||
// Recovery codes state
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
|
||||
const [savedConfirmed, setSavedConfirmed] = useState(false);
|
||||
|
||||
if (!api) {
|
||||
return <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">This wizard was not given its endpoints for session mode.</div>;
|
||||
}
|
||||
|
||||
async function startTotp() {
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const { secret, otpauth_url } = await api!.totpSetup();
|
||||
setSecret(secret);
|
||||
setQrDataUrl(await QRCode.toDataURL(otpauth_url));
|
||||
setStep("totp");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmTotp(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await api!.totpConfirm(totpCode);
|
||||
setRecoveryCodes(res.recovery_codes ?? []);
|
||||
setStep("recovery");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function startPasskey() {
|
||||
setError("");
|
||||
setBusy(true);
|
||||
try {
|
||||
const { publicKey, ceremony_id } = await api!.passkeyBegin();
|
||||
const cred = (await navigator.credentials.create({
|
||||
publicKey: toCreateOptions(publicKey),
|
||||
})) as PublicKeyCredential;
|
||||
const res = await api!.passkeyFinish(ceremony_id, credentialToJSON(cred));
|
||||
setRecoveryCodes(res.recovery_codes ?? []);
|
||||
setStep("recovery");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (step === "choose") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">This instance requires a second sign-in factor. Set one up to continue.</p>
|
||||
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
<div className="space-y-2">
|
||||
<Button type="button" variant="primary" className="w-full justify-center" loading={busy} onClick={startTotp}>
|
||||
Use an authenticator app
|
||||
</Button>
|
||||
{isPasskeySupported() && (
|
||||
<Button type="button" variant="secondary" className="w-full justify-center" loading={busy} onClick={startPasskey}>
|
||||
Use a passkey
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{onCancel && (
|
||||
<Button type="button" variant="ghost" className="w-full justify-center" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "totp") {
|
||||
return (
|
||||
<form onSubmit={confirmTotp} className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">Scan this code with your authenticator app, or enter the key manually.</p>
|
||||
{qrDataUrl && <img src={qrDataUrl} alt="Authenticator QR code" className="mx-auto h-40 w-40" />}
|
||||
<p className="break-all rounded-lg border border-border bg-surface-2 px-3 py-2 text-center font-mono text-xs text-text-secondary">{secret}</p>
|
||||
<div>
|
||||
<label htmlFor="totp-code" className="mb-1.5 block text-sm font-medium text-text-secondary">
|
||||
6-digit code
|
||||
</label>
|
||||
<input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">{error}</div>}
|
||||
<Button type="submit" variant="primary" loading={busy} className="w-full justify-center">
|
||||
Confirm
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" className="w-full justify-center" onClick={() => setStep("choose")}>
|
||||
Back
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// step === "recovery"
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-secondary">Save these recovery codes somewhere safe. Each can be used once if you lose access to your other factor.</p>
|
||||
<div className="grid grid-cols-2 gap-2 rounded-lg border border-border bg-surface-2 p-3 font-mono text-xs text-text-primary">
|
||||
{recoveryCodes.map((code) => (
|
||||
<div key={code}>{code}</div>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<input type="checkbox" checked={savedConfirmed} onChange={(e) => setSavedConfirmed(e.target.checked)} className="h-4 w-4 rounded border-border" />
|
||||
I have saved these codes
|
||||
</label>
|
||||
<Button type="button" variant="primary" className="w-full justify-center" disabled={!savedConfirmed} onClick={() => onComplete(recoveryCodes)}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user