0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

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
This commit is contained in:
simeng-li 2024-07-26 15:56:22 +08:00 committed by GitHub
parent 556f7e43a7
commit 669279aece
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 532 additions and 4 deletions

View file

@ -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));

View file

@ -52,6 +52,41 @@ export default function backupCodeVerificationRoutes<T extends WithLogContext>(
}
);
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({

View file

@ -4,5 +4,6 @@ export const experienceRoutes = {
verification: `${prefix}/verification`,
identification: `${prefix}/identification`,
profile: `${prefix}/profile`,
mfa: `${prefix}/profile/mfa`,
prefix,
};

View file

@ -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 },
});
}
}

View file

@ -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;
};

View file

@ -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);
});
});
});

View file

@ -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,
});
});
});
});