mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
fix(auth): regenerate backup codes with the emailed challenge (#2436)
This commit is contained in:
1 parent
add0a3dfc6
commit
16ae98e189
13 files changed
+427
-29
No files matched your search
@@ -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": {
|
"/users/@me/mfa/backup-codes/challenge/resend": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "resend_backup_codes_challenge",
|
"operationId": "resend_backup_codes_challenge",
|
||||||
@@ -18478,7 +18551,9 @@
|
|||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/MfaBackupCodesResponse"}}}
|
"content": {
|
||||||
|
"application/json": {"schema": {"$ref": "#/components/schemas/MfaBackupCodesChallengeVerifyResponse"}}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||||
@@ -18533,7 +18608,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"x-mint": {"metadata": {"title": "Verify backup codes challenge code"}},
|
"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": []}],
|
"security": [{"sessionToken": []}],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -33526,11 +33601,41 @@
|
|||||||
},
|
},
|
||||||
"required": ["ticket", "code_expires_at", "resend_available_at"]
|
"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": {
|
"MfaBackupCodesChallengeResendRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"ticket": {"type": "string", "description": "Backup codes challenge ticket identifier"}},
|
"properties": {"ticket": {"type": "string", "description": "Backup codes challenge ticket identifier"}},
|
||||||
"required": ["ticket"]
|
"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": {
|
"MfaBackupCodesChallengeVerifyRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -204,6 +204,10 @@ export const UserRateLimitConfigs = {
|
|||||||
bucket: 'user:mfa:backup_codes_challenge:verify',
|
bucket: 'user:mfa:backup_codes_challenge:verify',
|
||||||
config: {limit: 20, windowMs: ms('1 minute')},
|
config: {limit: 20, windowMs: ms('1 minute')},
|
||||||
} as RouteRateLimitConfig,
|
} 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: {
|
USER_PUSH_SUBSCRIBE: {
|
||||||
bucket: 'user:push:subscribe',
|
bucket: 'user:push:subscribe',
|
||||||
config: {limit: 20, windowMs: ms('1 minute')},
|
config: {limit: 20, windowMs: ms('1 minute')},
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import {
|
|||||||
DisableTotpRequest,
|
DisableTotpRequest,
|
||||||
EnableMfaTotpRequest,
|
EnableMfaTotpRequest,
|
||||||
InboundSmsChallengeStartResponse,
|
InboundSmsChallengeStartResponse,
|
||||||
|
MfaBackupCodesChallengeRegenerateRequest,
|
||||||
MfaBackupCodesChallengeResendRequest,
|
MfaBackupCodesChallengeResendRequest,
|
||||||
MfaBackupCodesChallengeStartResponse,
|
MfaBackupCodesChallengeStartResponse,
|
||||||
MfaBackupCodesChallengeVerifyRequest,
|
MfaBackupCodesChallengeVerifyRequest,
|
||||||
|
MfaBackupCodesChallengeVerifyResponse,
|
||||||
MfaBackupCodesRequest,
|
MfaBackupCodesRequest,
|
||||||
MfaBackupCodesResponse,
|
MfaBackupCodesResponse,
|
||||||
PhoneSendVerificationRequest,
|
PhoneSendVerificationRequest,
|
||||||
@@ -168,12 +170,12 @@ export function UserAuthController(app: HonoApp) {
|
|||||||
OpenAPI({
|
OpenAPI({
|
||||||
operationId: 'verify_backup_codes_challenge',
|
operationId: 'verify_backup_codes_challenge',
|
||||||
summary: 'Verify backup codes challenge code',
|
summary: 'Verify backup codes challenge code',
|
||||||
responseSchema: MfaBackupCodesResponse,
|
responseSchema: MfaBackupCodesChallengeVerifyResponse,
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
security: ['bearerToken', 'sessionToken'],
|
security: ['bearerToken', 'sessionToken'],
|
||||||
tags: ['Users'],
|
tags: ['Users'],
|
||||||
description:
|
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) => {
|
async (ctx) => {
|
||||||
const user = ctx.get('user');
|
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));
|
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(
|
app.post(
|
||||||
'/users/@me/phone/send-verification',
|
'/users/@me/phone/send-verification',
|
||||||
RateLimitMiddleware(RateLimitConfigs.PHONE_SEND_VERIFICATION),
|
RateLimitMiddleware(RateLimitConfigs.PHONE_SEND_VERIFICATION),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import {randomUUID} from 'node:crypto';
|
import {randomUUID, timingSafeEqual} from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
assertChangeCooldown,
|
assertChangeCooldown,
|
||||||
checkChangeRateLimit,
|
checkChangeRateLimit,
|
||||||
@@ -10,17 +10,23 @@ import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
|||||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||||
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
||||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
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 {ms} from 'itty-time';
|
||||||
import type {ApiContext} from '../../ApiContext';
|
import type {ApiContext} from '../../ApiContext';
|
||||||
import {requireEmailVerified} from '../../auth/EmailVerificationUtils';
|
import {requireEmailVerified} from '../../auth/EmailVerificationUtils';
|
||||||
|
import type {MfaBackupCode} from '../../models/MfaBackupCode';
|
||||||
import type {User} from '../../models/User';
|
import type {User} from '../../models/User';
|
||||||
|
import {regenerateMfaBackupCodes} from './UserAuth';
|
||||||
|
|
||||||
interface MfaBackupCodesChallengeTicket {
|
interface MfaBackupCodesChallengeTicket {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
code: string;
|
code: string | null;
|
||||||
code_sent_at: number;
|
code_sent_at: number;
|
||||||
code_expires_at: number;
|
code_expires_at: number;
|
||||||
|
verification_proof: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StartMfaBackupCodesChallengeResult {
|
interface StartMfaBackupCodesChallengeResult {
|
||||||
@@ -37,6 +43,15 @@ function normalizeVerificationCode(code: string): string {
|
|||||||
return code.trim().toUpperCase().replaceAll('-', '');
|
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 {
|
export class MfaBackupCodesChallengeService {
|
||||||
private readonly CODE_TTL_MS = ms('10 minutes');
|
private readonly CODE_TTL_MS = ms('10 minutes');
|
||||||
private readonly TICKET_TTL_MS = ms('30 minutes');
|
private readonly TICKET_TTL_MS = ms('30 minutes');
|
||||||
@@ -68,6 +83,7 @@ export class MfaBackupCodesChallengeService {
|
|||||||
code,
|
code,
|
||||||
code_sent_at: now,
|
code_sent_at: now,
|
||||||
code_expires_at: codeExpiresAt,
|
code_expires_at: codeExpiresAt,
|
||||||
|
verification_proof: null,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
ticket,
|
ticket,
|
||||||
@@ -97,25 +113,54 @@ export class MfaBackupCodesChallengeService {
|
|||||||
code,
|
code,
|
||||||
code_sent_at: now,
|
code_sent_at: now,
|
||||||
code_expires_at: codeExpiresAt,
|
code_expires_at: codeExpiresAt,
|
||||||
|
verification_proof: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async verify(user: User, ticket: string, code: string): Promise<MfaBackupCodesResponse> {
|
async verify(user: User, ticket: string, code: string): Promise<MfaBackupCodesChallengeVerifyResponse> {
|
||||||
const {cache, rateLimit, users} = this.apiContext.services;
|
const {rateLimit, users} = this.apiContext.services;
|
||||||
const state = await this.getTicketForUser(ticket, user);
|
const state = await this.getTicketForUser(ticket, user);
|
||||||
await checkChangeRateLimit(rateLimit, {
|
await checkChangeRateLimit(rateLimit, {
|
||||||
identifier: `mfa_backup_codes_challenge:verify:${ticket}`,
|
identifier: `mfa_backup_codes_challenge:verify:${ticket}`,
|
||||||
maxAttempts: 5,
|
maxAttempts: 5,
|
||||||
windowMs: ms('15 minutes'),
|
windowMs: ms('15 minutes'),
|
||||||
});
|
});
|
||||||
|
if (!state.code) {
|
||||||
|
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_NOT_ISSUED);
|
||||||
|
}
|
||||||
if (state.code_expires_at < Date.now()) {
|
if (state.code_expires_at < Date.now()) {
|
||||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_EXPIRED);
|
throw InputValidationError.fromCode('code', ValidationErrorCodes.VERIFICATION_CODE_EXPIRED);
|
||||||
}
|
}
|
||||||
if (normalizeVerificationCode(state.code) !== normalizeVerificationCode(code)) {
|
if (normalizeVerificationCode(state.code) !== normalizeVerificationCode(code)) {
|
||||||
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_VERIFICATION_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);
|
const backupCodes = await users.listMfaBackupCodes(user.id);
|
||||||
|
return {...this.toResponse(backupCodes), verification_proof: verificationProof};
|
||||||
|
}
|
||||||
|
|
||||||
|
async regenerate(user: User, ticket: string, verificationProof: string): Promise<MfaBackupCodesResponse> {
|
||||||
|
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<MfaBackupCode>): MfaBackupCodesResponse {
|
||||||
return {
|
return {
|
||||||
backup_codes: backupCodes.map((backupCode) => ({
|
backup_codes: backupCodes.map((backupCode) => ({
|
||||||
code: backupCode.code,
|
code: backupCode.code,
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export async function getMfaBackupCodes(
|
|||||||
return await users.listMfaBackupCodes(user.id);
|
return await users.listMfaBackupCodes(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function regenerateMfaBackupCodes(ctx: ApiContext, user: User): Promise<Array<MfaBackupCode>> {
|
export async function regenerateMfaBackupCodes(ctx: ApiContext, user: User): Promise<Array<MfaBackupCode>> {
|
||||||
const {users} = ctx.services;
|
const {users} = ctx.services;
|
||||||
const userId = user.id;
|
const userId = user.id;
|
||||||
const newBackupCodes = AuthUtility.generateBackupCodes(ctx);
|
const newBackupCodes = AuthUtility.generateBackupCodes(ctx);
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ interface ChallengeVerifyResult {
|
|||||||
code: string;
|
code: string;
|
||||||
consumed: boolean;
|
consumed: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
verification_proof: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChallengeRegenerateResult {
|
||||||
|
backup_codes: Array<{
|
||||||
|
code: string;
|
||||||
|
consumed: boolean;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ValidationErrorBody {
|
interface ValidationErrorBody {
|
||||||
@@ -34,8 +42,11 @@ interface ValidationErrorBody {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enableTotp(harness: ApiTestHarness, account: TestAccount): Promise<Array<string>> {
|
async function enableTotpWithSecret(
|
||||||
const secret = createTotpSecret();
|
harness: ApiTestHarness,
|
||||||
|
account: TestAccount,
|
||||||
|
secret: string,
|
||||||
|
): Promise<Array<string>> {
|
||||||
const enabled = await createBuilder<BackupCodesResponse>(harness, account.token)
|
const enabled = await createBuilder<BackupCodesResponse>(harness, account.token)
|
||||||
.post('/users/@me/mfa/totp/enable')
|
.post('/users/@me/mfa/totp/enable')
|
||||||
.body({secret, code: totpCodeNow(secret), password: account.password})
|
.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);
|
return enabled.backup_codes.map((backupCode) => backupCode.code);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enableTotp(harness: ApiTestHarness, account: TestAccount): Promise<Array<string>> {
|
||||||
|
return enableTotpWithSecret(harness, account, createTotpSecret());
|
||||||
|
}
|
||||||
|
|
||||||
async function createTotpAccount(harness: ApiTestHarness): Promise<{
|
async function createTotpAccount(harness: ApiTestHarness): Promise<{
|
||||||
account: TestAccount;
|
account: TestAccount;
|
||||||
backupCodes: Array<string>;
|
backupCodes: Array<string>;
|
||||||
@@ -71,6 +86,18 @@ async function verifyChallenge(
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function regenerateWithChallenge(
|
||||||
|
harness: ApiTestHarness,
|
||||||
|
token: string,
|
||||||
|
ticket: string,
|
||||||
|
verificationProof: string,
|
||||||
|
): Promise<ChallengeRegenerateResult> {
|
||||||
|
return createBuilder<ChallengeRegenerateResult>(harness, token)
|
||||||
|
.post('/users/@me/mfa/backup-codes/challenge/regenerate')
|
||||||
|
.body({ticket, verification_proof: verificationProof})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
async function getChallengeCode(harness: ApiTestHarness, email: string): Promise<string> {
|
async function getChallengeCode(harness: ApiTestHarness, email: string): Promise<string> {
|
||||||
const emails = await listTestEmails(harness, {recipient: email});
|
const emails = await listTestEmails(harness, {recipient: email});
|
||||||
const record = findLastTestEmail(emails, 'mfa_backup_codes_view');
|
const record = findLastTestEmail(emails, 'mfa_backup_codes_view');
|
||||||
@@ -184,17 +211,18 @@ describe('MfaBackupCodesChallengeFlow', () => {
|
|||||||
const result = await verifyChallenge(harness, account.token, startResult.ticket, mangled);
|
const result = await verifyChallenge(harness, account.token, startResult.ticket, mangled);
|
||||||
expect(result.backup_codes.map((backupCode) => backupCode.code).sort()).toEqual([...backupCodes].sort());
|
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 {account} = await createTotpAccount(harness);
|
||||||
const startResult = await startChallenge(harness, account.token);
|
const startResult = await startChallenge(harness, account.token);
|
||||||
const code = await getChallengeCode(harness, account.email);
|
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)
|
const {json} = await createBuilder(harness, account.token)
|
||||||
.post('/users/@me/mfa/backup-codes/challenge/verify')
|
.post('/users/@me/mfa/backup-codes/challenge/verify')
|
||||||
.body({ticket: startResult.ticket, code})
|
.body({ticket: startResult.ticket, code})
|
||||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||||
.executeWithResponse();
|
.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 () => {
|
test('rejects an unknown ticket', async () => {
|
||||||
const {account} = await createTotpAccount(harness);
|
const {account} = await createTotpAccount(harness);
|
||||||
@@ -227,4 +255,107 @@ describe('MfaBackupCodesChallengeFlow', () => {
|
|||||||
.execute();
|
.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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -163,6 +163,7 @@ export const Endpoints = {
|
|||||||
USER_MFA_BACKUP_CODES_CHALLENGE_START: '/users/@me/mfa/backup-codes/challenge',
|
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_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_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_DISABLE: '/users/@me/mfa/totp/disable',
|
||||||
USER_MFA_TOTP_ENABLE: '/users/@me/mfa/totp/enable',
|
USER_MFA_TOTP_ENABLE: '/users/@me/mfa/totp/enable',
|
||||||
USER_AUTHORIZED_IPS: '/users/@me/authorized-ips',
|
USER_AUTHORIZED_IPS: '/users/@me/authorized-ips',
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ interface BackupCodesChallengeResponse {
|
|||||||
resend_available_at: string | null;
|
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';
|
type BackupCodeMode = 'fetch' | 'regenerate';
|
||||||
|
|
||||||
function backupCodeMode(regenerate: boolean): BackupCodeMode {
|
function backupCodeMode(regenerate: boolean): BackupCodeMode {
|
||||||
@@ -53,9 +72,23 @@ async function requestBackupCodesChallengeResend(ticket: string): Promise<void>
|
|||||||
await http.post(Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_RESEND, {body: {ticket}});
|
await http.post(Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_RESEND, {body: {ticket}});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestBackupCodesChallengeVerify(ticket: string, code: string): Promise<Array<BackupCode>> {
|
async function requestBackupCodesChallengeVerify(
|
||||||
const response = await http.post<BackupCodesResponse>(Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_VERIFY, {
|
ticket: string,
|
||||||
body: {ticket, code},
|
code: string,
|
||||||
|
): Promise<BackupCodesChallengeVerifyResponse> {
|
||||||
|
const response = await http.post<BackupCodesChallengeVerifyResponse>(
|
||||||
|
Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_VERIFY,
|
||||||
|
{body: {ticket, code}},
|
||||||
|
);
|
||||||
|
return response.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestBackupCodesChallengeRegenerate(
|
||||||
|
ticket: string,
|
||||||
|
verificationProof: string,
|
||||||
|
): Promise<Array<BackupCode>> {
|
||||||
|
const response = await http.post<BackupCodesResponse>(Endpoints.USER_MFA_BACKUP_CODES_CHALLENGE_REGENERATE, {
|
||||||
|
body: {ticket, verification_proof: verificationProof},
|
||||||
});
|
});
|
||||||
return response.body.backup_codes;
|
return response.body.backup_codes;
|
||||||
}
|
}
|
||||||
@@ -120,13 +153,27 @@ export async function resendBackupCodesChallengeCode(ticket: string): Promise<vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyBackupCodesChallenge(ticket: string, code: string): Promise<Array<BackupCode>> {
|
export async function verifyBackupCodesChallenge(
|
||||||
|
ticket: string,
|
||||||
|
code: string,
|
||||||
|
): Promise<BackupCodesChallengeVerifyResponse> {
|
||||||
try {
|
try {
|
||||||
logger.debug('Verifying MFA backup codes challenge code');
|
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');
|
logger.debug('Successfully verified MFA backup codes challenge code');
|
||||||
return backupCodes;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rethrowMfaFailure('Failed to verify MFA backup codes challenge code:', error);
|
rethrowMfaFailure('Failed to verify MFA backup codes challenge code:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function regenerateBackupCodesWithChallenge(challenge: BackupCodesChallenge): Promise<Array<BackupCode>> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
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 styles from '@app/features/auth/components/modals/BackupCodesModal.module.css';
|
||||||
import {BackupCodesRegenerateModal} from '@app/features/auth/components/modals/BackupCodesRegenerateModal';
|
import {BackupCodesRegenerateModal} from '@app/features/auth/components/modals/BackupCodesRegenerateModal';
|
||||||
import {Button} from '@app/features/ui/button/Button';
|
import {Button} from '@app/features/ui/button/Button';
|
||||||
@@ -22,9 +23,10 @@ const BACKUP_CODES_DESCRIPTOR = msg({
|
|||||||
interface BackupCodesModalProps {
|
interface BackupCodesModalProps {
|
||||||
backupCodes: ReadonlyArray<BackupCode>;
|
backupCodes: ReadonlyArray<BackupCode>;
|
||||||
user: User;
|
user: User;
|
||||||
|
challenge?: BackupCodesChallenge;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BackupCodesModal = observer(({backupCodes, user}: BackupCodesModalProps) => {
|
export const BackupCodesModal = observer(({backupCodes, user, challenge}: BackupCodesModalProps) => {
|
||||||
const {i18n} = useLingui();
|
const {i18n} = useLingui();
|
||||||
return (
|
return (
|
||||||
<Modal.Root size="small" centered data-flx="auth.backup-codes-modal.modal-root">
|
<Modal.Root size="small" centered data-flx="auth.backup-codes-modal.modal-root">
|
||||||
@@ -101,6 +103,7 @@ export const BackupCodesModal = observer(({backupCodes, user}: BackupCodesModalP
|
|||||||
modal(() => (
|
modal(() => (
|
||||||
<BackupCodesRegenerateModal
|
<BackupCodesRegenerateModal
|
||||||
user={user}
|
user={user}
|
||||||
|
challenge={challenge}
|
||||||
data-flx="auth.backup-codes-modal.backup-codes-regenerate-modal"
|
data-flx="auth.backup-codes-modal.backup-codes-regenerate-modal"
|
||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import * as Modal from '@app/features/app/components/dialogs/Modal';
|
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 * as MfaCommands from '@app/features/auth/commands/MfaCommands';
|
||||||
import {BackupCodesModal} from '@app/features/auth/components/modals/BackupCodesModal';
|
import {BackupCodesModal} from '@app/features/auth/components/modals/BackupCodesModal';
|
||||||
import {Button} from '@app/features/ui/button/Button';
|
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 * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||||
import type {User} from '@app/features/user/models/User';
|
import type {User} from '@app/features/user/models/User';
|
||||||
import * as FormUtils from '@app/lib/forms';
|
import * as FormUtils from '@app/lib/forms';
|
||||||
|
import type {BackupCode} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||||
import {msg} from '@lingui/core/macro';
|
import {msg} from '@lingui/core/macro';
|
||||||
import {Trans, useLingui} from '@lingui/react/macro';
|
import {Trans, useLingui} from '@lingui/react/macro';
|
||||||
import {observer} from 'mobx-react-lite';
|
import {observer} from 'mobx-react-lite';
|
||||||
@@ -25,21 +27,41 @@ const REGENERATE_BACKUP_CODES_DESCRIPTOR = msg({
|
|||||||
|
|
||||||
interface BackupCodesRegenerateModalProps {
|
interface BackupCodesRegenerateModalProps {
|
||||||
user: User;
|
user: User;
|
||||||
|
challenge?: BackupCodesChallenge;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BackupCodesRegenerateModal = observer(({user}: BackupCodesRegenerateModalProps) => {
|
interface RegenerateResult {
|
||||||
|
backupCodes: Array<BackupCode>;
|
||||||
|
challenge?: BackupCodesChallenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerateBackupCodes(challenge?: BackupCodesChallenge): Promise<RegenerateResult> {
|
||||||
|
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 {i18n} = useLingui();
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const backupCodes = await MfaCommands.getBackupCodes(true);
|
const result = await regenerateBackupCodes(challenge);
|
||||||
ModalCommands.pop();
|
ModalCommands.pop();
|
||||||
ModalCommands.update('backup-codes', () =>
|
ModalCommands.update('backup-codes', () =>
|
||||||
modal(() => (
|
modal(() => (
|
||||||
<BackupCodesModal
|
<BackupCodesModal
|
||||||
backupCodes={backupCodes}
|
backupCodes={result.backupCodes}
|
||||||
user={user}
|
user={user}
|
||||||
|
challenge={result.challenge}
|
||||||
data-flx="auth.backup-codes-regenerate-modal.handle-confirm.backup-codes-modal"
|
data-flx="auth.backup-codes-regenerate-modal.handle-confirm.backup-codes-modal"
|
||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -99,13 +99,15 @@ export const BackupCodesViewModal = observer(({user}: BackupCodesViewModalProps)
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setCodeError(null);
|
setCodeError(null);
|
||||||
try {
|
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.pop();
|
||||||
ModalCommands.pushWithKey(
|
ModalCommands.pushWithKey(
|
||||||
modal(() => (
|
modal(() => (
|
||||||
<BackupCodesModal
|
<BackupCodesModal
|
||||||
backupCodes={backupCodes}
|
backupCodes={result.backup_codes}
|
||||||
user={user}
|
user={user}
|
||||||
|
challenge={challenge}
|
||||||
data-flx="auth.backup-codes-view-modal.handle-verify.backup-codes-modal"
|
data-flx="auth.backup-codes-view-modal.handle-verify.backup-codes-modal"
|
||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps)
|
|||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
enterKeyHint="done"
|
enterKeyHint="done"
|
||||||
inputMode="numeric"
|
inputMode="text"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
autoFocus
|
autoFocus
|
||||||
data-step-focus="true"
|
data-step-focus="true"
|
||||||
|
|||||||
@@ -402,6 +402,18 @@ export const MfaBackupCodesChallengeVerifyRequest = MfaBackupCodesChallengeResen
|
|||||||
|
|
||||||
export type MfaBackupCodesChallengeVerifyRequest = z.infer<typeof MfaBackupCodesChallengeVerifyRequest>;
|
export type MfaBackupCodesChallengeVerifyRequest = z.infer<typeof MfaBackupCodesChallengeVerifyRequest>;
|
||||||
|
|
||||||
|
export const MfaBackupCodesChallengeVerifyResponse = MfaBackupCodesResponse.extend({
|
||||||
|
verification_proof: z.string().describe('Proof token authorizing backup code regeneration on this ticket'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MfaBackupCodesChallengeVerifyResponse = z.infer<typeof MfaBackupCodesChallengeVerifyResponse>;
|
||||||
|
|
||||||
|
export const MfaBackupCodesChallengeRegenerateRequest = MfaBackupCodesChallengeResendRequest.extend({
|
||||||
|
verification_proof: createStringType().describe('Proof token obtained from verifying the email code'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MfaBackupCodesChallengeRegenerateRequest = z.infer<typeof MfaBackupCodesChallengeRegenerateRequest>;
|
||||||
|
|
||||||
export const PhoneSendVerificationRequest = z.object({
|
export const PhoneSendVerificationRequest = z.object({
|
||||||
phone: PhoneNumberType.describe('Phone number to send verification code'),
|
phone: PhoneNumberType.describe('Phone number to send verification code'),
|
||||||
channel: z
|
channel: z
|
||||||
|
|||||||
Reference in new issue
Block a user