fix(auth): email code to view backup codes, fix login matching (#2433)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-03 21:06:30 +02:00
1 parent eb4562b6a6
commit 90c349392b
86 files changed
+4245 -2801

No files matched your search

+14 -5
View File
@@ -68,6 +68,10 @@ function constantTimeEquals(a: string, b: string): boolean {
return timingSafeEqual(bufferA, bufferB);
}
function normalizeBackupCode(code: string): string {
return code.toLowerCase().replace(/[^a-z0-9]/g, '');
}
export async function verifyMfaCode(ctx: ApiContext, params: VerifyMfaCodeParams): Promise<boolean> {
const {userId, mfaSecret, code, allowBackup = false} = params;
const {users, cache, config} = ctx.services;
@@ -88,11 +92,16 @@ export async function verifyMfaCode(ctx: ApiContext, params: VerifyMfaCodeParams
Logger.error({userId, code: `${code.slice(0, 3)}***`, error}, 'Failed to validate TOTP code');
}
if (allowBackup) {
const backupCodes = await users.listMfaBackupCodes(userId);
const backupCode = backupCodes.find((bc) => !bc.consumed && constantTimeEquals(bc.code, code));
if (backupCode) {
await users.consumeMfaBackupCode(userId, code);
return true;
const normalizedCode = normalizeBackupCode(code);
if (normalizedCode.length > 0) {
const backupCodes = await users.listMfaBackupCodes(userId);
const backupCode = backupCodes.find(
(bc) => !bc.consumed && constantTimeEquals(normalizeBackupCode(bc.code), normalizedCode),
);
if (backupCode) {
await users.consumeMfaBackupCode(userId, backupCode.code);
return true;
}
}
}
return false;
@@ -0,0 +1,197 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import {
createAuthHarness,
createTestAccount,
createTotpSecret,
type LoginMfaResponse,
loginUser,
type TestAccount,
totpCodeNow,
} from './AuthTestUtils';
interface BackupCodeEntry {
code: string;
consumed: boolean;
}
interface BackupCodeListResponse {
backup_codes: Array<BackupCodeEntry>;
}
interface MfaLoginTokenResponse {
user_id: string;
token: string;
}
function stripBackupCode(code: string): string {
return code.toLowerCase().replace(/[^a-z0-9]/g, '');
}
function unissuedBackupCode(codes: Array<string>): string {
const issued = new Set(codes.map(stripBackupCode));
for (let index = 0; index < 100; index++) {
const candidate = `zzzz-${index.toString().padStart(4, '0')}`;
if (!issued.has(stripBackupCode(candidate))) {
return candidate;
}
}
throw new Error('Unable to derive an unissued backup code');
}
async function enableTotp(harness: ApiTestHarness, account: TestAccount, secret: string): Promise<Array<string>> {
const enabled = await createBuilder<BackupCodeListResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: totpCodeNow(secret), password: account.password})
.execute();
expect(enabled.backup_codes.length).toBeGreaterThan(0);
return enabled.backup_codes.map((entry) => entry.code);
}
async function startMfaLogin(harness: ApiTestHarness, account: TestAccount): Promise<string> {
const login = (await loginUser(harness, {email: account.email, password: account.password})) as LoginMfaResponse;
expect(login.mfa).toBe(true);
expect(login.ticket).toBeTruthy();
return login.ticket;
}
async function completeMfaLogin(harness: ApiTestHarness, ticket: string, code: string): Promise<string> {
const result = await createBuilderWithoutAuth<MfaLoginTokenResponse>(harness)
.post('/auth/login/mfa/totp')
.body({ticket, code})
.execute();
expect(result.token).toBeTruthy();
return result.token;
}
async function expectMfaLoginRejected(harness: ApiTestHarness, ticket: string, code: string): Promise<void> {
const result = await createBuilderWithoutAuth<{
code: string;
token?: string;
}>(harness)
.post('/auth/login/mfa/totp')
.body({ticket, code})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
expect(result.token).toBeUndefined();
}
async function assertSessionBelongsTo(harness: ApiTestHarness, token: string, userId: string): Promise<void> {
const me = await createBuilder<{
id: string;
}>(harness, token)
.get('/users/@me')
.execute();
expect(me.id).toBe(userId);
}
async function listBackupCodes(
harness: ApiTestHarness,
account: TestAccount,
secret: string,
): Promise<Array<BackupCodeEntry>> {
const listed = await createBuilder<BackupCodeListResponse>(harness, account.token)
.post('/users/@me/mfa/backup-codes')
.body({mfa_method: 'totp', mfa_code: totpCodeNow(secret), regenerate: false})
.execute();
return listed.backup_codes;
}
describe('MFA backup code login', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('accepts a backup code exactly as issued', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const codes = await enableTotp(harness, account, secret);
expect(codes[0]!).toMatch(/^[a-z0-9]{4}-[a-z0-9]{4}$/);
const ticket = await startMfaLogin(harness, account);
const token = await completeMfaLogin(harness, ticket, codes[0]!);
await assertSessionBelongsTo(harness, token, account.userId);
});
it('accepts a backup code with the dash stripped as the web client sends it', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const codes = await enableTotp(harness, account, secret);
const ticket = await startMfaLogin(harness, account);
const token = await completeMfaLogin(harness, ticket, codes[0]!.replace('-', ''));
await assertSessionBelongsTo(harness, token, account.userId);
const listed = await listBackupCodes(harness, account, secret);
expect(listed).toHaveLength(codes.length);
const consumed = listed.filter((entry) => entry.consumed);
expect(consumed).toHaveLength(1);
expect(consumed[0]!.code).toBe(codes[0]!);
});
it('accepts uppercased and space padded backup codes', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const codes = await enableTotp(harness, account, secret);
const upperTicket = await startMfaLogin(harness, account);
const upperToken = await completeMfaLogin(harness, upperTicket, codes[0]!.toUpperCase());
await assertSessionBelongsTo(harness, upperToken, account.userId);
const spacedTicket = await startMfaLogin(harness, account);
const spacedToken = await completeMfaLogin(harness, spacedTicket, ` ${codes[1]!.replace('-', ' ')} `);
await assertSessionBelongsTo(harness, spacedToken, account.userId);
const listed = await listBackupCodes(harness, account, secret);
expect(listed).toHaveLength(codes.length);
const consumed = listed.filter((entry) => entry.consumed).map((entry) => entry.code);
expect(consumed.sort()).toEqual([codes[0]!, codes[1]!].sort());
});
it('marks the issued row consumed and rejects every spelling of a used code', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const codes = await enableTotp(harness, account, secret);
const issued = codes[0]!;
const firstTicket = await startMfaLogin(harness, account);
const token = await completeMfaLogin(harness, firstTicket, issued.replace('-', ''));
await assertSessionBelongsTo(harness, token, account.userId);
const secondTicket = await startMfaLogin(harness, account);
await expectMfaLoginRejected(harness, secondTicket, issued);
await expectMfaLoginRejected(harness, secondTicket, issued.replace('-', ''));
await expectMfaLoginRejected(harness, secondTicket, issued.toUpperCase());
const listed = await listBackupCodes(harness, account, secret);
expect(listed).toHaveLength(codes.length);
expect(listed.map((entry) => entry.code).sort()).toEqual([...codes].sort());
const consumed = listed.filter((entry) => entry.consumed);
expect(consumed).toHaveLength(1);
expect(consumed[0]!.code).toBe(issued);
});
it('rejects empty and whitespace only backup codes', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const codes = await enableTotp(harness, account, secret);
const ticket = await startMfaLogin(harness, account);
await expectMfaLoginRejected(harness, ticket, '');
await expectMfaLoginRejected(harness, ticket, ' ');
await expectMfaLoginRejected(harness, ticket, '-');
const listed = await listBackupCodes(harness, account, secret);
expect(listed).toHaveLength(codes.length);
expect(listed.filter((entry) => entry.consumed)).toHaveLength(0);
const token = await completeMfaLogin(harness, ticket, codes[0]!);
await assertSessionBelongsTo(harness, token, account.userId);
});
it('rejects a well formed backup code that was never issued', async () => {
const account = await createTestAccount(harness);
const secret = createTotpSecret();
const codes = await enableTotp(harness, account, secret);
const unissued = unissuedBackupCode(codes);
const ticket = await startMfaLogin(harness, account);
await expectMfaLoginRejected(harness, ticket, unissued);
await expectMfaLoginRejected(harness, ticket, stripBackupCode(unissued));
const listed = await listBackupCodes(harness, account, secret);
expect(listed).toHaveLength(codes.length);
expect(listed.filter((entry) => entry.consumed)).toHaveLength(0);
});
});
@@ -71,6 +71,7 @@ import {AgeVerificationService} from '../stripe/services/AgeVerificationService'
import type {HonoEnv} from '../types/HonoEnv';
import type {UserRepository} from '../user/repositories/UserRepository';
import {EmailChangeService} from '../user/services/EmailChangeService';
import {MfaBackupCodesChallengeService} from '../user/services/MfaBackupCodesChallengeService';
import {PasswordChangeService} from '../user/services/PasswordChangeService';
import {UserAccountRequestService} from '../user/services/UserAccountRequestService';
import {UserAuthRequestService} from '../user/services/UserAuthRequestService';
@@ -363,6 +364,7 @@ class RequestServices implements RequestScopedServices {
private cachedFavoriteMemeRequestService: FavoriteMemeRequestService | undefined;
private cachedSingleCommunityService: SingleCommunityService | undefined;
private cachedEmailChangeService: EmailChangeService | undefined;
private cachedMfaBackupCodesChallengeService: MfaBackupCodesChallengeService | undefined;
private cachedPasswordChangeService: PasswordChangeService | undefined;
private cachedInviteRequestService: InviteRequestService | undefined;
private cachedOAuth2Service: OAuth2Service | undefined;
@@ -799,6 +801,11 @@ class RequestServices implements RequestScopedServices {
return this.cachedEmailChangeService;
}
get mfaBackupCodesChallengeService(): MfaBackupCodesChallengeService {
this.cachedMfaBackupCodesChallengeService ??= new MfaBackupCodesChallengeService(this.context);
return this.cachedMfaBackupCodesChallengeService;
}
get passwordChangeService(): PasswordChangeService {
this.cachedPasswordChangeService ??= new PasswordChangeService(this.context, getPasswordChangeRepository());
return this.cachedPasswordChangeService;
+238
View File
@@ -18327,6 +18327,222 @@
}
}
},
"/users/@me/mfa/backup-codes/challenge": {
"post": {
"operationId": "start_backup_codes_challenge",
"summary": "Start backup codes challenge",
"tags": ["Users"],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/MfaBackupCodesChallengeStartResponse"}}
}
},
"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": "Start backup codes challenge"}},
"description": "Initiates the challenge required to view existing backup codes. Sends a verification code to the user's email address. Returns a ticket for use in the remaining challenge steps.",
"security": [{"sessionToken": []}],
"requestBody": {
"required": true,
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/EmptyBodyRequest"}}}
}
}
},
"/users/@me/mfa/backup-codes/challenge/resend": {
"post": {
"operationId": "resend_backup_codes_challenge",
"summary": "Resend backup codes challenge code",
"tags": ["Users"],
"responses": {
"204": {"description": "No Content"},
"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": "Resend backup codes challenge code"}},
"description": "Resends the verification code for a backup codes challenge. Use if the original code was not received. Requires a valid backup codes challenge ticket.",
"security": [{"sessionToken": []}],
"requestBody": {
"required": true,
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/MfaBackupCodesChallengeResendRequest"}}
}
}
}
},
"/users/@me/mfa/backup-codes/challenge/verify": {
"post": {
"operationId": "verify_backup_codes_challenge",
"summary": "Verify backup codes challenge code",
"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": "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.",
"security": [{"sessionToken": []}],
"requestBody": {
"required": true,
"content": {
"application/json": {"schema": {"$ref": "#/components/schemas/MfaBackupCodesChallengeVerifyRequest"}}
}
}
}
},
"/users/@me/mfa/totp/disable": {
"post": {
"operationId": "disable_totp_mfa",
@@ -33301,6 +33517,28 @@
},
"required": ["regenerate"]
},
"MfaBackupCodesChallengeStartResponse": {
"type": "object",
"properties": {
"ticket": {"type": "string", "description": "Ticket for backup codes challenge actions"},
"code_expires_at": {"type": "string", "description": "ISO8601 timestamp when the verification code expires"},
"resend_available_at": {"type": "string", "description": "ISO8601 timestamp when the code can be resent"}
},
"required": ["ticket", "code_expires_at", "resend_available_at"]
},
"MfaBackupCodesChallengeResendRequest": {
"type": "object",
"properties": {"ticket": {"type": "string", "description": "Backup codes challenge ticket identifier"}},
"required": ["ticket"]
},
"MfaBackupCodesChallengeVerifyRequest": {
"type": "object",
"properties": {
"ticket": {"type": "string", "description": "Backup codes challenge ticket identifier"},
"code": {"type": "string", "description": "Verification code sent to the email address"}
},
"required": ["ticket", "code"]
},
"DisableTotpRequest": {
"type": "object",
"properties": {
@@ -192,6 +192,18 @@ export const UserRateLimitConfigs = {
bucket: 'user:mfa:backup_codes',
config: {limit: 6, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
USER_MFA_BACKUP_CODES_CHALLENGE_START: {
bucket: 'user:mfa:backup_codes_challenge:start',
config: {limit: 6, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
USER_MFA_BACKUP_CODES_CHALLENGE_RESEND: {
bucket: 'user:mfa:backup_codes_challenge:resend',
config: {limit: 6, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
USER_MFA_BACKUP_CODES_CHALLENGE_VERIFY: {
bucket: 'user:mfa:backup_codes_challenge:verify',
config: {limit: 20, windowMs: ms('1 minute')},
} as RouteRateLimitConfig,
USER_PUSH_SUBSCRIBE: {
bucket: 'user:push:subscribe',
config: {limit: 20, windowMs: ms('1 minute')},
+2
View File
@@ -69,6 +69,7 @@ import type {EntranceSoundPlayService} from '../user/entrance_sound/EntranceSoun
import type {EntranceSoundService} from '../user/entrance_sound/EntranceSoundService';
import type {IUserRepository} from '../user/IUserRepository';
import type {EmailChangeService} from '../user/services/EmailChangeService';
import type {MfaBackupCodesChallengeService} from '../user/services/MfaBackupCodesChallengeService';
import type {PasswordChangeService} from '../user/services/PasswordChangeService';
import type {UserAccountRequestService} from '../user/services/UserAccountRequestService';
import type {UserActivityBuffer} from '../user/services/UserActivityBuffer';
@@ -129,6 +130,7 @@ export interface HonoEnv {
streamService: StreamService;
emailService: IEmailService;
emailChangeService: EmailChangeService;
mfaBackupCodesChallengeService: MfaBackupCodesChallengeService;
passwordChangeService: PasswordChangeService;
embedService: EmbedService;
entityAssetService: EntityAssetService;
@@ -5,6 +5,9 @@ import {
DisableTotpRequest,
EnableMfaTotpRequest,
InboundSmsChallengeStartResponse,
MfaBackupCodesChallengeResendRequest,
MfaBackupCodesChallengeStartResponse,
MfaBackupCodesChallengeVerifyRequest,
MfaBackupCodesRequest,
MfaBackupCodesResponse,
PhoneSendVerificationRequest,
@@ -19,6 +22,7 @@ import {
WebAuthnRegisterRequest,
} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {CredentialIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
import {EmptyBodyRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import {requireSudoMode} from '../../auth/services/SudoVerificationService';
import {Config} from '../../Config';
import {DefaultUserOnly, LoginRequired, LoginRequiredAllowSuspicious} from '../../middleware/AuthMiddleware';
@@ -111,6 +115,72 @@ export function UserAuthController(app: HonoApp) {
);
},
);
app.post(
'/users/@me/mfa/backup-codes/challenge',
RateLimitMiddleware(RateLimitConfigs.USER_MFA_BACKUP_CODES_CHALLENGE_START),
LoginRequired,
DefaultUserOnly,
Validator('json', EmptyBodyRequest),
OpenAPI({
operationId: 'start_backup_codes_challenge',
summary: 'Start backup codes challenge',
responseSchema: MfaBackupCodesChallengeStartResponse,
statusCode: 200,
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
"Initiates the challenge required to view existing backup codes. Sends a verification code to the user's email address. Returns a ticket for use in the remaining challenge steps.",
}),
async (ctx) => {
const user = ctx.get('user');
return ctx.json(await ctx.get('mfaBackupCodesChallengeService').start(user));
},
);
app.post(
'/users/@me/mfa/backup-codes/challenge/resend',
RateLimitMiddleware(RateLimitConfigs.USER_MFA_BACKUP_CODES_CHALLENGE_RESEND),
LoginRequired,
DefaultUserOnly,
Validator('json', MfaBackupCodesChallengeResendRequest),
OpenAPI({
operationId: 'resend_backup_codes_challenge',
summary: 'Resend backup codes challenge code',
responseSchema: null,
statusCode: 204,
security: ['bearerToken', 'sessionToken'],
tags: ['Users'],
description:
'Resends the verification code for a backup codes challenge. Use if the original code was not received. Requires a valid backup codes challenge ticket.',
}),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
await ctx.get('mfaBackupCodesChallengeService').resend(user, body.ticket);
return ctx.body(null, 204);
},
);
app.post(
'/users/@me/mfa/backup-codes/challenge/verify',
RateLimitMiddleware(RateLimitConfigs.USER_MFA_BACKUP_CODES_CHALLENGE_VERIFY),
LoginRequired,
DefaultUserOnly,
Validator('json', MfaBackupCodesChallengeVerifyRequest),
OpenAPI({
operationId: 'verify_backup_codes_challenge',
summary: 'Verify backup codes challenge code',
responseSchema: MfaBackupCodesResponse,
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.',
}),
async (ctx) => {
const user = ctx.get('user');
const body = ctx.req.valid('json');
return ctx.json(await ctx.get('mfaBackupCodesChallengeService').verify(user, body.ticket, body.code));
},
);
app.post(
'/users/@me/phone/send-verification',
RateLimitMiddleware(RateLimitConfigs.PHONE_SEND_VERIFICATION),
@@ -0,0 +1,144 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import {
assertChangeCooldown,
checkChangeRateLimit,
generateChangeVerificationCode,
} from '@app/api/user/services/UserChangeChallengeUtils';
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 {ms} from 'itty-time';
import type {ApiContext} from '../../ApiContext';
import {requireEmailVerified} from '../../auth/EmailVerificationUtils';
import type {User} from '../../models/User';
interface MfaBackupCodesChallengeTicket {
user_id: string;
code: string;
code_sent_at: number;
code_expires_at: number;
}
interface StartMfaBackupCodesChallengeResult {
ticket: string;
code_expires_at: string;
resend_available_at: string;
}
function getChallengeCacheKey(ticket: string): string {
return `mfa-backup-codes-challenge:${ticket}`;
}
function normalizeVerificationCode(code: string): string {
return code.trim().toUpperCase().replaceAll('-', '');
}
export class MfaBackupCodesChallengeService {
private readonly CODE_TTL_MS = ms('10 minutes');
private readonly TICKET_TTL_MS = ms('30 minutes');
private readonly RESEND_COOLDOWN_MS = ms('30 seconds');
constructor(private readonly apiContext: ApiContext) {}
async start(user: User): Promise<StartMfaBackupCodesChallengeResult> {
const {email, rateLimit} = this.apiContext.services;
requireEmailVerified(user, 'mfa');
if (!user.email) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.USER_DOES_NOT_HAVE_AN_EMAIL_ADDRESS);
}
if (!user.totpSecret || !user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP)) {
throw new MfaNotEnabledError();
}
await checkChangeRateLimit(rateLimit, {
identifier: `mfa_backup_codes_challenge:start:${user.id}`,
maxAttempts: 3,
windowMs: ms('15 minutes'),
});
const ticket = randomUUID();
const now = Date.now();
const code = generateChangeVerificationCode();
const codeExpiresAt = now + this.CODE_TTL_MS;
await email.sendMfaBackupCodesVerification(user.email, user.username, code, user.locale);
await this.storeTicket(ticket, {
user_id: user.id.toString(),
code,
code_sent_at: now,
code_expires_at: codeExpiresAt,
});
return {
ticket,
code_expires_at: new Date(codeExpiresAt).toISOString(),
resend_available_at: new Date(now + this.RESEND_COOLDOWN_MS).toISOString(),
};
}
async resend(user: User, ticket: string): Promise<void> {
const {email, rateLimit} = this.apiContext.services;
const state = await this.getTicketForUser(ticket, user);
if (!user.email) {
throw InputValidationError.fromCode('email', ValidationErrorCodes.USER_DOES_NOT_HAVE_AN_EMAIL_ADDRESS);
}
assertChangeCooldown(new Date(state.code_sent_at), this.RESEND_COOLDOWN_MS);
await checkChangeRateLimit(rateLimit, {
identifier: `mfa_backup_codes_challenge:resend:${user.id}`,
maxAttempts: 3,
windowMs: ms('15 minutes'),
});
const now = Date.now();
const code = generateChangeVerificationCode();
const codeExpiresAt = now + this.CODE_TTL_MS;
await email.sendMfaBackupCodesVerification(user.email, user.username, code, user.locale);
await this.storeTicket(ticket, {
user_id: state.user_id,
code,
code_sent_at: now,
code_expires_at: codeExpiresAt,
});
}
async verify(user: User, ticket: string, code: string): Promise<MfaBackupCodesResponse> {
const {cache, 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_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 backupCodes = await users.listMfaBackupCodes(user.id);
return {
backup_codes: backupCodes.map((backupCode) => ({
code: backupCode.code,
consumed: backupCode.consumed,
})),
};
}
private async storeTicket(ticket: string, state: MfaBackupCodesChallengeTicket): Promise<void> {
const {cache} = this.apiContext.services;
await cache.set<MfaBackupCodesChallengeTicket>(
getChallengeCacheKey(ticket),
state,
Math.ceil(this.TICKET_TTL_MS / 1000),
);
}
private async getTicketForUser(ticket: string, user: User): Promise<MfaBackupCodesChallengeTicket> {
const {cache} = this.apiContext.services;
const state = await cache.get<MfaBackupCodesChallengeTicket>(getChallengeCacheKey(ticket));
if (!state || state.user_id !== user.id.toString()) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_TICKET);
}
return state;
}
}
@@ -0,0 +1,230 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
import {
type BackupCodesResponse,
createTestAccount,
createTotpSecret,
findLastTestEmail,
listTestEmails,
type TestAccount,
totpCodeNow,
} from '../../auth/tests/AuthTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
interface ChallengeStartResult {
ticket: string;
code_expires_at: string;
resend_available_at: string;
}
interface ChallengeVerifyResult {
backup_codes: Array<{
code: string;
consumed: boolean;
}>;
}
interface ValidationErrorBody {
errors?: Array<{
code: string;
path: string;
}>;
}
async function enableTotp(harness: ApiTestHarness, account: TestAccount): Promise<Array<string>> {
const secret = createTotpSecret();
const enabled = await createBuilder<BackupCodesResponse>(harness, account.token)
.post('/users/@me/mfa/totp/enable')
.body({secret, code: totpCodeNow(secret), password: account.password})
.execute();
return enabled.backup_codes.map((backupCode) => backupCode.code);
}
async function createTotpAccount(harness: ApiTestHarness): Promise<{
account: TestAccount;
backupCodes: Array<string>;
}> {
const account = await createTestAccount(harness);
const backupCodes = await enableTotp(harness, account);
return {account, backupCodes};
}
async function startChallenge(harness: ApiTestHarness, token: string): Promise<ChallengeStartResult> {
return createBuilder<ChallengeStartResult>(harness, token)
.post('/users/@me/mfa/backup-codes/challenge')
.body({})
.execute();
}
async function verifyChallenge(
harness: ApiTestHarness,
token: string,
ticket: string,
code: string,
): Promise<ChallengeVerifyResult> {
return createBuilder<ChallengeVerifyResult>(harness, token)
.post('/users/@me/mfa/backup-codes/challenge/verify')
.body({ticket, code})
.execute();
}
async function getChallengeCode(harness: ApiTestHarness, email: string): Promise<string> {
const emails = await listTestEmails(harness, {recipient: email});
const record = findLastTestEmail(emails, 'mfa_backup_codes_view');
if (!record) {
throw new Error(`No backup codes challenge email found for ${email}`);
}
return record.metadata.code;
}
describe('MfaBackupCodesChallengeFlow', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
describe('start', () => {
test('returns a ticket and emails a verification code', async () => {
const {account} = await createTotpAccount(harness);
const result = await startChallenge(harness, account.token);
expect(typeof result.ticket).toBe('string');
expect(result.ticket.length).toBeGreaterThan(0);
expect(result.code_expires_at).toBeDefined();
expect(result.resend_available_at).toBeDefined();
const emails = await listTestEmails(harness, {recipient: account.email});
const challengeEmail = findLastTestEmail(emails, 'mfa_backup_codes_view');
expect(challengeEmail).not.toBeNull();
expect(challengeEmail!.metadata.code).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}$/);
});
test('rejects an account without TOTP enabled', async () => {
const account = await createTestAccount(harness);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/backup-codes/challenge')
.body({})
.expect(HTTP_STATUS.BAD_REQUEST, 'TWO_FACTOR_REQUIRED')
.execute();
const emails = await listTestEmails(harness, {recipient: account.email});
expect(findLastTestEmail(emails, 'mfa_backup_codes_view')).toBeNull();
});
test('requires authentication', async () => {
await createBuilderWithoutAuth(harness)
.post('/users/@me/mfa/backup-codes/challenge')
.body({})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
});
describe('resend', () => {
test('rejects during the cooldown period', async () => {
const {account} = await createTotpAccount(harness);
const startResult = await startChallenge(harness, account.token);
await createBuilder(harness, account.token)
.post('/users/@me/mfa/backup-codes/challenge/resend')
.body({ticket: startResult.ticket})
.expect(429)
.execute();
});
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/resend')
.body({ticket: 'nonexistent-ticket'})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.executeWithResponse();
expect((json as ValidationErrorBody).errors?.[0]?.code).toBe('INVALID_OR_EXPIRED_TICKET');
});
test('requires authentication', async () => {
await createBuilderWithoutAuth(harness)
.post('/users/@me/mfa/backup-codes/challenge/resend')
.body({ticket: 'some-ticket'})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
});
describe('verify', () => {
test('returns the backup codes issued when TOTP was enabled', async () => {
const {account, backupCodes} = await createTotpAccount(harness);
const startResult = await startChallenge(harness, account.token);
const code = await getChallengeCode(harness, account.email);
const result = await verifyChallenge(harness, account.token, startResult.ticket, code);
expect(result.backup_codes.length).toBe(backupCodes.length);
expect(result.backup_codes.map((backupCode) => backupCode.code).sort()).toEqual([...backupCodes].sort());
for (const backupCode of result.backup_codes) {
expect(backupCode.consumed).toBe(false);
}
});
test('rejects an incorrect code and returns no backup codes', 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/verify')
.body({ticket: startResult.ticket, code: 'XXXX-YYYY'})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.executeWithResponse();
const body = json as ValidationErrorBody & {
backup_codes?: unknown;
};
expect(body.errors?.[0]?.code).toBe('INVALID_VERIFICATION_CODE');
expect(body.backup_codes).toBeUndefined();
});
test('accepts a code with different case and without dashes', async () => {
const {account, backupCodes} = await createTotpAccount(harness);
const startResult = await startChallenge(harness, account.token);
const code = await getChallengeCode(harness, account.email);
const mangled = code.toLowerCase().replaceAll('-', '');
expect(mangled).not.toBe(code);
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 () => {
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 {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');
});
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/verify')
.body({ticket: 'nonexistent-ticket', code: 'ABCD-1234'})
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.executeWithResponse();
expect((json as ValidationErrorBody).errors?.[0]?.code).toBe('INVALID_OR_EXPIRED_TICKET');
});
test('rejects a 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 {json} = await createBuilder(harness, other.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');
const result = await verifyChallenge(harness, owner.account.token, startResult.ticket, code);
expect(result.backup_codes.map((backupCode) => backupCode.code).sort()).toEqual([...owner.backupCodes].sort());
});
test('requires authentication', async () => {
await createBuilderWithoutAuth(harness)
.post('/users/@me/mfa/backup-codes/challenge/verify')
.body({ticket: 'some-ticket', code: 'ABCD-1234'})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
});
});