feat(auth): mandatory 2FA (TOTP + WebAuthn passkeys) with hard enrollment gate, AAL2 step-up, and single-use recovery codes
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createSupabaseServerClient } from '@/lib/supabase/server';
|
||||
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
||||
import { jsonNoStore } from '@/lib/admin/response';
|
||||
import { generateRecoveryCodes, hashRecoveryCode } from '@/lib/auth/recovery';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Generate a fresh set of recovery codes for the current user. Requires an
|
||||
* aal2 session (the user just verified a factor). Any previously-issued codes
|
||||
* are replaced. The plaintext codes are returned once and never persisted.
|
||||
*/
|
||||
export async function POST(): Promise<NextResponse> {
|
||||
const supabase = createSupabaseServerClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return jsonNoStore({ error: 'unauthorized' }, { status: 401 });
|
||||
|
||||
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
|
||||
if (aal?.currentLevel !== 'aal2') {
|
||||
return jsonNoStore({ error: 'aal2_required' }, { status: 403 });
|
||||
}
|
||||
|
||||
const codes = generateRecoveryCodes(10);
|
||||
const admin = getSupabaseAdmin();
|
||||
|
||||
const del = await admin
|
||||
.from('mfa_recovery_codes')
|
||||
.delete()
|
||||
.eq('user_id', user.id);
|
||||
if (del.error) return jsonNoStore({ error: 'failed' }, { status: 500 });
|
||||
|
||||
const rows = codes.map((c) => ({
|
||||
user_id: user.id,
|
||||
code_hash: hashRecoveryCode(c),
|
||||
}));
|
||||
const ins = await admin.from('mfa_recovery_codes').insert(rows);
|
||||
if (ins.error) return jsonNoStore({ error: 'failed' }, { status: 500 });
|
||||
|
||||
return jsonNoStore({ codes });
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextResponse, type NextRequest } from 'next/server';
|
||||
import { createSupabaseServerClient } from '@/lib/supabase/server';
|
||||
import { getSupabaseAdmin } from '@/lib/supabase/admin';
|
||||
import { jsonNoStore } from '@/lib/admin/response';
|
||||
import { hashRecoveryCode } from '@/lib/auth/recovery';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Redeem a single-use recovery code for account recovery.
|
||||
*
|
||||
* The user is authenticated (aal1) but locked out of step-up because they lost
|
||||
* their authenticator/passkey. On a valid code we mark it used and DELETE all
|
||||
* of the user's MFA factors via the admin API. GoTrue logs the user out of all
|
||||
* sessions when a verified factor is deleted, so the client signs out and
|
||||
* returns to /login; after signing back in (aal1, zero factors) the middleware
|
||||
* forces fresh enrollment. We never fabricate an aal2 JWT.
|
||||
*/
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
const supabase = createSupabaseServerClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return jsonNoStore({ error: 'unauthorized' }, { status: 401 });
|
||||
|
||||
let body: { code?: string };
|
||||
try {
|
||||
body = (await request.json()) as { code?: string };
|
||||
} catch {
|
||||
return jsonNoStore({ error: 'bad_request' }, { status: 400 });
|
||||
}
|
||||
const code = (body.code ?? '').trim();
|
||||
if (!code) return jsonNoStore({ error: 'invalid_code' }, { status: 400 });
|
||||
|
||||
const admin = getSupabaseAdmin();
|
||||
const hash = hashRecoveryCode(code);
|
||||
|
||||
const { data: match, error } = await admin
|
||||
.from('mfa_recovery_codes')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('code_hash', hash)
|
||||
.is('used_at', null)
|
||||
.limit(1)
|
||||
.maybeSingle<{ id: number }>();
|
||||
if (error) return jsonNoStore({ error: 'failed' }, { status: 500 });
|
||||
if (!match) return jsonNoStore({ error: 'invalid_code' }, { status: 400 });
|
||||
|
||||
const upd = await admin
|
||||
.from('mfa_recovery_codes')
|
||||
.update({ used_at: new Date().toISOString() })
|
||||
.eq('id', match.id)
|
||||
.is('used_at', null);
|
||||
if (upd.error) return jsonNoStore({ error: 'failed' }, { status: 500 });
|
||||
|
||||
// Account recovery: remove every MFA factor so the user can re-enroll.
|
||||
const { data: list } = await admin.auth.admin.mfa.listFactors({
|
||||
userId: user.id,
|
||||
});
|
||||
for (const factor of list?.factors ?? []) {
|
||||
await admin.auth.admin.mfa.deleteFactor({ id: factor.id, userId: user.id });
|
||||
}
|
||||
|
||||
return jsonNoStore({ ok: true });
|
||||
}
|
||||
@@ -45,6 +45,12 @@ a:hover {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -33,6 +33,7 @@ export default async function RootLayout({
|
||||
<>
|
||||
<Link href="/dashboard">Dashboard</Link>
|
||||
<Link href="/billing">Billing</Link>
|
||||
<Link href="/security">Security</Link>
|
||||
{isAdmin && <Link href="/admin">Admin</Link>}
|
||||
<form action="/api/auth/signout" method="post" style={{ margin: 0 }}>
|
||||
<button className="secondary" type="submit">
|
||||
|
||||
+8
-2
@@ -29,6 +29,10 @@ export default function LoginPage() {
|
||||
setError(
|
||||
'Email verification failed or the link expired. Sign in below to resend a confirmation email.',
|
||||
);
|
||||
} else if (params.get('mfa_reset') === '1') {
|
||||
setResendInfo(
|
||||
'Your two-factor methods were reset. Sign in to set up a new one.',
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -59,7 +63,9 @@ export default function LoginPage() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
router.push('/dashboard');
|
||||
// Hand off to the MFA gate: the challenge page completes step-up (or
|
||||
// redirects to enrollment for users without a verified factor).
|
||||
router.push('/security/challenge');
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
@@ -120,7 +126,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
<div className="row" style={{ marginTop: '1rem' }}>
|
||||
<button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Signing in…' : 'Sign in'}
|
||||
{isPending ? 'Verifying…' : 'Sign in'}
|
||||
</button>
|
||||
<Link className="muted" href="/signup">
|
||||
Need an account?
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createSupabaseBrowserClient } from '@/lib/supabase/browser';
|
||||
import {
|
||||
passkeyErrorMessage,
|
||||
stepUpWithPasskey,
|
||||
} from '@/lib/auth/webauthn-client';
|
||||
|
||||
/**
|
||||
* AAL2 step-up UI: verify with TOTP or passkey, or fall back to a single-use
|
||||
* recovery code (which resets MFA and bounces the user back to /login to set up
|
||||
* a fresh factor).
|
||||
*/
|
||||
export function ChallengeClient({
|
||||
totpFactorId,
|
||||
passkeyFactorId,
|
||||
next,
|
||||
}: {
|
||||
totpFactorId: string | null;
|
||||
passkeyFactorId: string | null;
|
||||
next: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const supabase = useMemo(() => createSupabaseBrowserClient(), []);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [code, setCode] = useState('');
|
||||
const [showRecovery, setShowRecovery] = useState(false);
|
||||
const [recovery, setRecovery] = useState('');
|
||||
|
||||
function done() {
|
||||
router.replace(next);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function verifyTotp(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!totpFactorId) return;
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data: ch, error: ce } = await supabase.auth.mfa.challenge({
|
||||
factorId: totpFactorId,
|
||||
});
|
||||
if (ce) throw ce;
|
||||
const { error: ve } = await supabase.auth.mfa.verify({
|
||||
factorId: totpFactorId,
|
||||
challengeId: ch.id,
|
||||
code: code.trim(),
|
||||
});
|
||||
if (ve) throw ve;
|
||||
done();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyPasskey() {
|
||||
if (!passkeyFactorId) return;
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await stepUpWithPasskey(supabase, passkeyFactorId);
|
||||
done();
|
||||
} catch (e) {
|
||||
setError(passkeyErrorMessage(e));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function redeemRecovery(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch('/api/security/recovery/redeem', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: recovery.trim() }),
|
||||
});
|
||||
const json = (await res.json()) as { ok?: boolean; error?: string };
|
||||
if (!res.ok || !json.ok) {
|
||||
throw new Error(
|
||||
json.error === 'invalid_code'
|
||||
? 'That recovery code is invalid or already used.'
|
||||
: 'Recovery failed. Please try again.',
|
||||
);
|
||||
}
|
||||
// Deleting verified factors logs the user out everywhere; clear local
|
||||
// cookies and return to login to re-enroll.
|
||||
await supabase.auth.signOut().catch(() => {});
|
||||
router.replace('/login?mfa_reset=1');
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
{passkeyFactorId && (
|
||||
<div className="card">
|
||||
<h2>Passkey</h2>
|
||||
<p className="muted">
|
||||
Verify with your device biometrics or security key.
|
||||
</p>
|
||||
<button type="button" onClick={verifyPasskey} disabled={busy}>
|
||||
{busy ? 'Waiting…' : 'Use a passkey'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totpFactorId && (
|
||||
<div className="card">
|
||||
<h2>Authenticator app</h2>
|
||||
<form onSubmit={verifyTotp}>
|
||||
<label htmlFor="challenge-code">6-digit code</label>
|
||||
<input
|
||||
id="challenge-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
required
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
<div className="row" style={{ marginTop: '1rem' }}>
|
||||
<button type="submit" disabled={busy || code.trim().length < 6}>
|
||||
{busy ? 'Verifying…' : 'Verify'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h2>Lost access?</h2>
|
||||
{showRecovery ? (
|
||||
<form onSubmit={redeemRecovery}>
|
||||
<p className="muted">
|
||||
Enter a recovery code. This removes your current two-factor
|
||||
methods so you can set up new ones after signing in again.
|
||||
</p>
|
||||
<label htmlFor="recovery-code">Recovery code</label>
|
||||
<input
|
||||
id="recovery-code"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="xxxx-xxxx-xxxx"
|
||||
required
|
||||
value={recovery}
|
||||
onChange={(e) => setRecovery(e.target.value)}
|
||||
/>
|
||||
<div className="row" style={{ marginTop: '1rem' }}>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-danger"
|
||||
disabled={busy || recovery.trim().length === 0}
|
||||
>
|
||||
{busy ? 'Working…' : 'Use recovery code'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => setShowRecovery(true)}
|
||||
disabled={busy}
|
||||
>
|
||||
Use a recovery code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { createSupabaseServerClient } from '@/lib/supabase/server';
|
||||
import { safeNextPath } from '@/lib/auth/mfa';
|
||||
import { ChallengeClient } from './challenge-client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* AAL2 step-up challenge. The middleware sends authenticated aal1 users here
|
||||
* when they have verified factors. Users with no verified factors are sent to
|
||||
* enrollment instead; users already at aal2 are forwarded to their target.
|
||||
*/
|
||||
export default async function ChallengePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { next?: string };
|
||||
}) {
|
||||
const supabase = createSupabaseServerClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) redirect('/login');
|
||||
|
||||
const next = safeNextPath(searchParams.next);
|
||||
|
||||
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
|
||||
if (aal?.currentLevel === 'aal2') redirect(next);
|
||||
|
||||
const { data: factors } = await supabase.auth.mfa.listFactors();
|
||||
const verified = factors?.all.filter((f) => f.status === 'verified') ?? [];
|
||||
if (verified.length === 0) redirect('/security/enroll');
|
||||
|
||||
const totp = verified.find((f) => f.factor_type === 'totp');
|
||||
const passkey = verified.find((f) => f.factor_type === 'webauthn');
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<h1>Verify it's you</h1>
|
||||
<p className="muted">Complete two-factor authentication to continue.</p>
|
||||
<ChallengeClient
|
||||
totpFactorId={totp?.id ?? null}
|
||||
passkeyFactorId={passkey?.id ?? null}
|
||||
next={next}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createSupabaseBrowserClient } from '@/lib/supabase/browser';
|
||||
import {
|
||||
PasskeyEnrollPanel,
|
||||
RecoveryCodesPanel,
|
||||
TotpEnrollPanel,
|
||||
} from '../mfa-components';
|
||||
|
||||
/**
|
||||
* Drives the mandatory first-factor enrollment: pick TOTP and/or passkey, then
|
||||
* (once a factor is verified and the session is aal2) generate and display the
|
||||
* one-time recovery codes before releasing the user to the dashboard.
|
||||
*/
|
||||
export function EnrollClient() {
|
||||
const router = useRouter();
|
||||
const supabase = useMemo(() => createSupabaseBrowserClient(), []);
|
||||
const [phase, setPhase] = useState<'choose' | 'recovery'>('choose');
|
||||
const [codes, setCodes] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleVerified() {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/security/recovery/generate', {
|
||||
method: 'POST',
|
||||
});
|
||||
const json = (await res.json()) as { codes?: string[]; error?: string };
|
||||
if (!res.ok || !json.codes) {
|
||||
throw new Error(json.error || 'Could not generate recovery codes.');
|
||||
}
|
||||
setCodes(json.codes);
|
||||
} catch (e) {
|
||||
// The factor is already enabled; surface the error but still let the
|
||||
// user proceed (they can regenerate codes later in Security settings).
|
||||
setError(
|
||||
`${(e as Error).message} You can generate recovery codes later in Security settings.`,
|
||||
);
|
||||
setCodes([]);
|
||||
} finally {
|
||||
setPhase('recovery');
|
||||
}
|
||||
}
|
||||
|
||||
function finish() {
|
||||
router.replace('/dashboard');
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (phase === 'recovery') {
|
||||
return (
|
||||
<div className="stack">
|
||||
{error && <p className="error">{error}</p>}
|
||||
{codes.length > 0 ? (
|
||||
<RecoveryCodesPanel codes={codes} />
|
||||
) : (
|
||||
<div className="card">
|
||||
<h2>Two-factor authentication enabled</h2>
|
||||
<p className="muted">Your account is now protected.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="row">
|
||||
<button type="button" onClick={finish}>
|
||||
Continue to dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<TotpEnrollPanel supabase={supabase} onVerified={handleVerified} />
|
||||
<PasskeyEnrollPanel supabase={supabase} onVerified={handleVerified} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { createSupabaseServerClient } from '@/lib/supabase/server';
|
||||
import { EnrollClient } from './enroll-client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Mandatory MFA enrollment gate. Reachable only by authenticated users (the
|
||||
* middleware sends users here when they have zero verified factors). If the
|
||||
* user already has a verified factor, there is nothing to enroll, so we send
|
||||
* them on to the dashboard.
|
||||
*/
|
||||
export default async function EnrollPage() {
|
||||
const supabase = createSupabaseServerClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) redirect('/login');
|
||||
|
||||
const { data: factors } = await supabase.auth.mfa.listFactors();
|
||||
const verified = factors?.all.filter((f) => f.status === 'verified') ?? [];
|
||||
if (verified.length > 0) redirect('/dashboard');
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<h1>Secure your account</h1>
|
||||
<p className="muted">
|
||||
Two-factor authentication is required. Add at least one method below to
|
||||
continue.
|
||||
</p>
|
||||
<EnrollClient />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import {
|
||||
enrollPasskey,
|
||||
passkeyErrorMessage,
|
||||
} from '@/lib/auth/webauthn-client';
|
||||
|
||||
/**
|
||||
* Remove any leftover *unverified* factors of a given type before starting a
|
||||
* fresh enrollment. GoTrue rejects a second enroll with a duplicate friendly
|
||||
* name, and abandoned/unverified factors would otherwise block re-enrollment
|
||||
* (e.g. after a refresh mid-flow). Unenrolling unverified factors does not
|
||||
* require aal2.
|
||||
*/
|
||||
async function cleanupUnverified(
|
||||
supabase: SupabaseClient,
|
||||
factorType: 'totp' | 'webauthn',
|
||||
): Promise<void> {
|
||||
const { data } = await supabase.auth.mfa.listFactors();
|
||||
const stale =
|
||||
data?.all.filter(
|
||||
(f) => f.factor_type === factorType && f.status === 'unverified',
|
||||
) ?? [];
|
||||
for (const f of stale) {
|
||||
await supabase.auth.mfa.unenroll({ factorId: f.id });
|
||||
}
|
||||
}
|
||||
|
||||
/** TOTP authenticator-app enrollment: QR + manual secret, then code verify. */
|
||||
export function TotpEnrollPanel({
|
||||
supabase,
|
||||
onVerified,
|
||||
}: {
|
||||
supabase: SupabaseClient;
|
||||
onVerified: () => void;
|
||||
}) {
|
||||
const [stage, setStage] = useState<'idle' | 'pending'>('idle');
|
||||
const [qr, setQr] = useState('');
|
||||
const [secret, setSecret] = useState('');
|
||||
const [factorId, setFactorId] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function start() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await cleanupUnverified(supabase, 'totp');
|
||||
const { data, error } = await supabase.auth.mfa.enroll({
|
||||
factorType: 'totp',
|
||||
friendlyName: 'Authenticator app',
|
||||
});
|
||||
if (error) throw error;
|
||||
if (!data || !('totp' in data)) {
|
||||
throw new Error('Unexpected enrollment response.');
|
||||
}
|
||||
setFactorId(data.id);
|
||||
setQr(data.totp.qr_code);
|
||||
setSecret(data.totp.secret);
|
||||
setStage('pending');
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function verify(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data: ch, error: ce } = await supabase.auth.mfa.challenge({
|
||||
factorId,
|
||||
});
|
||||
if (ce) throw ce;
|
||||
const { error: ve } = await supabase.auth.mfa.verify({
|
||||
factorId,
|
||||
challengeId: ch.id,
|
||||
code: code.trim(),
|
||||
});
|
||||
if (ve) throw ve;
|
||||
onVerified();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (stage === 'idle') {
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Authenticator app</h2>
|
||||
<p className="muted">
|
||||
Use an app like Google Authenticator, 1Password, or Authy to generate
|
||||
6-digit codes.
|
||||
</p>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="button" onClick={start} disabled={busy}>
|
||||
{busy ? 'Preparing…' : 'Set up authenticator app'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Scan the QR code</h2>
|
||||
<p className="muted">
|
||||
Scan this with your authenticator app, then enter the 6-digit code to
|
||||
confirm.
|
||||
</p>
|
||||
{qr && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={qr}
|
||||
alt="TOTP QR code"
|
||||
width={200}
|
||||
height={200}
|
||||
style={{ background: '#fff', padding: 8, borderRadius: 8 }}
|
||||
/>
|
||||
)}
|
||||
<p className="muted" style={{ marginTop: '0.75rem' }}>
|
||||
Can't scan? Enter this secret manually:
|
||||
</p>
|
||||
<div className="token">{secret}</div>
|
||||
<form onSubmit={verify}>
|
||||
<label htmlFor="totp-code">6-digit code</label>
|
||||
<input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]*"
|
||||
maxLength={6}
|
||||
required
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="row" style={{ marginTop: '1rem' }}>
|
||||
<button type="submit" disabled={busy || code.trim().length < 6}>
|
||||
{busy ? 'Verifying…' : 'Verify and enable'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Passkey (WebAuthn) enrollment via the simplewebauthn ceremony. */
|
||||
export function PasskeyEnrollPanel({
|
||||
supabase,
|
||||
onVerified,
|
||||
}: {
|
||||
supabase: SupabaseClient;
|
||||
onVerified: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function start() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await cleanupUnverified(supabase, 'webauthn');
|
||||
await enrollPasskey(supabase, 'Passkey');
|
||||
onVerified();
|
||||
} catch (e) {
|
||||
setError(passkeyErrorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Passkey</h2>
|
||||
<p className="muted">
|
||||
Use your device's biometrics, screen lock, or a hardware security
|
||||
key.
|
||||
</p>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="button" onClick={start} disabled={busy}>
|
||||
{busy ? 'Waiting for passkey…' : 'Set up a passkey'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One-time display of freshly generated recovery codes. */
|
||||
export function RecoveryCodesPanel({ codes }: { codes: string[] }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function copy() {
|
||||
navigator.clipboard
|
||||
.writeText(codes.join('\n'))
|
||||
.then(() => setCopied(true))
|
||||
.catch(() => setCopied(false));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h2>Save your recovery codes</h2>
|
||||
<p className="muted">
|
||||
Store these somewhere safe. Each code can be used once to recover access
|
||||
if you lose your authenticator and passkeys. They will not be shown
|
||||
again.
|
||||
</p>
|
||||
<div
|
||||
className="token"
|
||||
style={{ display: 'grid', gap: '0.25rem', lineHeight: 1.8 }}
|
||||
>
|
||||
{codes.map((c) => (
|
||||
<span key={c}>{c}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: '1rem' }}>
|
||||
<button type="button" className="secondary" onClick={copy}>
|
||||
{copied ? 'Copied' : 'Copy codes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { createSupabaseServerClient } from '@/lib/supabase/server';
|
||||
import { SecurityClient } from './security-client';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Security settings: manage MFA factors and regenerate recovery codes. The
|
||||
* middleware guarantees the visitor is authenticated and at aal2 (mandatory
|
||||
* MFA), so factor mutations here always run with the required assurance level.
|
||||
*/
|
||||
export default async function SecurityPage() {
|
||||
const supabase = createSupabaseServerClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) redirect('/login');
|
||||
|
||||
const { data: factors } = await supabase.auth.mfa.listFactors();
|
||||
const verified =
|
||||
factors?.all
|
||||
.filter((f) => f.status === 'verified')
|
||||
.map((f) => ({
|
||||
id: f.id,
|
||||
type: f.factor_type,
|
||||
friendlyName: f.friendly_name ?? null,
|
||||
createdAt: f.created_at,
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<h1>Security</h1>
|
||||
<p className="muted">Manage two-factor authentication for your account.</p>
|
||||
<SecurityClient initialFactors={verified} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createSupabaseBrowserClient } from '@/lib/supabase/browser';
|
||||
import {
|
||||
PasskeyEnrollPanel,
|
||||
RecoveryCodesPanel,
|
||||
TotpEnrollPanel,
|
||||
} from './mfa-components';
|
||||
|
||||
type FactorView = {
|
||||
id: string;
|
||||
type: string;
|
||||
friendlyName: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
totp: 'Authenticator app',
|
||||
webauthn: 'Passkey',
|
||||
phone: 'Phone',
|
||||
};
|
||||
|
||||
/**
|
||||
* Security settings client: list verified factors, add new ones, remove
|
||||
* factors (never the last one — mandatory MFA), and regenerate recovery codes.
|
||||
*/
|
||||
export function SecurityClient({
|
||||
initialFactors,
|
||||
}: {
|
||||
initialFactors: FactorView[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const supabase = useMemo(() => createSupabaseBrowserClient(), []);
|
||||
const [factors] = useState<FactorView[]>(initialFactors);
|
||||
const [adding, setAdding] = useState<null | 'totp' | 'webauthn'>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [codes, setCodes] = useState<string[] | null>(null);
|
||||
const [regenBusy, setRegenBusy] = useState(false);
|
||||
|
||||
async function remove(id: string) {
|
||||
if (factors.length <= 1) {
|
||||
setError('You must keep at least one two-factor method enabled.');
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setBusyId(id);
|
||||
try {
|
||||
const { error } = await supabase.auth.mfa.unenroll({ factorId: id });
|
||||
if (error) throw error;
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function onAdded() {
|
||||
setAdding(null);
|
||||
setNotice('Two-factor method added.');
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function regenerate() {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setCodes(null);
|
||||
setRegenBusy(true);
|
||||
try {
|
||||
const res = await fetch('/api/security/recovery/generate', {
|
||||
method: 'POST',
|
||||
});
|
||||
const json = (await res.json()) as { codes?: string[]; error?: string };
|
||||
if (!res.ok || !json.codes) {
|
||||
throw new Error(json.error || 'Could not generate recovery codes.');
|
||||
}
|
||||
setCodes(json.codes);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setRegenBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{error && <p className="error">{error}</p>}
|
||||
{notice && <p className="success">{notice}</p>}
|
||||
|
||||
<div className="card">
|
||||
<h2>Your two-factor methods</h2>
|
||||
{factors.length === 0 ? (
|
||||
<p className="muted">No methods enabled.</p>
|
||||
) : (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Method</th>
|
||||
<th>Name</th>
|
||||
<th>Added</th>
|
||||
<th aria-label="actions" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{factors.map((f) => (
|
||||
<tr key={f.id}>
|
||||
<td>{TYPE_LABEL[f.type] ?? f.type}</td>
|
||||
<td>{f.friendlyName ?? '—'}</td>
|
||||
<td>{new Date(f.createdAt).toLocaleDateString()}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-sm btn-danger"
|
||||
onClick={() => remove(f.id)}
|
||||
disabled={busyId === f.id || factors.length <= 1}
|
||||
title={
|
||||
factors.length <= 1
|
||||
? 'At least one method is required'
|
||||
: 'Remove this method'
|
||||
}
|
||||
>
|
||||
{busyId === f.id ? 'Removing…' : 'Remove'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{adding === 'totp' && (
|
||||
<TotpEnrollPanel supabase={supabase} onVerified={onAdded} />
|
||||
)}
|
||||
{adding === 'webauthn' && (
|
||||
<PasskeyEnrollPanel supabase={supabase} onVerified={onAdded} />
|
||||
)}
|
||||
|
||||
{adding === null && (
|
||||
<div className="card">
|
||||
<h2>Add a method</h2>
|
||||
<div className="row">
|
||||
<button type="button" onClick={() => setAdding('totp')}>
|
||||
Add authenticator app
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => setAdding('webauthn')}
|
||||
>
|
||||
Add passkey
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<h2>Recovery codes</h2>
|
||||
<p className="muted">
|
||||
Generate a new set of single-use recovery codes. This invalidates any
|
||||
codes you were issued before.
|
||||
</p>
|
||||
{codes && <RecoveryCodesPanel codes={codes} />}
|
||||
<div className="row" style={{ marginTop: '1rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={regenerate}
|
||||
disabled={regenBusy}
|
||||
>
|
||||
{regenBusy ? 'Generating…' : 'Regenerate recovery codes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user