mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(auth): email code to view backup codes, fix login matching (#2433)
This commit is contained in:
1 parent
eb4562b6a6
commit
90c349392b
86 files changed
+4245
-2801
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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')},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user