feat(security): server-side guard preventing removal of last MFA factor
This commit is contained in:
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
import { createSupabaseServerClient } from '@/lib/supabase/server';
|
||||||
|
import { jsonNoStore } from '@/lib/admin/response';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove one of the current user's MFA factors. Requires an aal2 session.
|
||||||
|
*
|
||||||
|
* Mandatory MFA means every user must keep at least one VERIFIED factor. The
|
||||||
|
* client disables the "Remove" button on the last factor, but that is only a
|
||||||
|
* first line of defense — this route is the real server-side guard: if deleting
|
||||||
|
* the requested factor would leave the user with zero verified factors we
|
||||||
|
* refuse with 409 and never call GoTrue. The factor must belong to the caller.
|
||||||
|
*/
|
||||||
|
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 });
|
||||||
|
|
||||||
|
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
|
||||||
|
if (aal?.currentLevel !== 'aal2') {
|
||||||
|
return jsonNoStore({ error: 'aal2_required' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: { factorId?: string };
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as { factorId?: string };
|
||||||
|
} catch {
|
||||||
|
return jsonNoStore({ error: 'bad_request' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const factorId = (body.factorId ?? '').trim();
|
||||||
|
if (!factorId) {
|
||||||
|
return jsonNoStore({ error: 'invalid_factor' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: list, error: listErr } = await supabase.auth.mfa.listFactors();
|
||||||
|
if (listErr) return jsonNoStore({ error: 'failed' }, { status: 500 });
|
||||||
|
|
||||||
|
const all = list?.all ?? [];
|
||||||
|
const target = all.find((f) => f.id === factorId);
|
||||||
|
if (!target) {
|
||||||
|
return jsonNoStore({ error: 'invalid_factor' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only VERIFIED factors count as protection. If the user would be left with
|
||||||
|
// no verified factors after this removal, refuse.
|
||||||
|
const verified = all.filter((f) => f.status === 'verified');
|
||||||
|
const remainingVerified = verified.filter((f) => f.id !== factorId).length;
|
||||||
|
if (target.status === 'verified' && remainingVerified === 0) {
|
||||||
|
return jsonNoStore(
|
||||||
|
{ error: 'cannot_remove_last_factor' },
|
||||||
|
{ status: 409 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: unenrollErr } = await supabase.auth.mfa.unenroll({ factorId });
|
||||||
|
if (unenrollErr) {
|
||||||
|
return jsonNoStore({ error: unenrollErr.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonNoStore({ ok: true });
|
||||||
|
}
|
||||||
@@ -50,8 +50,20 @@ export function SecurityClient({
|
|||||||
setNotice(null);
|
setNotice(null);
|
||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
try {
|
try {
|
||||||
const { error } = await supabase.auth.mfa.unenroll({ factorId: id });
|
const res = await fetch('/api/security/unenroll', {
|
||||||
if (error) throw error;
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ factorId: id }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = (await res.json().catch(() => null)) as {
|
||||||
|
error?: string;
|
||||||
|
} | null;
|
||||||
|
if (json?.error === 'cannot_remove_last_factor') {
|
||||||
|
throw new Error('You must keep at least one two-factor method enabled.');
|
||||||
|
}
|
||||||
|
throw new Error(json?.error || 'Could not remove this method.');
|
||||||
|
}
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError((e as Error).message);
|
setError((e as Error).message);
|
||||||
|
|||||||
Reference in New Issue
Block a user