feat(auth): mandatory 2FA (TOTP + WebAuthn passkeys) with hard enrollment gate, AAL2 step-up, and single-use recovery codes

This commit is contained in:
Gerhard Scheikl
2026-05-31 21:38:01 +02:00
parent 129e21529c
commit e14e909700
19 changed files with 1310 additions and 142 deletions
@@ -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 });
}
+66
View File
@@ -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 });
}