fix(auth): disable TOTP with one code instead of two (#2816)

This commit is contained in:
Hampus
2026-09-17 04:21:50 +02:00
committed by GitHub
parent b019f4a91f
commit 4cecbf1f43
6 changed files with 240 additions and 7 deletions
@@ -193,9 +193,9 @@ describe('Auth sudo required operations', () => {
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: backupCode,
code: 'invalid-code',
})
.expect(403)
.expect(400, 'INVALID_FORM_BODY')
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
@@ -449,9 +449,9 @@ describe('MFA Consistency Tests', () => {
await createBuilder(harness, loggedIn.token)
.post('/users/@me/mfa/totp/disable')
.body({
code: backupCodes.backup_codes[0]!.code,
code: 'invalid-code',
})
.expect(403)
.expect(400, 'INVALID_FORM_BODY')
.execute();
await createBuilder(harness, loggedIn.token)
.post('/users/@me/mfa/totp/disable')
@@ -0,0 +1,228 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type BackupCodesResponse,
createAuthHarness,
createTestAccount,
createTotpSecret,
type TestAccount,
totpCodeNow,
} from '@app/api/auth/tests/AuthTestUtils';
import {
createRegistrationResponse,
createWebAuthnDevice,
type WebAuthnRegistrationOptions,
} from '@app/api/auth/tests/WebAuthnTestUtils';
import {Config} from '@app/api/Config';
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
const SUDO_MODE_HEADER = 'X-Fluxer-Sudo-Mode-JWT';
interface ValidationErrorBody {
code: string;
errors: Array<{path: string; code: string}>;
}
async function withTotpReplayProtection<T>(run: () => Promise<T>): Promise<T> {
const previous = Config.dev.testModeEnabled;
Config.dev.testModeEnabled = false;
try {
return await run();
} finally {
Config.dev.testModeEnabled = previous;
}
}
function wrongCodeFor(code: string): string {
return ((Number(code) + 500_000) % 1_000_000).toString().padStart(6, '0');
}
async function enableTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<Array<string>> {
const response = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: totpCodeNow(secret), password: account.password})
.execute();
return response.backup_codes.map((backupCode) => backupCode.code);
}
async function loginRequiresMfa(harness: ApiTestHarness, account: TestAccount): Promise<boolean> {
const login = await createBuilderWithoutAuth<{mfa?: boolean}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
return login.mfa === true;
}
describe('MFA TOTP disable', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('disables when the sudo fields repeat the authenticator code', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await enableTotp(harness, account, secret);
await withTotpReplayProtection(async () => {
const code = totpCodeNow(secret);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({code, mfa_method: 'totp', mfa_code: code})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
expect(await loginRequiresMfa(harness, account)).toBe(false);
});
it('proves sudo mode with the sudo fields and skips code when mfa_method is set', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await enableTotp(harness, account, secret);
await withTotpReplayProtection(async () => {
const code = totpCodeNow(secret);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({code: wrongCodeFor(code), mfa_method: 'totp', mfa_code: code})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
expect(await loginRequiresMfa(harness, account)).toBe(false);
});
it('disables with an authenticator code alone', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await enableTotp(harness, account, secret);
await withTotpReplayProtection(async () => {
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({code: totpCodeNow(secret)})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
expect(await loginRequiresMfa(harness, account)).toBe(false);
});
it('disables with a backup code alone', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const backupCodes = await enableTotp(harness, account, secret);
await withTotpReplayProtection(async () => {
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({code: backupCodes[0]!})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
expect(await loginRequiresMfa(harness, account)).toBe(false);
});
it('rejects a wrong code without a sudo token and keeps TOTP enabled', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await enableTotp(harness, account, secret);
await withTotpReplayProtection(async () => {
const error = await createBuilder<ValidationErrorBody>(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({code: wrongCodeFor(totpCodeNow(secret))})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
expect(error.errors).toEqual([
{path: 'mfa_code', code: ValidationErrorCodes.INVALID_MFA_CODE, message: expect.any(String)},
]);
});
expect(await loginRequiresMfa(harness, account)).toBe(true);
});
it('rejects an authenticator code already spent on another sudo proof', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
await enableTotp(harness, account, secret);
await withTotpReplayProtection(async () => {
const code = totpCodeNow(secret);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/backup-codes')
.body({regenerate: false, mfa_method: 'totp', mfa_code: code})
.expect(HTTP_STATUS.OK)
.execute();
const error = await createBuilder<ValidationErrorBody>(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({code})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
expect(error.errors[0]?.path).toBe('mfa_code');
expect(error.errors[0]?.code).toBe(ValidationErrorCodes.INVALID_MFA_CODE);
});
expect(await loginRequiresMfa(harness, account)).toBe(true);
});
it('checks code when a sudo token proves sudo mode', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const backupCodes = await enableTotp(harness, account, secret);
await withTotpReplayProtection(async () => {
const code = totpCodeNow(secret);
const {response} = await createBuilder(harness, account.token)
.post('/users/@me/mfa/backup-codes')
.body({regenerate: false, mfa_method: 'totp', mfa_code: code})
.expect(HTTP_STATUS.OK)
.executeWithResponse();
const sudoToken = response.headers.get(SUDO_MODE_HEADER);
expect(sudoToken).toBeTruthy();
for (const rejected of [wrongCodeFor(code), code]) {
const error = await createBuilder<ValidationErrorBody>(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.header(SUDO_MODE_HEADER, sudoToken!)
.body({code: rejected})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
expect(error.errors[0]?.path).toBe('code');
expect(error.errors[0]?.code).toBe(ValidationErrorCodes.INVALID_CODE);
}
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.header(SUDO_MODE_HEADER, sudoToken!)
.body({code: backupCodes[0]!})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
});
expect(await loginRequiresMfa(harness, account)).toBe(false);
});
it('still requires sudo mode when the account has no TOTP secret', async () => {
const account = await createTestAccount(harness);
const device = createWebAuthnDevice();
const registrationOptions = await createBuilder<WebAuthnRegistrationOptions>(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials/registration-options')
.body({password: account.password})
.execute();
if (registrationOptions.rp.id) {
device.rpId = registrationOptions.rp.id;
}
await createBuilder(harness, account.token)
.post('/users/@me/mfa/webauthn/credentials')
.body({
response: createRegistrationResponse(device, registrationOptions, 'Passkey'),
challenge: registrationOptions.challenge,
name: 'Passkey',
password: account.password,
})
.expect(HTTP_STATUS.NO_CONTENT)
.execute();
await createBuilder(harness, account.token)
.post('/users/@me/mfa/totp/disable')
.body({code: '123456'})
.expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED')
.execute();
});
});
@@ -86,7 +86,9 @@ export function UserAuthController(app: HonoApp) {
async (ctx) => {
const body = ctx.req.valid('json');
const user = ctx.get('user');
const sudoResult = await requireSudoMode(ctx, user, body);
const sudoBody =
body.mfa_method || !user.totpSecret ? body : {...body, mfa_method: 'totp' as const, mfa_code: body.code};
const sudoResult = await requireSudoMode(ctx, user, sudoBody);
await ctx.get('userAuthRequestService').disableTotp({user, data: body, sudoContext: sudoResult});
return ctx.body(null, 204);
},
@@ -77,6 +77,7 @@ export async function disableMfaTotp(ctx: ApiContext, {user, code, sudoContext}:
if (!user.totpSecret) throw new MfaNotEnabledError();
assertSudoVerifiedForMfa(user, sudoContext);
if (
sudoContext.method !== 'mfa' &&
!(await AuthMfa.verifyMfaCode(ctx, {
userId: user.id,
mfaSecret: user.totpSecret,
@@ -488,7 +488,9 @@ TOTP is enabled and 10 backup codes are issued. Any backup code the account alre
<RouteHeader method="POST" path="/v1/users/@me/mfa/totp/disable" mfa />
Disables TOTP for the current account and returns 204 with an empty body. [Sudo mode](#sudo-mode) is required in addition to this route's `code` field. Emits a [User Update](/gateway/events/#user-update) Gateway event.
Disables TOTP for the current account and returns 204 with an empty body. [Sudo mode](#sudo-mode) is required. Emits a [User Update](/gateway/events/#user-update) Gateway event.
Fluxer checks one authenticator code or backup code for each request. With a valid sudo token, it checks `code`. Without one, the sudo verification fields prove sudo mode when `mfa_method` is set, and Fluxer does not check `code`. Otherwise `code` proves sudo mode as if it were `mfa_code` with `mfa_method` set to `totp`, so the attempt draws on the TOTP allowance described under [sudo mode](#sudo-mode).
TOTP must already be enabled, or the request is refused with 400 `TWO_FACTOR_REQUIRED`. A verified email is not required, so an account whose address later became unverified can still remove its authenticator.
@@ -500,7 +502,7 @@ The body extends the [sudo verification object](#sudo-verification-object) with
| --- | --- | --- |
| code<sup>1</sup> | string | Current TOTP code or an unconsumed backup code (1-32 characters) |
<sup>1</sup> A wrong value returns `INVALID_CODE` on the path `code`
<sup>1</sup> A wrong value returns `INVALID_CODE` on the path `code` with a sudo token, and `INVALID_MFA_CODE` on the path `mfa_code` when `code` proves sudo mode
:::caution[Backup codes do not survive TOTP removal]
Disabling TOTP invalidates every backup code on the account, including the code that proved this request. An account that keeps a WebAuthn credential is left with none, and needs [List MFA backup codes](#list-mfa-backup-codes) with `regenerate` set to true to issue a new set.