From 706c41aad921248461f959cf63b51f4838b83e6b Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 3 Oct 2026 02:11:14 +0200 Subject: [PATCH] fix(auth): check the TOTP setup code before asking for sudo (#3146) --- .../auth/tests/MfaTotpEnableCodeCheck.test.ts | 57 +++++++++++++++++++ .../user/controllers/UserAuthController.ts | 2 + fluxer_api/src/api/user/services/UserAuth.ts | 15 +++++ 3 files changed, 74 insertions(+) create mode 100644 fluxer_api/src/api/auth/tests/MfaTotpEnableCodeCheck.test.ts diff --git a/fluxer_api/src/api/auth/tests/MfaTotpEnableCodeCheck.test.ts b/fluxer_api/src/api/auth/tests/MfaTotpEnableCodeCheck.test.ts new file mode 100644 index 000000000..141789a9e --- /dev/null +++ b/fluxer_api/src/api/auth/tests/MfaTotpEnableCodeCheck.test.ts @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {createAuthHarness, createTestAccount, createTotpSecret, totpCodeNow} from '@app/api/auth/tests/AuthTestUtils'; +import type {ApiTestHarness} from '@app/api/test/ApiTestHarness'; +import {HTTP_STATUS} from '@app/api/test/TestConstants'; +import {createBuilder} from '@app/api/test/TestRequestBuilder'; +import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; + +interface ValidationErrorBody { + code: string; + errors: Array<{path: string; code: string}>; +} + +function wrongCodeFor(code: string): string { + return ((Number(code) + 500_000) % 1_000_000).toString().padStart(6, '0'); +} + +describe('Enabling TOTP checks the setup code before sudo', () => { + let harness: ApiTestHarness; + beforeAll(async () => { + harness = await createAuthHarness(); + }); + beforeEach(async () => { + await harness.reset(); + }); + afterAll(async () => { + await harness?.shutdown(); + }); + it('rejects a wrong setup code on the code field without asking for a password', async () => { + const account = await createTestAccount(harness); + const secret = createTotpSecret(); + const error = await createBuilder(harness, account.token) + .post('/users/@me/mfa/totp/enable') + .body({secret, code: wrongCodeFor(totpCodeNow(secret))}) + .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); + }); + it('asks for sudo for a valid setup code, then enables with the password', async () => { + const account = await createTestAccount(harness); + const secret = createTotpSecret(); + const code = totpCodeNow(secret); + await createBuilder(harness, account.token) + .post('/users/@me/mfa/totp/enable') + .body({secret, code}) + .expect(HTTP_STATUS.FORBIDDEN, 'SUDO_MODE_REQUIRED') + .execute(); + const enabled = await createBuilder<{backup_codes: Array<{code: string}>}>(harness, account.token) + .post('/users/@me/mfa/totp/enable') + .body({secret, code, password: account.password}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(enabled.backup_codes.length).toBeGreaterThan(0); + }); +}); diff --git a/fluxer_api/src/api/user/controllers/UserAuthController.ts b/fluxer_api/src/api/user/controllers/UserAuthController.ts index 554b497ad..04f9e7e05 100644 --- a/fluxer_api/src/api/user/controllers/UserAuthController.ts +++ b/fluxer_api/src/api/user/controllers/UserAuthController.ts @@ -12,6 +12,7 @@ import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware'; import {SudoModeMiddleware} from '@app/api/middleware/SudoModeMiddleware'; import {RateLimitConfigs} from '@app/api/RateLimitConfig'; import type {HonoApp} from '@app/api/types/HonoEnv'; +import {assertValidTotpSetupCode} from '@app/api/user/services/UserAuth'; import {Validator} from '@app/api/Validator'; import { DisableTotpRequest, @@ -60,6 +61,7 @@ export function UserAuthController(app: HonoApp) { async (ctx) => { const body = ctx.req.valid('json'); const user = ctx.get('user'); + await assertValidTotpSetupCode(body.secret, body.code); const sudoResult = await requireSudoMode(ctx, user, body); return ctx.json( await ctx.get('userAuthRequestService').enableTotp({ diff --git a/fluxer_api/src/api/user/services/UserAuth.ts b/fluxer_api/src/api/user/services/UserAuth.ts index c04a1e350..1add1ff62 100644 --- a/fluxer_api/src/api/user/services/UserAuth.ts +++ b/fluxer_api/src/api/user/services/UserAuth.ts @@ -8,6 +8,7 @@ import type {SudoVerificationResult} from '@app/api/auth/services/SudoVerificati import type {MfaBackupCode} from '@app/api/models/MfaBackupCode'; import type {User} from '@app/api/models/User'; import {mapUserToPrivateResponse} from '@app/api/user/UserMappers'; +import {TotpGenerator} from '@app/api/utils/TotpGenerator'; import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {MfaNotDisabledError} from '@fluxer/errors/src/domains/auth/MfaNotDisabledError'; @@ -55,6 +56,20 @@ async function assertSudoVerifiedForMfa( ); } +async function isValidTotpSetupCode(secret: string, code: string): Promise { + try { + return await new TotpGenerator(secret).validateTotp(code); + } catch { + return false; + } +} + +export async function assertValidTotpSetupCode(secret: string, code: string): Promise { + if (!(await isValidTotpSetupCode(secret, code))) { + throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE); + } +} + export async function enableMfaTotp( ctx: ApiContext, {user, secret, code, sudoContext}: EnableMfaTotpParams,