diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 750b0a3..bd2654b 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -937,10 +937,16 @@ function parseMfaData(mfaSecret: string | null | undefined): MfaData | null { export async function generateMfaSetup(userId: number): Promise<{ secret: string; qrDataUrl: string; -} | null> { +} | { alreadyEnabled: true } | null> { const user = await getUser(userId); if (!user) return null; + // Never regenerate over a LIVE enrolment. Overwriting the stored secret + clearing + // backupCodes while mfaEnabled stays true locks the account out for good (#1399): the + // authenticator holds the old secret, no backup code can match, and login keeps demanding + // a code. To re-enrol, the user must disable MFA first (DELETE), then set it up fresh. + if (user.mfaEnabled) return { alreadyEnabled: true }; + // Build issuer name with hostname (same as license matching) const hostname = process.env.DOCKHAND_HOSTNAME || os.hostname(); const issuer = `Dockhand (${hostname})`; diff --git a/src/routes/api/users/[id]/mfa/+server.ts b/src/routes/api/users/[id]/mfa/+server.ts index f9749eb..6faf0f8 100644 --- a/src/routes/api/users/[id]/mfa/+server.ts +++ b/src/routes/api/users/[id]/mfa/+server.ts @@ -55,11 +55,24 @@ export const POST: RequestHandler = async (event) => { }); } + // Only an explicit setup request regenerates. Treating "anything not verify" as + // "regenerate" let a stray/empty POST (a restored tab, a refresh) destroy a live MFA + // enrolment (#1399). An empty body still means setup for backward-compat with the current + // client, but a body with a DIFFERENT action is rejected rather than silently regenerating. + if (body.action && body.action !== 'setup') { + return json({ error: `Unknown MFA action "${body.action}"` }, { status: 400 }); + } + // Generate new MFA setup const setup = await generateMfaSetup(userId); if (!setup) { return json({ error: 'User not found' }, { status: 404 }); } + if ('alreadyEnabled' in setup) { + // Refuse to overwrite a live enrolment - would lock the user out (#1399). They must + // disable MFA first, then set it up again. + return json({ error: 'MFA is already enabled. Disable it before setting it up again.' }, { status: 409 }); + } return json({ secret: setup.secret,