From 669279aece6912ded684af028aeb976f5f7b97bb Mon Sep 17 00:00:00 2001 From: simeng-li Date: Fri, 26 Jul 2024 15:56:22 +0800 Subject: [PATCH] test(core): add the mfa binding integration tests (#6330) * refactor(core): refactor backup code generate flow refactor backup code generate flow * fix(core): fix api payload fix api payload * test(core): implement the mfa binding integration tests implement the mfa binding integration tests * test(core): rebase backup code refactor rebase backup code refactor --- .../core/src/routes/experience/classes/mfa.ts | 8 +- .../backup-code-verification.ts | 35 +++ .../src/client/experience/const.ts | 1 + .../src/client/experience/index.ts | 22 ++ .../helpers/experience/totp-verification.ts | 14 + .../bind-mfa/happpy-path.test.ts | 275 ++++++++++++++++++ .../experience-api/bind-mfa/sad-path.test.ts | 181 ++++++++++++ 7 files changed, 532 insertions(+), 4 deletions(-) create mode 100644 packages/integration-tests/src/tests/api/experience-api/bind-mfa/happpy-path.test.ts create mode 100644 packages/integration-tests/src/tests/api/experience-api/bind-mfa/sad-path.test.ts diff --git a/packages/core/src/routes/experience/classes/mfa.ts b/packages/core/src/routes/experience/classes/mfa.ts index 0924590c48a..fb023b7db81 100644 --- a/packages/core/src/routes/experience/classes/mfa.ts +++ b/packages/core/src/routes/experience/classes/mfa.ts @@ -165,7 +165,7 @@ export class Mfa { * @throws {RequestError} with status 400 if the verification record is not verified * @throws {RequestError} with status 400 if the verification record has no secret * @throws {RequestError} with status 404 if the verification record is not found - * @throws {RequestError} with status 422 if TOTP is not enabled in the sign-in experience + * @throws {RequestError} with status 400 if TOTP is not enabled in the sign-in experience * @throws {RequestError} with status 422 if the user already has a TOTP factor * * - Any existing TOTP factor will be replaced with the new one. @@ -196,7 +196,7 @@ export class Mfa { * @throws {RequestError} with status 400 if the verification record is not verified * @throws {RequestError} with status 400 if the verification record has no registration data * @throws {RequestError} with status 404 if the verification record is not found - * @throws {RequestError} with status 422 if WebAuthn is not enabled in the sign-in experience + * @throws {RequestError} with status 400 if WebAuthn is not enabled in the sign-in experience */ async addWebAuthnByVerificationId(verificationId: string) { const verificationRecord = this.interactionContext.getVerificationRecordByTypeAndId( @@ -215,7 +215,7 @@ export class Mfa { * - Any existing backup code factor will be replaced with the new one. * * @throws {RequestError} with status 404 if no pending backup codes are found - * @throws {RequestError} with status 422 if Backup Code is not enabled in the sign-in experience + * @throws {RequestError} with status 400 if Backup Code is not enabled in the sign-in experience * @throws {RequestError} with status 422 if the backup code is the only MFA factor */ async addBackupCodeByVerificationId(verificationId: string) { @@ -241,7 +241,7 @@ export class Mfa { } /** - * @throws {RequestError} with status 422 if the mfa factors are not enabled in the sign-in experience + * @throws {RequestError} with status 400 if the mfa factors are not enabled in the sign-in experience */ async checkAvailability() { const newBindMfaFactors = deduplicate(this.bindMfaFactorsArray.map(({ type }) => type)); diff --git a/packages/core/src/routes/experience/verification-routes/backup-code-verification.ts b/packages/core/src/routes/experience/verification-routes/backup-code-verification.ts index 496995d0845..2e3d9ce1341 100644 --- a/packages/core/src/routes/experience/verification-routes/backup-code-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/backup-code-verification.ts @@ -52,6 +52,41 @@ export default function backupCodeVerificationRoutes( } ); + router.post( + `${experienceRoutes.verification}/backup-code/generate`, + koaGuard({ + status: [200, 400], + response: z.object({ + verificationId: z.string(), + codes: z.array(z.string()), + }), + }), + async (ctx, next) => { + const { experienceInteraction } = ctx; + + assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found'); + + const backupCodeVerificationRecord = BackupCodeVerification.create( + libraries, + queries, + experienceInteraction.identifiedUserId + ); + + const codes = backupCodeVerificationRecord.generate(); + + ctx.experienceInteraction.setVerificationRecord(backupCodeVerificationRecord); + + await ctx.experienceInteraction.save(); + + ctx.body = { + verificationId: backupCodeVerificationRecord.id, + codes, + }; + + return next(); + } + ); + router.post( `${experienceRoutes.verification}/backup-code/verify`, koaGuard({ diff --git a/packages/integration-tests/src/client/experience/const.ts b/packages/integration-tests/src/client/experience/const.ts index dfd3d22a7a2..f7dec1da688 100644 --- a/packages/integration-tests/src/client/experience/const.ts +++ b/packages/integration-tests/src/client/experience/const.ts @@ -4,5 +4,6 @@ export const experienceRoutes = { verification: `${prefix}/verification`, identification: `${prefix}/identification`, profile: `${prefix}/profile`, + mfa: `${prefix}/profile/mfa`, prefix, }; diff --git a/packages/integration-tests/src/client/experience/index.ts b/packages/integration-tests/src/client/experience/index.ts index c58cba5c0a1..426e1b5aae3 100644 --- a/packages/integration-tests/src/client/experience/index.ts +++ b/packages/integration-tests/src/client/experience/index.ts @@ -2,6 +2,7 @@ import { type CreateExperienceApiPayload, type IdentificationApiPayload, type InteractionEvent, + type MfaFactor, type NewPasswordIdentityVerificationPayload, type PasswordVerificationPayload, type UpdateProfileApiPayload, @@ -178,6 +179,14 @@ export class ExperienceClient extends MockClient { .json<{ verificationId: string }>(); } + public async generateMfaBackupCodes() { + return api + .post(`${experienceRoutes.verification}/backup-code/generate`, { + headers: { cookie: this.interactionCookie }, + }) + .json<{ verificationId: string; codes: string[] }>(); + } + public async verifyBackupCode(payload: { code: string }) { return api .post(`${experienceRoutes.verification}/backup-code/verify`, { @@ -211,4 +220,17 @@ export class ExperienceClient extends MockClient { json: payload, }); } + + public async skipMfaBinding() { + return api.post(`${experienceRoutes.mfa}/mfa-skipped`, { + headers: { cookie: this.interactionCookie }, + }); + } + + public async bindMfa(type: MfaFactor, verificationId: string) { + return api.post(`${experienceRoutes.mfa}`, { + headers: { cookie: this.interactionCookie }, + json: { type, verificationId }, + }); + } } diff --git a/packages/integration-tests/src/helpers/experience/totp-verification.ts b/packages/integration-tests/src/helpers/experience/totp-verification.ts index ced40ce9aab..c7fe2fea9b6 100644 --- a/packages/integration-tests/src/helpers/experience/totp-verification.ts +++ b/packages/integration-tests/src/helpers/experience/totp-verification.ts @@ -1,3 +1,5 @@ +import { authenticator } from 'otplib'; + import { type ExperienceClient } from '#src/client/experience/index.js'; export const successFullyCreateNewTotpSecret = async (client: ExperienceClient) => { @@ -23,3 +25,15 @@ export const successfullyVerifyTotp = async ( return verificationId; }; + +export const successfullyCreateAndVerifyTotp = async (client: ExperienceClient) => { + const { secret, verificationId } = await successFullyCreateNewTotpSecret(client); + const code = authenticator.generate(secret); + + await successfullyVerifyTotp(client, { + code, + verificationId, + }); + + return verificationId; +}; diff --git a/packages/integration-tests/src/tests/api/experience-api/bind-mfa/happpy-path.test.ts b/packages/integration-tests/src/tests/api/experience-api/bind-mfa/happpy-path.test.ts new file mode 100644 index 00000000000..09b27d1e395 --- /dev/null +++ b/packages/integration-tests/src/tests/api/experience-api/bind-mfa/happpy-path.test.ts @@ -0,0 +1,275 @@ +import { InteractionEvent, MfaFactor, SignInIdentifier } from '@logto/schemas'; +import { authenticator } from 'otplib'; + +import { createUserMfaVerification, deleteUser } from '#src/api/admin-user.js'; +import { initExperienceClient, logoutClient, processSession } from '#src/helpers/client.js'; +import { + identifyUserWithUsernamePassword, + signInWithPassword, +} from '#src/helpers/experience/index.js'; +import { + successfullyCreateAndVerifyTotp, + successfullyVerifyTotp, +} from '#src/helpers/experience/totp-verification.js'; +import { expectRejects } from '#src/helpers/index.js'; +import { + enableAllPasswordSignInMethods, + enableMandatoryMfaWithTotp, + enableMandatoryMfaWithTotpAndBackupCode, + enableUserControlledMfaWithTotp, +} from '#src/helpers/sign-in-experience.js'; +import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js'; +import { devFeatureTest } from '#src/utils.js'; + +devFeatureTest.describe('Bind MFA APIs happy path', () => { + const userApi = new UserApiTest(); + + beforeAll(async () => { + await enableAllPasswordSignInMethods({ + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }); + }); + + afterEach(async () => { + await userApi.cleanUp(); + }); + + describe('mandatory TOTP', () => { + beforeAll(async () => { + await enableMandatoryMfaWithTotp(); + }); + + it('should bind TOTP on register', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const client = await initExperienceClient(); + await client.initInteraction({ interactionEvent: InteractionEvent.Register }); + + const { verificationId } = await client.createNewPasswordIdentityVerification({ + identifier: { + type: SignInIdentifier.Username, + value: username, + }, + password, + }); + + await client.identifyUser({ verificationId }); + + await expectRejects(client.submitInteraction(), { + code: 'user.missing_mfa', + status: 422, + }); + + const totpVerificationId = await successfullyCreateAndVerifyTotp(client); + + await client.bindMfa(MfaFactor.TOTP, totpVerificationId); + + const { redirectTo } = await client.submitInteraction(); + const userId = await processSession(client, redirectTo); + await logoutClient(client); + + const signInClient = await initExperienceClient(); + await identifyUserWithUsernamePassword(signInClient, username, password); + + await expectRejects(signInClient.submitInteraction(), { + code: 'session.mfa.require_mfa_verification', + status: 403, + }); + + await deleteUser(userId); + }); + + it('should bind TOTP on sign-in', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + await userApi.create({ username, password }); + + const client = await initExperienceClient(); + await identifyUserWithUsernamePassword(client, username, password); + + await expectRejects(client.submitInteraction(), { + code: 'user.missing_mfa', + status: 422, + }); + + const totpVerificationId = await successfullyCreateAndVerifyTotp(client); + + await client.bindMfa(MfaFactor.TOTP, totpVerificationId); + + const { redirectTo } = await client.submitInteraction(); + await processSession(client, redirectTo); + await logoutClient(client); + }); + + it('should not throw if user already has TOTP', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const user = await userApi.create({ username, password }); + const response = await createUserMfaVerification(user.id, MfaFactor.TOTP); + + if (response.type !== MfaFactor.TOTP) { + throw new Error('unexpected mfa type'); + } + + const { secret } = response; + + const client = await initExperienceClient(); + await identifyUserWithUsernamePassword(client, username, password); + const code = authenticator.generate(secret); + + await successfullyVerifyTotp(client, { code }); + + const { redirectTo } = await client.submitInteraction(); + await processSession(client, redirectTo); + await logoutClient(client); + }); + }); + + describe('user controlled TOTP', () => { + beforeAll(async () => { + await enableUserControlledMfaWithTotp(); + }); + + it('should able to skip MFA binding on register', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const client = await initExperienceClient(); + await client.initInteraction({ interactionEvent: InteractionEvent.Register }); + + const { verificationId } = await client.createNewPasswordIdentityVerification({ + identifier: { + type: SignInIdentifier.Username, + value: username, + }, + password, + }); + + await client.identifyUser({ verificationId }); + + await expectRejects(client.submitInteraction(), { + code: 'user.missing_mfa', + status: 422, + }); + + await client.skipMfaBinding(); + + const { redirectTo } = await client.submitInteraction(); + const userId = await processSession(client, redirectTo); + await logoutClient(client); + + await signInWithPassword({ + identifier: { + type: SignInIdentifier.Username, + value: username, + }, + password, + }); + + await deleteUser(userId); + }); + + it('should able to skip MFA binding on sign-in', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + await userApi.create({ username, password }); + + const client = await initExperienceClient(); + await identifyUserWithUsernamePassword(client, username, password); + + await expectRejects(client.submitInteraction(), { + code: 'user.missing_mfa', + status: 422, + }); + + await client.skipMfaBinding(); + + const { redirectTo } = await client.submitInteraction(); + await processSession(client, redirectTo); + await logoutClient(client); + }); + }); + + describe('mandatory TOTP with backup codes', () => { + beforeAll(async () => { + await enableMandatoryMfaWithTotpAndBackupCode(); + }); + + it('should bind TOTP and backup codes on register', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const client = await initExperienceClient(); + await client.initInteraction({ interactionEvent: InteractionEvent.Register }); + + const { verificationId } = await client.createNewPasswordIdentityVerification({ + identifier: { + type: SignInIdentifier.Username, + value: username, + }, + password, + }); + + await client.identifyUser({ verificationId }); + + await expectRejects(client.submitInteraction(), { + code: 'user.missing_mfa', + status: 422, + }); + + const totpVerificationId = await successfullyCreateAndVerifyTotp(client); + + await client.bindMfa(MfaFactor.TOTP, totpVerificationId); + + await expectRejects(client.submitInteraction(), { + code: 'session.mfa.backup_code_required', + status: 422, + }); + + const { codes, verificationId: backupCodeVerificationId } = + await client.generateMfaBackupCodes(); + + expect(codes.length).toBeGreaterThan(0); + + await client.bindMfa(MfaFactor.BackupCode, backupCodeVerificationId); + + const { redirectTo } = await client.submitInteraction(); + const userId = await processSession(client, redirectTo); + await logoutClient(client); + + await deleteUser(userId); + }); + + it('should bind backup codes on sign-in', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const user = await userApi.create({ username, password }); + + const result = await createUserMfaVerification(user.id, MfaFactor.TOTP); + + if (result.type !== MfaFactor.TOTP) { + throw new Error('unexpected mfa type'); + } + + const { secret } = result; + const code = authenticator.generate(secret); + + const client = await initExperienceClient(); + await identifyUserWithUsernamePassword(client, username, password); + + await expectRejects(client.submitInteraction(), { + code: 'session.mfa.require_mfa_verification', + status: 403, + }); + + await successfullyVerifyTotp(client, { code }); + + await expectRejects(client.submitInteraction(), { + code: 'session.mfa.backup_code_required', + status: 422, + }); + + const { codes, verificationId } = await client.generateMfaBackupCodes(); + expect(codes.length).toBeGreaterThan(0); + + await client.bindMfa(MfaFactor.BackupCode, verificationId); + + const { redirectTo } = await client.submitInteraction(); + await processSession(client, redirectTo); + await logoutClient(client); + }); + }); +}); diff --git a/packages/integration-tests/src/tests/api/experience-api/bind-mfa/sad-path.test.ts b/packages/integration-tests/src/tests/api/experience-api/bind-mfa/sad-path.test.ts new file mode 100644 index 00000000000..8484caf1eb8 --- /dev/null +++ b/packages/integration-tests/src/tests/api/experience-api/bind-mfa/sad-path.test.ts @@ -0,0 +1,181 @@ +import { InteractionEvent, MfaFactor, SignInIdentifier } from '@logto/schemas'; + +import { createUserMfaVerification } from '#src/api/admin-user.js'; +import { initExperienceClient } from '#src/helpers/client.js'; +import { identifyUserWithUsernamePassword } from '#src/helpers/experience/index.js'; +import { successfullyCreateAndVerifyTotp } from '#src/helpers/experience/totp-verification.js'; +import { expectRejects } from '#src/helpers/index.js'; +import { + enableAllPasswordSignInMethods, + enableMandatoryMfaWithTotp, + enableMandatoryMfaWithTotpAndBackupCode, +} from '#src/helpers/sign-in-experience.js'; +import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js'; +import { devFeatureTest } from '#src/utils.js'; + +devFeatureTest.describe('Bind MFA APIs sad path', () => { + const userApi = new UserApiTest(); + + beforeAll(async () => { + await enableAllPasswordSignInMethods({ + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }); + }); + + afterEach(async () => { + await userApi.cleanUp(); + }); + + describe('No MFA is enabled', () => { + it('should throw mfa_factor_not_enabled error when binding TOTP', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + await userApi.create({ username, password }); + const client = await initExperienceClient(); + + await identifyUserWithUsernamePassword(client, username, password); + + const totpVerificationId = await successfullyCreateAndVerifyTotp(client); + + await expectRejects(client.bindMfa(MfaFactor.TOTP, totpVerificationId), { + code: 'session.mfa.mfa_factor_not_enabled', + status: 400, + }); + }); + }); + + describe('Mandatory TOTP', () => { + beforeAll(async () => { + await enableMandatoryMfaWithTotp(); + }); + + it('should throw not supported error when binding TOTP on ForgotPassword interaction', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + await userApi.create({ username, password }); + const client = await initExperienceClient(); + await client.initInteraction({ interactionEvent: InteractionEvent.ForgotPassword }); + + await expectRejects(client.skipMfaBinding(), { + code: 'session.not_supported_for_forgot_password', + status: 400, + }); + + await expectRejects(client.bindMfa(MfaFactor.TOTP, 'dummy_verification_id'), { + code: 'session.not_supported_for_forgot_password', + status: 400, + }); + }); + + it('should throw identifier_not_found error, if user has not been identified', async () => { + const client = await initExperienceClient(); + await client.initInteraction({ interactionEvent: InteractionEvent.SignIn }); + await expectRejects(client.bindMfa(MfaFactor.TOTP, 'dummy_verification_id'), { + code: 'session.identifier_not_found', + status: 404, + }); + }); + + it('should throw mfa_factor_not_enabled error when trying to bind backup code', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + await userApi.create({ username, password }); + const client = await initExperienceClient(); + await identifyUserWithUsernamePassword(client, username, password); + + const { verificationId } = await client.generateMfaBackupCodes(); + + await expectRejects(client.bindMfa(MfaFactor.BackupCode, verificationId), { + code: 'session.mfa.mfa_factor_not_enabled', + status: 400, + }); + }); + + it('should throw mfa_policy_not_user_controlled error when trying to skip MFA binding', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + await userApi.create({ username, password }); + const client = await initExperienceClient(); + await identifyUserWithUsernamePassword(client, username, password); + + await expectRejects(client.skipMfaBinding(), { + code: 'session.mfa.mfa_policy_not_user_controlled', + status: 422, + }); + }); + }); + + describe('Mandatory TOTP and Backup Code', () => { + beforeAll(async () => { + await enableMandatoryMfaWithTotpAndBackupCode(); + }); + + it('should throw if user has a TOTP in record', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const user = await userApi.create({ username, password }); + + await createUserMfaVerification(user.id, MfaFactor.TOTP); + const response = await createUserMfaVerification(user.id, MfaFactor.BackupCode); + + if (response.type !== MfaFactor.BackupCode) { + throw new Error('unexpected mfa type'); + } + + const code = response.codes[0]!; + + const client = await initExperienceClient(); + await identifyUserWithUsernamePassword(client, username, password); + await client.verifyBackupCode({ code }); + + const totpVerificationId = await successfullyCreateAndVerifyTotp(client); + + await expectRejects(client.bindMfa(MfaFactor.TOTP, totpVerificationId), { + code: 'user.totp_already_in_use', + status: 422, + }); + }); + + it('should throw if the interaction is not verified, when add new backup codes', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const user = await userApi.create({ username, password }); + await createUserMfaVerification(user.id, MfaFactor.TOTP); + + const client = await initExperienceClient(); + await identifyUserWithUsernamePassword(client, username, password); + const { verificationId } = await client.generateMfaBackupCodes(); + + await expectRejects(client.bindMfa(MfaFactor.BackupCode, verificationId), { + code: 'session.mfa.require_mfa_verification', + status: 403, + }); + }); + + it('should throw if the backup codes is the only MFA factor', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + await userApi.create({ username, password }); + + const client = await initExperienceClient(); + await identifyUserWithUsernamePassword(client, username, password); + + const { verificationId } = await client.generateMfaBackupCodes(); + + await expectRejects(client.bindMfa(MfaFactor.BackupCode, verificationId), { + code: 'session.mfa.backup_code_can_not_be_alone', + status: 422, + }); + }); + + it('should throw if no pending backup codes is found', async () => { + const { username, password } = generateNewUserProfile({ username: true, password: true }); + await userApi.create({ username, password }); + + const client = await initExperienceClient(); + await identifyUserWithUsernamePassword(client, username, password); + const totpVerificationId = await successfullyCreateAndVerifyTotp(client); + await client.bindMfa(MfaFactor.TOTP, totpVerificationId); + + await expectRejects(client.bindMfa(MfaFactor.BackupCode, 'invalid_verification'), { + code: 'session.verification_session_not_found', + status: 404, + }); + }); + }); +});