From 16ae98e189d1674ff857c53c965549fd234c1d7f Mon Sep 17 00:00:00 2001 From: Hampus Date: Thu, 3 Sep 2026 21:29:16 +0200 Subject: [PATCH] fix(auth): regenerate backup codes with the emailed challenge (#2436) --- fluxer_api/src/api/openapi/openapi.json | 109 +++++++++++++- .../rate_limit_configs/UserRateLimitConfig.ts | 4 + .../user/controllers/UserAuthController.ts | 30 +++- .../MfaBackupCodesChallengeService.ts | 57 ++++++- fluxer_api/src/api/user/services/UserAuth.ts | 2 +- .../tests/MfaBackupCodesChallengeFlow.test.ts | 141 +++++++++++++++++- .../src/features/app/constants/Endpoints.ts | 1 + .../src/features/auth/commands/MfaCommands.ts | 59 +++++++- .../components/modals/BackupCodesModal.tsx | 5 +- .../modals/BackupCodesRegenerateModal.tsx | 28 +++- .../modals/BackupCodesViewModal.tsx | 6 +- .../src/features/auth/flow/MfaScreen.tsx | 2 +- .../schema/src/domains/auth/AuthSchemas.ts | 12 ++ 13 files changed, 427 insertions(+), 29 deletions(-) diff --git a/fluxer_api/src/api/openapi/openapi.json b/fluxer_api/src/api/openapi/openapi.json index 9940fe274..b84180023 100644 --- a/fluxer_api/src/api/openapi/openapi.json +++ b/fluxer_api/src/api/openapi/openapi.json @@ -18400,6 +18400,79 @@ } } }, + "/users/@me/mfa/backup-codes/challenge/regenerate": { + "post": { + "operationId": "regenerate_backup_codes_challenge", + "summary": "Regenerate backup codes with a verified challenge", + "tags": ["Users"], + "responses": { + "200": { + "description": "Success", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MfaBackupCodesResponse"}}} + }, + "400": { + "description": "Bad Request - The request was malformed or contained invalid data", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "401": { + "description": "Unauthorized - Authentication is required or the token is invalid", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "403": { + "description": "Forbidden - You do not have permission to perform this action", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + }, + "429": { + "description": "Too Many Requests - You are being rate limited", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": {"type": "string", "enum": ["RATE_LIMITED"]}, + "message": {"type": "string"}, + "retry_after": {"type": "number", "description": "Seconds to wait before retrying"}, + "global": {"type": "boolean", "description": "Whether this is a global rate limit"} + }, + "required": ["code", "message", "retry_after"] + } + } + }, + "headers": { + "Retry-After": { + "description": "Number of seconds to wait before retrying (only on 429)", + "schema": {"type": "integer"} + }, + "X-RateLimit-Limit": { + "description": "The number of requests that can be made in the current window", + "schema": {"type": "integer"} + }, + "X-RateLimit-Remaining": { + "description": "The number of remaining requests that can be made", + "schema": {"type": "integer"} + }, + "X-RateLimit-Reset": { + "description": "Unix timestamp when the rate limit resets", + "schema": {"type": "integer"} + } + } + }, + "500": { + "description": "Internal Server Error - An unexpected error occurred", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + }, + "x-mint": {"metadata": {"title": "Regenerate backup codes with a verified challenge"}}, + "description": "Replaces the account backup codes using the proof token from a verified backup codes challenge. Old codes are invalidated.", + "security": [{"sessionToken": []}], + "requestBody": { + "required": true, + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/MfaBackupCodesChallengeRegenerateRequest"}} + } + } + } + }, "/users/@me/mfa/backup-codes/challenge/resend": { "post": { "operationId": "resend_backup_codes_challenge", @@ -18478,7 +18551,9 @@ "responses": { "200": { "description": "Success", - "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MfaBackupCodesResponse"}}} + "content": { + "application/json": {"schema": {"$ref": "#/components/schemas/MfaBackupCodesChallengeVerifyResponse"}} + } }, "400": { "description": "Bad Request - The request was malformed or contained invalid data", @@ -18533,7 +18608,7 @@ } }, "x-mint": {"metadata": {"title": "Verify backup codes challenge code"}}, - "description": "Verifies the email code sent during a backup codes challenge and returns the existing backup codes. The ticket is consumed on success.", + "description": "Verifies the email code sent during a backup codes challenge and returns the existing backup codes along with a proof token. The code is consumed on success and the proof token authorizes regeneration on the same ticket.", "security": [{"sessionToken": []}], "requestBody": { "required": true, @@ -33526,11 +33601,41 @@ }, "required": ["ticket", "code_expires_at", "resend_available_at"] }, + "MfaBackupCodesChallengeRegenerateRequest": { + "type": "object", + "properties": { + "ticket": {"type": "string", "description": "Backup codes challenge ticket identifier"}, + "verification_proof": {"type": "string", "description": "Proof token obtained from verifying the email code"} + }, + "required": ["ticket", "verification_proof"] + }, "MfaBackupCodesChallengeResendRequest": { "type": "object", "properties": {"ticket": {"type": "string", "description": "Backup codes challenge ticket identifier"}}, "required": ["ticket"] }, + "MfaBackupCodesChallengeVerifyResponse": { + "type": "object", + "properties": { + "backup_codes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "code": {"type": "string", "description": "The backup code"}, + "consumed": {"type": "boolean", "description": "Whether the code has been used"} + }, + "required": ["code", "consumed"] + }, + "description": "List of backup codes" + }, + "verification_proof": { + "type": "string", + "description": "Proof token authorizing backup code regeneration on this ticket" + } + }, + "required": ["backup_codes", "verification_proof"] + }, "MfaBackupCodesChallengeVerifyRequest": { "type": "object", "properties": { diff --git a/fluxer_api/src/api/rate_limit_configs/UserRateLimitConfig.ts b/fluxer_api/src/api/rate_limit_configs/UserRateLimitConfig.ts index e5af6086c..9f7f276ac 100644 --- a/fluxer_api/src/api/rate_limit_configs/UserRateLimitConfig.ts +++ b/fluxer_api/src/api/rate_limit_configs/UserRateLimitConfig.ts @@ -204,6 +204,10 @@ export const UserRateLimitConfigs = { bucket: 'user:mfa:backup_codes_challenge:verify', config: {limit: 20, windowMs: ms('1 minute')}, } as RouteRateLimitConfig, + USER_MFA_BACKUP_CODES_CHALLENGE_REGENERATE: { + bucket: 'user:mfa:backup_codes_challenge:regenerate', + config: {limit: 6, windowMs: ms('1 minute')}, + } as RouteRateLimitConfig, USER_PUSH_SUBSCRIBE: { bucket: 'user:push:subscribe', config: {limit: 20, windowMs: ms('1 minute')}, diff --git a/fluxer_api/src/api/user/controllers/UserAuthController.ts b/fluxer_api/src/api/user/controllers/UserAuthController.ts index 722f4cba0..5edb572cb 100644 --- a/fluxer_api/src/api/user/controllers/UserAuthController.ts +++ b/fluxer_api/src/api/user/controllers/UserAuthController.ts @@ -5,9 +5,11 @@ import { DisableTotpRequest, EnableMfaTotpRequest, InboundSmsChallengeStartResponse, + MfaBackupCodesChallengeRegenerateRequest, MfaBackupCodesChallengeResendRequest, MfaBackupCodesChallengeStartResponse, MfaBackupCodesChallengeVerifyRequest, + MfaBackupCodesChallengeVerifyResponse, MfaBackupCodesRequest, MfaBackupCodesResponse, PhoneSendVerificationRequest, @@ -168,12 +170,12 @@ export function UserAuthController(app: HonoApp) { OpenAPI({ operationId: 'verify_backup_codes_challenge', summary: 'Verify backup codes challenge code', - responseSchema: MfaBackupCodesResponse, + responseSchema: MfaBackupCodesChallengeVerifyResponse, statusCode: 200, security: ['bearerToken', 'sessionToken'], tags: ['Users'], description: - 'Verifies the email code sent during a backup codes challenge and returns the existing backup codes. The ticket is consumed on success.', + 'Verifies the email code sent during a backup codes challenge and returns the existing backup codes along with a proof token. The code is consumed on success and the proof token authorizes regeneration on the same ticket.', }), async (ctx) => { const user = ctx.get('user'); @@ -181,6 +183,30 @@ export function UserAuthController(app: HonoApp) { return ctx.json(await ctx.get('mfaBackupCodesChallengeService').verify(user, body.ticket, body.code)); }, ); + app.post( + '/users/@me/mfa/backup-codes/challenge/regenerate', + RateLimitMiddleware(RateLimitConfigs.USER_MFA_BACKUP_CODES_CHALLENGE_REGENERATE), + LoginRequired, + DefaultUserOnly, + Validator('json', MfaBackupCodesChallengeRegenerateRequest), + OpenAPI({ + operationId: 'regenerate_backup_codes_challenge', + summary: 'Regenerate backup codes with a verified challenge', + responseSchema: MfaBackupCodesResponse, + statusCode: 200, + security: ['bearerToken', 'sessionToken'], + tags: ['Users'], + description: + 'Replaces the account backup codes using the proof token from a verified backup codes challenge. Old codes are invalidated.', + }), + async (ctx) => { + const user = ctx.get('user'); + const body = ctx.req.valid('json'); + return ctx.json( + await ctx.get('mfaBackupCodesChallengeService').regenerate(user, body.ticket, body.verification_proof), + ); + }, + ); app.post( '/users/@me/phone/send-verification', RateLimitMiddleware(RateLimitConfigs.PHONE_SEND_VERIFICATION), diff --git a/fluxer_api/src/api/user/services/MfaBackupCodesChallengeService.ts b/fluxer_api/src/api/user/services/MfaBackupCodesChallengeService.ts index ab5be5675..93f738581 100644 --- a/fluxer_api/src/api/user/services/MfaBackupCodesChallengeService.ts +++ b/fluxer_api/src/api/user/services/MfaBackupCodesChallengeService.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {randomUUID} from 'node:crypto'; +import {randomUUID, timingSafeEqual} from 'node:crypto'; import { assertChangeCooldown, checkChangeRateLimit, @@ -10,17 +10,23 @@ import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants'; import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes'; import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError'; import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError'; -import type {MfaBackupCodesResponse} from '@fluxer/schema/src/domains/auth/AuthSchemas'; +import type { + MfaBackupCodesChallengeVerifyResponse, + MfaBackupCodesResponse, +} from '@fluxer/schema/src/domains/auth/AuthSchemas'; import {ms} from 'itty-time'; import type {ApiContext} from '../../ApiContext'; import {requireEmailVerified} from '../../auth/EmailVerificationUtils'; +import type {MfaBackupCode} from '../../models/MfaBackupCode'; import type {User} from '../../models/User'; +import {regenerateMfaBackupCodes} from './UserAuth'; interface MfaBackupCodesChallengeTicket { user_id: string; - code: string; + code: string | null; code_sent_at: number; code_expires_at: number; + verification_proof: string | null; } interface StartMfaBackupCodesChallengeResult { @@ -37,6 +43,15 @@ function normalizeVerificationCode(code: string): string { return code.trim().toUpperCase().replaceAll('-', ''); } +function constantTimeEquals(a: string, b: string): boolean { + const bufferA = Buffer.from(a); + const bufferB = Buffer.from(b); + if (bufferA.length !== bufferB.length) { + return false; + } + return timingSafeEqual(bufferA, bufferB); +} + export class MfaBackupCodesChallengeService { private readonly CODE_TTL_MS = ms('10 minutes'); private readonly TICKET_TTL_MS = ms('30 minutes'); @@ -68,6 +83,7 @@ export class MfaBackupCodesChallengeService { code, code_sent_at: now, code_expires_at: codeExpiresAt, + verification_proof: null, }); return { ticket, @@ -97,25 +113,54 @@ export class MfaBackupCodesChallengeService { code, code_sent_at: now, code_expires_at: codeExpiresAt, + verification_proof: null, }); } - async verify(user: User, ticket: string, code: string): Promise { - const {cache, rateLimit, users} = this.apiContext.services; + async verify(user: User, ticket: string, code: string): Promise { + const {rateLimit, users} = this.apiContext.services; const state = await this.getTicketForUser(ticket, user); await checkChangeRateLimit(rateLimit, { identifier: `mfa_backup_codes_challenge:verify:${ticket}`, maxAttempts: 5, windowMs: ms('15 minutes'), }); + if (!state.code) { + throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_NOT_ISSUED); + } if (state.code_expires_at < Date.now()) { throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_EXPIRED); } if (normalizeVerificationCode(state.code) !== normalizeVerificationCode(code)) { throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_VERIFICATION_CODE); } - await cache.delete(getChallengeCacheKey(ticket)); + const verificationProof = randomUUID(); + await this.storeTicket(ticket, {...state, code: null, verification_proof: verificationProof}); const backupCodes = await users.listMfaBackupCodes(user.id); + return {...this.toResponse(backupCodes), verification_proof: verificationProof}; + } + + async regenerate(user: User, ticket: string, verificationProof: string): Promise { + const {rateLimit} = this.apiContext.services; + const state = await this.getTicketForUser(ticket, user); + await checkChangeRateLimit(rateLimit, { + identifier: `mfa_backup_codes_challenge:regenerate:${ticket}`, + maxAttempts: 5, + windowMs: ms('15 minutes'), + }); + if (!user.totpSecret || !user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP)) { + throw new MfaNotEnabledError(); + } + if (!state.verification_proof) { + throw InputValidationError.fromCode('verification_proof', ValidationErrorCodes.INVALID_OR_EXPIRED_TICKET); + } + if (!constantTimeEquals(state.verification_proof, verificationProof)) { + throw InputValidationError.fromCode('verification_proof', ValidationErrorCodes.INVALID_PROOF_TOKEN); + } + return this.toResponse(await regenerateMfaBackupCodes(this.apiContext, user)); + } + + private toResponse(backupCodes: Array): MfaBackupCodesResponse { return { backup_codes: backupCodes.map((backupCode) => ({ code: backupCode.code, diff --git a/fluxer_api/src/api/user/services/UserAuth.ts b/fluxer_api/src/api/user/services/UserAuth.ts index 9b1e0be45..5d0301699 100644 --- a/fluxer_api/src/api/user/services/UserAuth.ts +++ b/fluxer_api/src/api/user/services/UserAuth.ts @@ -118,7 +118,7 @@ export async function getMfaBackupCodes( return await users.listMfaBackupCodes(user.id); } -async function regenerateMfaBackupCodes(ctx: ApiContext, user: User): Promise> { +export async function regenerateMfaBackupCodes(ctx: ApiContext, user: User): Promise> { const {users} = ctx.services; const userId = user.id; const newBackupCodes = AuthUtility.generateBackupCodes(ctx); diff --git a/fluxer_api/src/api/user/tests/MfaBackupCodesChallengeFlow.test.ts b/fluxer_api/src/api/user/tests/MfaBackupCodesChallengeFlow.test.ts index 541c926cf..3f9802f78 100644 --- a/fluxer_api/src/api/user/tests/MfaBackupCodesChallengeFlow.test.ts +++ b/fluxer_api/src/api/user/tests/MfaBackupCodesChallengeFlow.test.ts @@ -25,6 +25,14 @@ interface ChallengeVerifyResult { code: string; consumed: boolean; }>; + verification_proof: string; +} + +interface ChallengeRegenerateResult { + backup_codes: Array<{ + code: string; + consumed: boolean; + }>; } interface ValidationErrorBody { @@ -34,8 +42,11 @@ interface ValidationErrorBody { }>; } -async function enableTotp(harness: ApiTestHarness, account: TestAccount): Promise> { - const secret = createTotpSecret(); +async function enableTotpWithSecret( + harness: ApiTestHarness, + account: TestAccount, + secret: string, +): Promise> { const enabled = await createBuilder(harness, account.token) .post('/users/@me/mfa/totp/enable') .body({secret, code: totpCodeNow(secret), password: account.password}) @@ -43,6 +54,10 @@ async function enableTotp(harness: ApiTestHarness, account: TestAccount): Promis return enabled.backup_codes.map((backupCode) => backupCode.code); } +async function enableTotp(harness: ApiTestHarness, account: TestAccount): Promise> { + return enableTotpWithSecret(harness, account, createTotpSecret()); +} + async function createTotpAccount(harness: ApiTestHarness): Promise<{ account: TestAccount; backupCodes: Array; @@ -71,6 +86,18 @@ async function verifyChallenge( .execute(); } +async function regenerateWithChallenge( + harness: ApiTestHarness, + token: string, + ticket: string, + verificationProof: string, +): Promise { + return createBuilder(harness, token) + .post('/users/@me/mfa/backup-codes/challenge/regenerate') + .body({ticket, verification_proof: verificationProof}) + .execute(); +} + async function getChallengeCode(harness: ApiTestHarness, email: string): Promise { const emails = await listTestEmails(harness, {recipient: email}); const record = findLastTestEmail(emails, 'mfa_backup_codes_view'); @@ -184,17 +211,18 @@ describe('MfaBackupCodesChallengeFlow', () => { const result = await verifyChallenge(harness, account.token, startResult.ticket, mangled); expect(result.backup_codes.map((backupCode) => backupCode.code).sort()).toEqual([...backupCodes].sort()); }); - test('rejects reusing a ticket that was already verified', async () => { + test('consumes the emailed code so it cannot be verified twice', async () => { const {account} = await createTotpAccount(harness); const startResult = await startChallenge(harness, account.token); const code = await getChallengeCode(harness, account.email); - await verifyChallenge(harness, account.token, startResult.ticket, code); + const first = await verifyChallenge(harness, account.token, startResult.ticket, code); + expect(first.verification_proof).toBeTruthy(); const {json} = await createBuilder(harness, account.token) .post('/users/@me/mfa/backup-codes/challenge/verify') .body({ticket: startResult.ticket, code}) .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') .executeWithResponse(); - expect((json as ValidationErrorBody).errors?.[0]?.code).toBe('INVALID_OR_EXPIRED_TICKET'); + expect((json as ValidationErrorBody).errors?.[0]?.code).toBe('VERIFICATION_CODE_NOT_ISSUED'); }); test('rejects an unknown ticket', async () => { const {account} = await createTotpAccount(harness); @@ -227,4 +255,107 @@ describe('MfaBackupCodesChallengeFlow', () => { .execute(); }); }); + describe('regenerate', () => { + test('replaces the backup codes using the verification proof', async () => { + const {account, backupCodes} = await createTotpAccount(harness); + const startResult = await startChallenge(harness, account.token); + const code = await getChallengeCode(harness, account.email); + const verified = await verifyChallenge(harness, account.token, startResult.ticket, code); + const regenerated = await regenerateWithChallenge( + harness, + account.token, + startResult.ticket, + verified.verification_proof, + ); + expect(regenerated.backup_codes.length).toBe(backupCodes.length); + const before = new Set(backupCodes); + expect(regenerated.backup_codes.every((backupCode) => !before.has(backupCode.code))).toBe(true); + for (const backupCode of regenerated.backup_codes) { + expect(backupCode.consumed).toBe(false); + } + }); + test('rejects an incorrect verification proof', async () => { + const {account, backupCodes} = await createTotpAccount(harness); + const startResult = await startChallenge(harness, account.token); + const code = await getChallengeCode(harness, account.email); + const verified = await verifyChallenge(harness, account.token, startResult.ticket, code); + const wrongProof = `${verified.verification_proof.slice(0, -1)}${verified.verification_proof.endsWith('a') ? 'b' : 'a'}`; + const {json} = await createBuilder(harness, account.token) + .post('/users/@me/mfa/backup-codes/challenge/regenerate') + .body({ticket: startResult.ticket, verification_proof: wrongProof}) + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .executeWithResponse(); + expect((json as ValidationErrorBody).errors?.[0]?.code).toBe('INVALID_PROOF_TOKEN'); + const reverify = await createBuilder(harness, account.token) + .post('/users/@me/mfa/backup-codes/challenge/verify') + .body({ticket: startResult.ticket, code}) + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .executeWithResponse(); + expect((reverify.json as ValidationErrorBody).errors?.[0]?.code).toBe('VERIFICATION_CODE_NOT_ISSUED'); + const regenerated = await regenerateWithChallenge( + harness, + account.token, + startResult.ticket, + verified.verification_proof, + ); + expect(regenerated.backup_codes.map((backupCode) => backupCode.code).sort()).not.toEqual([...backupCodes].sort()); + }); + test('rejects a ticket that has not been verified yet', async () => { + const {account} = await createTotpAccount(harness); + const startResult = await startChallenge(harness, account.token); + const {json} = await createBuilder(harness, account.token) + .post('/users/@me/mfa/backup-codes/challenge/regenerate') + .body({ticket: startResult.ticket, verification_proof: 'not-a-real-proof'}) + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .executeWithResponse(); + expect((json as ValidationErrorBody).errors?.[0]?.code).toBe('INVALID_OR_EXPIRED_TICKET'); + }); + test('rejects a verified ticket belonging to another user', async () => { + const owner = await createTotpAccount(harness); + const other = await createTotpAccount(harness); + const startResult = await startChallenge(harness, owner.account.token); + const code = await getChallengeCode(harness, owner.account.email); + const verified = await verifyChallenge(harness, owner.account.token, startResult.ticket, code); + const {json} = await createBuilder(harness, other.account.token) + .post('/users/@me/mfa/backup-codes/challenge/regenerate') + .body({ticket: startResult.ticket, verification_proof: verified.verification_proof}) + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .executeWithResponse(); + expect((json as ValidationErrorBody).errors?.[0]?.code).toBe('INVALID_OR_EXPIRED_TICKET'); + }); + test('rejects an unknown ticket', async () => { + const {account} = await createTotpAccount(harness); + const {json} = await createBuilder(harness, account.token) + .post('/users/@me/mfa/backup-codes/challenge/regenerate') + .body({ticket: 'nonexistent-ticket', verification_proof: 'whatever'}) + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .executeWithResponse(); + expect((json as ValidationErrorBody).errors?.[0]?.code).toBe('INVALID_OR_EXPIRED_TICKET'); + }); + test('rejects a proof once TOTP has been disabled', async () => { + const account = await createTestAccount(harness); + const secret = createTotpSecret(); + const backupCodes = await enableTotpWithSecret(harness, account, secret); + const startResult = await startChallenge(harness, account.token); + const code = await getChallengeCode(harness, account.email); + const verified = await verifyChallenge(harness, account.token, startResult.ticket, code); + await createBuilder(harness, account.token) + .post('/users/@me/mfa/totp/disable') + .body({code: backupCodes[0]!, mfa_method: 'totp', mfa_code: totpCodeNow(secret)}) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + await createBuilder(harness, account.token) + .post('/users/@me/mfa/backup-codes/challenge/regenerate') + .body({ticket: startResult.ticket, verification_proof: verified.verification_proof}) + .expect(HTTP_STATUS.BAD_REQUEST, 'TWO_FACTOR_REQUIRED') + .execute(); + }); + test('requires authentication', async () => { + await createBuilderWithoutAuth(harness) + .post('/users/@me/mfa/backup-codes/challenge/regenerate') + .body({ticket: 'some-ticket', verification_proof: 'some-proof'}) + .expect(HTTP_STATUS.UNAUTHORIZED) + .execute(); + }); + }); }); diff --git a/fluxer_app/src/features/app/constants/Endpoints.ts b/fluxer_app/src/features/app/constants/Endpoints.ts index b0d6182d7..36104239d 100644 --- a/fluxer_app/src/features/app/constants/Endpoints.ts +++ b/fluxer_app/src/features/app/constants/Endpoints.ts @@ -163,6 +163,7 @@ export const Endpoints = { USER_MFA_BACKUP_CODES_CHALLENGE_START: '/users/@me/mfa/backup-codes/challenge', USER_MFA_BACKUP_CODES_CHALLENGE_RESEND: '/users/@me/mfa/backup-codes/challenge/resend', USER_MFA_BACKUP_CODES_CHALLENGE_VERIFY: '/users/@me/mfa/backup-codes/challenge/verify', + USER_MFA_BACKUP_CODES_CHALLENGE_REGENERATE: '/users/@me/mfa/backup-codes/challenge/regenerate', USER_MFA_TOTP_DISABLE: '/users/@me/mfa/totp/disable', USER_MFA_TOTP_ENABLE: '/users/@me/mfa/totp/enable', USER_AUTHORIZED_IPS: '/users/@me/authorized-ips', diff --git a/fluxer_app/src/features/auth/commands/MfaCommands.ts b/fluxer_app/src/features/auth/commands/MfaCommands.ts index 073226f4c..61745dbd7 100644 --- a/fluxer_app/src/features/auth/commands/MfaCommands.ts +++ b/fluxer_app/src/features/auth/commands/MfaCommands.ts @@ -18,6 +18,25 @@ interface BackupCodesChallengeResponse { resend_available_at: string | null; } +interface BackupCodesChallengeVerifyResponse extends BackupCodesResponse { + verification_proof: string; +} + +export interface BackupCodesChallenge { + ticket: string; + verificationProof: string; +} + +const CHALLENGE_CREDENTIAL_PATHS = new Set(['ticket', 'verification_proof']); + +export function isExpiredBackupCodesChallengeError(error: unknown): boolean { + if (!error || typeof error !== 'object' || !('body' in error)) { + return false; + } + const body = (error as {body?: {errors?: Array<{path?: string}>}}).body; + return body?.errors?.some((validationError) => CHALLENGE_CREDENTIAL_PATHS.has(validationError.path ?? '')) ?? false; +} + type BackupCodeMode = 'fetch' | 'regenerate'; function backupCodeMode(regenerate: boolean): BackupCodeMode { @@ -53,9 +72,23 @@ async function requestBackupCodesChallengeResend(ticket: string): Promise await http.post(Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_RESEND, {body: {ticket}}); } -async function requestBackupCodesChallengeVerify(ticket: string, code: string): Promise> { - const response = await http.post(Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_VERIFY, { - body: {ticket, code}, +async function requestBackupCodesChallengeVerify( + ticket: string, + code: string, +): Promise { + const response = await http.post( + Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_VERIFY, + {body: {ticket, code}}, + ); + return response.body; +} + +async function requestBackupCodesChallengeRegenerate( + ticket: string, + verificationProof: string, +): Promise> { + const response = await http.post(Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_REGENERATE, { + body: {ticket, verification_proof: verificationProof}, }); return response.body.backup_codes; } @@ -120,13 +153,27 @@ export async function resendBackupCodesChallengeCode(ticket: string): Promise> { +export async function verifyBackupCodesChallenge( + ticket: string, + code: string, +): Promise { try { logger.debug('Verifying MFA backup codes challenge code'); - const backupCodes = await requestBackupCodesChallengeVerify(ticket, code); + const result = await requestBackupCodesChallengeVerify(ticket, code); logger.debug('Successfully verified MFA backup codes challenge code'); - return backupCodes; + return result; } catch (error) { rethrowMfaFailure('Failed to verify MFA backup codes challenge code:', error); } } + +export async function regenerateBackupCodesWithChallenge(challenge: BackupCodesChallenge): Promise> { + try { + logger.debug('Regenerating MFA backup codes with a verified challenge'); + const backupCodes = await requestBackupCodesChallengeRegenerate(challenge.ticket, challenge.verificationProof); + logger.debug('Successfully regenerated MFA backup codes with a verified challenge'); + return backupCodes; + } catch (error) { + rethrowMfaFailure('Failed to regenerate MFA backup codes with a verified challenge:', error); + } +} diff --git a/fluxer_app/src/features/auth/components/modals/BackupCodesModal.tsx b/fluxer_app/src/features/auth/components/modals/BackupCodesModal.tsx index 982f4f4f6..52a44faf4 100644 --- a/fluxer_app/src/features/auth/components/modals/BackupCodesModal.tsx +++ b/fluxer_app/src/features/auth/components/modals/BackupCodesModal.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import * as Modal from '@app/features/app/components/dialogs/Modal'; +import type {BackupCodesChallenge} from '@app/features/auth/commands/MfaCommands'; import styles from '@app/features/auth/components/modals/BackupCodesModal.module.css'; import {BackupCodesRegenerateModal} from '@app/features/auth/components/modals/BackupCodesRegenerateModal'; import {Button} from '@app/features/ui/button/Button'; @@ -22,9 +23,10 @@ const BACKUP_CODES_DESCRIPTOR = msg({ interface BackupCodesModalProps { backupCodes: ReadonlyArray; user: User; + challenge?: BackupCodesChallenge; } -export const BackupCodesModal = observer(({backupCodes, user}: BackupCodesModalProps) => { +export const BackupCodesModal = observer(({backupCodes, user, challenge}: BackupCodesModalProps) => { const {i18n} = useLingui(); return ( @@ -101,6 +103,7 @@ export const BackupCodesModal = observer(({backupCodes, user}: BackupCodesModalP modal(() => ( )), diff --git a/fluxer_app/src/features/auth/components/modals/BackupCodesRegenerateModal.tsx b/fluxer_app/src/features/auth/components/modals/BackupCodesRegenerateModal.tsx index e29dfd59a..2a8d8dbfe 100644 --- a/fluxer_app/src/features/auth/components/modals/BackupCodesRegenerateModal.tsx +++ b/fluxer_app/src/features/auth/components/modals/BackupCodesRegenerateModal.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import * as Modal from '@app/features/app/components/dialogs/Modal'; +import type {BackupCodesChallenge} from '@app/features/auth/commands/MfaCommands'; import * as MfaCommands from '@app/features/auth/commands/MfaCommands'; import {BackupCodesModal} from '@app/features/auth/components/modals/BackupCodesModal'; import {Button} from '@app/features/ui/button/Button'; @@ -9,6 +10,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands'; import * as ToastCommands from '@app/features/ui/commands/ToastCommands'; import type {User} from '@app/features/user/models/User'; import * as FormUtils from '@app/lib/forms'; +import type {BackupCode} from '@fluxer/schema/src/domains/user/UserResponseSchemas'; import {msg} from '@lingui/core/macro'; import {Trans, useLingui} from '@lingui/react/macro'; import {observer} from 'mobx-react-lite'; @@ -25,21 +27,41 @@ const REGENERATE_BACKUP_CODES_DESCRIPTOR = msg({ interface BackupCodesRegenerateModalProps { user: User; + challenge?: BackupCodesChallenge; } -export const BackupCodesRegenerateModal = observer(({user}: BackupCodesRegenerateModalProps) => { +interface RegenerateResult { + backupCodes: Array; + challenge?: BackupCodesChallenge; +} + +async function regenerateBackupCodes(challenge?: BackupCodesChallenge): Promise { + if (challenge) { + try { + return {backupCodes: await MfaCommands.regenerateBackupCodesWithChallenge(challenge), challenge}; + } catch (error) { + if (!MfaCommands.isExpiredBackupCodesChallengeError(error)) { + throw error; + } + } + } + return {backupCodes: await MfaCommands.getBackupCodes(true)}; +} + +export const BackupCodesRegenerateModal = observer(({user, challenge}: BackupCodesRegenerateModalProps) => { const {i18n} = useLingui(); const [isSubmitting, setIsSubmitting] = useState(false); const handleConfirm = async () => { setIsSubmitting(true); try { - const backupCodes = await MfaCommands.getBackupCodes(true); + const result = await regenerateBackupCodes(challenge); ModalCommands.pop(); ModalCommands.update('backup-codes', () => modal(() => ( )), diff --git a/fluxer_app/src/features/auth/components/modals/BackupCodesViewModal.tsx b/fluxer_app/src/features/auth/components/modals/BackupCodesViewModal.tsx index bd5d52c94..7aa025a14 100644 --- a/fluxer_app/src/features/auth/components/modals/BackupCodesViewModal.tsx +++ b/fluxer_app/src/features/auth/components/modals/BackupCodesViewModal.tsx @@ -99,13 +99,15 @@ export const BackupCodesViewModal = observer(({user}: BackupCodesViewModalProps) setSubmitting(true); setCodeError(null); try { - const backupCodes = await MfaCommands.verifyBackupCodesChallenge(ticket, code); + const result = await MfaCommands.verifyBackupCodesChallenge(ticket, code); + const challenge = {ticket, verificationProof: result.verification_proof}; ModalCommands.pop(); ModalCommands.pushWithKey( modal(() => ( )), diff --git a/fluxer_app/src/features/auth/flow/MfaScreen.tsx b/fluxer_app/src/features/auth/flow/MfaScreen.tsx index d4ba604d2..aebc57eb7 100644 --- a/fluxer_app/src/features/auth/flow/MfaScreen.tsx +++ b/fluxer_app/src/features/auth/flow/MfaScreen.tsx @@ -80,7 +80,7 @@ const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps) autoCapitalize="none" autoCorrect="off" enterKeyHint="done" - inputMode="numeric" + inputMode="text" spellCheck={false} autoFocus data-step-focus="true" diff --git a/packages/schema/src/domains/auth/AuthSchemas.ts b/packages/schema/src/domains/auth/AuthSchemas.ts index c6d571df2..63f69b465 100644 --- a/packages/schema/src/domains/auth/AuthSchemas.ts +++ b/packages/schema/src/domains/auth/AuthSchemas.ts @@ -402,6 +402,18 @@ export const MfaBackupCodesChallengeVerifyRequest = MfaBackupCodesChallengeResen export type MfaBackupCodesChallengeVerifyRequest = z.infer; +export const MfaBackupCodesChallengeVerifyResponse = MfaBackupCodesResponse.extend({ + verification_proof: z.string().describe('Proof token authorizing backup code regeneration on this ticket'), +}); + +export type MfaBackupCodesChallengeVerifyResponse = z.infer; + +export const MfaBackupCodesChallengeRegenerateRequest = MfaBackupCodesChallengeResendRequest.extend({ + verification_proof: createStringType().describe('Proof token obtained from verifying the email code'), +}); + +export type MfaBackupCodesChallengeRegenerateRequest = z.infer; + export const PhoneSendVerificationRequest = z.object({ phone: PhoneNumberType.describe('Phone number to send verification code'), channel: z