Skip to content

test: add integration tests for delete mfa #7480

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: wangsijie-prd-1008-add-totp
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 48 additions & 10 deletions packages/core/src/routes/account/index.openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -284,28 +284,66 @@
"post": {
"operationId": "AddMfaVerification",
"summary": "Add a MFA verification",
"description": "Add a MFA verification to the user, a logto-verification-id in header is required for checking sensitive permissions. Only WebAuthn is supported for now, a new identifier verification record is required for the webauthn registration verification.",
"description": "Add a MFA verification to the user, a logto-verification-id in header is required for checking sensitive permissions.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"newIdentifierVerificationRecordId": {
"description": "The identifier verification record ID for the new WebAuthn registration verification."
},
"type": {
"description": "The type of the MFA verification."
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["WebAuthn"],
"description": "The type of the MFA verification."
},
"newIdentifierVerificationRecordId": {
"type": "string",
"description": "The identifier verification record ID for the new WebAuthn registration verification."
},
"name": {
"type": "string",
"description": "The name of the MFA verification, if not provided, the name will be generated from user agent."
}
},
"required": ["type", "newIdentifierVerificationRecordId"]
},
"name": {
"description": "The name of the MFA verification, if not provided, the name will be generated from user agent."
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["TOTP"],
"description": "The type of the MFA verification, for TOTP, one user can only bind one TOTP factor."
},
"secret": {
"type": "string",
"description": "The TOTP secret for the MFA verification. Use the generate endpoint to create a secret, and verify the generated code with the user before binding to make sure the user has setup the secret in their authenticator app."
}
},
"required": ["type", "secret"]
}
}
]
}
}
}
}
}
},
"/api/my-account/mfa-verifications/totp-secret/generate": {
"post": {
"operationId": "GenerateTotpSecret",
"summary": "Generate a TOTP secret",
"tags": ["Dev feature"],
"description": "Generate a TOTP secret for the user.",
"responses": {
"200": {
"description": "The TOTP secret was generated successfully."
}
}
}
},
"/api/my-account/mfa-verifications/{verificationId}/name": {
"patch": {
"operationId": "UpdateMfaVerificationName",
Expand Down
127 changes: 92 additions & 35 deletions packages/core/src/routes/account/mfa-verifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import { z } from 'zod';

import koaGuard from '#src/middleware/koa-guard.js';

import { EnvSet } from '../../env-set/index.js';
import RequestError from '../../errors/RequestError/index.js';
import { buildVerificationRecordByIdAndType } from '../../libraries/verification.js';
import assertThat from '../../utils/assert-that.js';
import { transpileUserMfaVerifications } from '../../utils/user.js';
import { generateTotpSecret, validateTotpSecret } from '../interaction/utils/totp-validation.js';
import type { UserRouter, RouterInitArgs } from '../types.js';

import { accountApiPrefix } from './constants.js';
Expand Down Expand Up @@ -57,11 +59,17 @@ export default function mfaVerificationsRoutes<T extends UserRouter>(
router.post(
`${accountApiPrefix}/mfa-verifications`,
koaGuard({
body: z.object({
type: z.literal(MfaFactor.WebAuthn),
newIdentifierVerificationRecordId: z.string(),
name: z.string().optional(),
}),
body: z.discriminatedUnion('type', [
z.object({
type: z.literal(MfaFactor.WebAuthn),
newIdentifierVerificationRecordId: z.string(),
name: z.string().optional(),
}),
z.object({
type: z.literal(MfaFactor.TOTP),
secret: z.string(),
}),
]),
status: [204, 400, 401],
}),
async (ctx, next) => {
Expand All @@ -70,7 +78,6 @@ export default function mfaVerificationsRoutes<T extends UserRouter>(
identityVerified,
new RequestError({ code: 'verification_record.permission_denied', status: 401 })
);
const { newIdentifierVerificationRecordId, name } = ctx.guard.body;
const { fields } = ctx.accountCenter;
assertThat(
fields.mfa === AccountCenterControlValue.Edit,
Expand All @@ -82,46 +89,96 @@ export default function mfaVerificationsRoutes<T extends UserRouter>(
new RequestError({ code: 'auth.unauthorized', status: 401 })
);

// Check new identifier
const newVerificationRecord = await buildVerificationRecordByIdAndType({
type: VerificationType.WebAuthn,
id: newIdentifierVerificationRecordId,
queries,
libraries,
});
assertThat(newVerificationRecord.isVerified, 'verification_record.not_found');

const bindMfa = newVerificationRecord.toBindMfa();
// Feature flag check, throw 500
if (!EnvSet.values.isDevFeaturesEnabled && ctx.guard.body.type === MfaFactor.TOTP) {
throw new Error('TOTP is not supported yet');
}

const user = await findUserById(userId);

// Check sign in experience, if webauthn is enabled
// Check sign in experience, if mfa factor is enabled
const { mfa } = await findDefaultSignInExperience();
assertThat(
mfa.factors.includes(MfaFactor.WebAuthn),
new RequestError({ code: 'session.mfa.mfa_factor_not_enabled', status: 400 })
);

const updatedUser = await updateUserById(userId, {
mfaVerifications: [
...user.mfaVerifications,
{
...bindMfa,
id: generateStandardId(),
createdAt: new Date().toISOString(),
name,
},
],
});

ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });
assertThat(mfa.factors.includes(ctx.guard.body.type), 'session.mfa.mfa_factor_not_enabled');

if (ctx.guard.body.type === MfaFactor.TOTP) {
// A user can only bind one TOTP factor
assertThat(
user.mfaVerifications.every(({ type }) => type !== MfaFactor.TOTP),
new RequestError({
code: 'user.totp_already_in_use',
status: 422,
})
);

// Check secret
assertThat(validateTotpSecret(ctx.guard.body.secret), 'user.totp_secret_invalid');

const updatedUser = await updateUserById(userId, {
mfaVerifications: [
...user.mfaVerifications,
{
id: generateStandardId(),
createdAt: new Date().toISOString(),
type: MfaFactor.TOTP,
key: ctx.guard.body.secret,
},
],
});

ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
} else if (ctx.guard.body.type === MfaFactor.WebAuthn) {
const { newIdentifierVerificationRecordId, name } = ctx.guard.body;
// Check new identifier
const newVerificationRecord = await buildVerificationRecordByIdAndType({
type: VerificationType.WebAuthn,
id: newIdentifierVerificationRecordId,
queries,
libraries,
});
assertThat(newVerificationRecord.isVerified, 'verification_record.not_found');

const bindMfa = newVerificationRecord.toBindMfa();

const updatedUser = await updateUserById(userId, {
mfaVerifications: [
...user.mfaVerifications,
{
...bindMfa,
id: generateStandardId(),
createdAt: new Date().toISOString(),
name,
},
],
});

ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser });
}

ctx.status = 204;

return next();
}
);

if (EnvSet.values.isDevFeaturesEnabled) {
router.post(
`${accountApiPrefix}/mfa-verifications/totp-secret/generate`,
koaGuard({
status: [200],
}),
async (ctx, next) => {
const secret = generateTotpSecret();
ctx.body = {
secret,
};

return next();
}
);
}

// Update mfa verification name, only support webauthn
router.patch(
`${accountApiPrefix}/mfa-verifications/:verificationId/name`,
Expand Down
1 change: 1 addition & 0 deletions packages/integration-tests/src/api/account-center.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const enableAllAccountCenterFields = async (api: KyInstance = authedAdmin
profile: AccountCenterControlValue.Edit,
social: AccountCenterControlValue.Edit,
customData: AccountCenterControlValue.Edit,
mfa: AccountCenterControlValue.Edit,
},
},
api
Expand Down
27 changes: 26 additions & 1 deletion packages/integration-tests/src/api/my-account.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type UserProfileResponse } from '@logto/schemas';
import { type UserMfaVerificationResponse, type UserProfileResponse } from '@logto/schemas';
import { type KyInstance } from 'ky';

const verificationRecordIdHeader = 'logto-verification-id';
Expand Down Expand Up @@ -74,3 +74,28 @@ export const updateOtherProfile = async (api: KyInstance, body: Record<string, u

export const getUserInfo = async (api: KyInstance) =>
api.get('api/my-account').json<Partial<UserProfileResponse>>();

export const generateTotpSecret = async (api: KyInstance) =>
api.post('api/my-account/mfa-verifications/totp-secret/generate').json<{ secret: string }>();

export const getMfaVerifications = async (api: KyInstance) =>
api.get('api/my-account/mfa-verifications').json<UserMfaVerificationResponse>();

export const addMfaVerification = async (
api: KyInstance,
verificationRecordId: string,
body: Record<string, unknown>
) =>
api.post('api/my-account/mfa-verifications', {
json: body,
headers: { [verificationRecordIdHeader]: verificationRecordId },
});

export const deleteMfaVerification = async (
api: KyInstance,
verificationId: string,
verificationRecordId: string
) =>
api.delete(`api/my-account/mfa-verifications/${verificationId}`, {
headers: { [verificationRecordIdHeader]: verificationRecordId },
});
Loading
Loading