Skip to content

Commit

Permalink
feat(core): implement the missing mfa bind and guard flow
Browse files Browse the repository at this point in the history
implement the missing mfa bind and guard glow
  • Loading branch information
simeng-li committed Jul 23, 2024
1 parent 9f9116b commit 399ab20
Show file tree
Hide file tree
Showing 11 changed files with 609 additions and 43 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* eslint-disable max-lines */
import { type ToZodObject } from '@logto/connector-kit';
import {
InteractionEvent,
MfaFactor,
VerificationType,
type UpdateProfileApiPayload,
type User,
Expand All @@ -18,7 +20,9 @@ import { interactionProfileGuard, type Interaction, type InteractionProfile } fr
import {
getNewUserProfileFromVerificationRecord,
identifyUserByVerificationRecord,
mergeUserMfaVerifications,
} from './helpers.js';
import { Mfa, mfaDataGuard, userMfaDataKey, type MfaData } from './mfa.js';
import { Profile } from './profile.js';
import { toUserSocialIdentityData } from './utils.js';
import { MfaValidator } from './validators/mfa-validator.js';
Expand All @@ -37,13 +41,15 @@ type InteractionStorage = {
interactionEvent?: InteractionEvent;
userId?: string;
profile?: InteractionProfile;
bindMfa?: MfaData;
verificationRecords?: VerificationRecordData[];
};

const interactionStorageGuard = z.object({
interactionEvent: z.nativeEnum(InteractionEvent).optional(),
userId: z.string().optional(),
profile: interactionProfileGuard.optional(),
bindMfa: mfaDataGuard.optional(),
verificationRecords: verificationRecordDataGuard.array().optional(),
}) satisfies ToZodObject<InteractionStorage>;

Expand All @@ -64,6 +70,7 @@ export default class ExperienceInteraction {
private userCache?: User;
/** The user provided profile data in the current interaction that needs to be stored to database. */
readonly #profile: Profile;
readonly #bindMfa: Mfa;
/** The interaction event for the current interaction. */
#interactionEvent?: InteractionEvent;

Expand All @@ -85,6 +92,7 @@ export default class ExperienceInteraction {

if (!interactionDetails) {
this.#profile = new Profile(libraries, queries, {}, async () => this.getIdentifiedUser());
this.#bindMfa = new Mfa(libraries, queries, {}, async () => this.getIdentifiedUser());
return;
}

Expand All @@ -96,11 +104,18 @@ export default class ExperienceInteraction {
new RequestError({ code: 'session.interaction_not_found', status: 404 })
);

const { verificationRecords = [], profile = {}, userId, interactionEvent } = result.data;
const {
verificationRecords = [],
profile = {},
bindMfa = {},
userId,
interactionEvent,
} = result.data;

this.#interactionEvent = interactionEvent;
this.userId = userId;
this.#profile = new Profile(libraries, queries, profile, async () => this.getIdentifiedUser());
this.#bindMfa = new Mfa(libraries, queries, bindMfa, async () => this.getIdentifiedUser());

for (const record of verificationRecords) {
const instance = buildVerificationRecord(libraries, queries, record);
Expand Down Expand Up @@ -188,6 +203,9 @@ export default class ExperienceInteraction {
this.verificationRecords.setValue(record);
}

/**
* @throws {RequestError} with 404 if the verification record is not found
*/
public getVerificationRecordByTypeAndId<K extends keyof VerificationRecordMap>(
type: K,
verificationId: string
Expand All @@ -202,10 +220,11 @@ export default class ExperienceInteraction {
return record;
}

/**
* @throws {RequestError} with 404 if the verification record is not found
* @throws {RequestError} with 422 if the profile is invalid
*/
public async addUserProfile({ email, phone, username, password }: UpdateProfileApiPayload) {
// Guard current interaction event is MFA verified
await this.guardMfaVerificationStatus();

const primaryEmail =
email &&
this.getVerificationRecordByTypeAndId(
Expand Down Expand Up @@ -233,11 +252,53 @@ export default class ExperienceInteraction {
}
}

/**
* @throws {RequestError} with 404 if the interaction is not identified
* @throws {RequestError} with 422 if the password is invalid
*/
public async resetPassword(password: string) {
await this.getIdentifiedUser();
await this.#profile.setPasswordDigest(password, true);
}

public async skipMfa() {
await this.#bindMfa.skip();
}

Check warning on line 265 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L264-L265

Added lines #L264 - L265 were not covered by tests

/**
* @throws {RequestError} with 404 if the verification record is not found
* @throws {RequestError} with 422 if the mfa factor is not allowed
*/
public async bindMfaByVerificationRecord(
type: MfaFactor.TOTP | MfaFactor.WebAuthn,
verificationRecordId: string
) {
switch (type) {
case MfaFactor.TOTP: {
const verificationRecord = this.getVerificationRecordByTypeAndId(
VerificationType.TOTP,
verificationRecordId
);
await this.#bindMfa.addTotp(verificationRecord.toBindMfa());
break;
}
case MfaFactor.WebAuthn: {
const verificationRecord = this.getVerificationRecordByTypeAndId(
VerificationType.WebAuthn,
verificationRecordId
);
await this.#bindMfa.addWebAuthn(verificationRecord.toBindMfa());
break;
}
}
}

Check warning on line 293 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L272-L293

Added lines #L272 - L293 were not covered by tests

/**
* @throws {RequestError} with 422 if the backup codes is not allowed
*/
public async generateBackupCodes() {
return this.#bindMfa.addBackupCodes();
}

Check warning on line 300 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L299-L300

Added lines #L299 - L300 were not covered by tests

/**
* Validate the interaction verification records against the sign-in experience and user MFA settings.
* The interaction is verified if at least one user enabled MFA verification record is present and verified.
Expand All @@ -256,7 +317,7 @@ export default class ExperienceInteraction {
isVerified,
new RequestError(
{ code: 'session.mfa.require_mfa_verification', status: 403 },
{ availableFactors: mfaValidator.availableMfaVerificationTypes }
{ availableFactors: mfaValidator.availableUserMfaVerificationTypes }

Check warning on line 320 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L320

Added line #L320 was not covered by tests
)
);
}
Expand Down Expand Up @@ -293,7 +354,7 @@ export default class ExperienceInteraction {
**/
public async submit() {
const {
queries: { users: userQueries, userSsoIdentities: userSsoIdentitiesQueries },
queries: { users: userQueries },

Check warning on line 357 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L357

Added line #L357 was not covered by tests
} = this.tenant;

// Initiated
Expand Down Expand Up @@ -335,7 +396,14 @@ export default class ExperienceInteraction {
// Profile fulfilled
await this.#profile.assertUserMandatoryProfileFulfilled();

// Revalidate the new MFA data if any
await this.#bindMfa.checkAvailability();

// MFA fulfilled
await this.#bindMfa.assertUserMandatoryMfaFulfilled();

Check warning on line 404 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L399-L404

Added lines #L399 - L404 were not covered by tests
const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.#profile.data;
const { mfaSkipped, mfaVerifications } = this.#bindMfa.toUserMfaVerifications();

Check warning on line 406 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L406

Added line #L406 was not covered by tests

// Update user profile
await userQueries.updateUserById(user.id, {
Expand All @@ -348,6 +416,21 @@ export default class ExperienceInteraction {
},
}
),
...conditional(
mfaVerifications.length > 0 && {
mfaVerifications: mergeUserMfaVerifications(user.mfaVerifications, mfaVerifications),
}
),
...conditional(
mfaSkipped && {
logtoConfig: {
...user.logtoConfig,
[userMfaDataKey]: {
skipped: true,
},
},
}
),

Check warning on line 433 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L419-L433

Added lines #L419 - L433 were not covered by tests
lastSignInAt: Date.now(),
});

Expand All @@ -374,6 +457,7 @@ export default class ExperienceInteraction {
interactionEvent,
userId,
profile: this.#profile.data,
bindMfa: this.#bindMfa.data,

Check warning on line 460 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L460

Added line #L460 was not covered by tests
verificationRecords: this.verificationRecordsArray.map((record) => record.toJson()),
};
}
Expand Down Expand Up @@ -481,3 +565,4 @@ export default class ExperienceInteraction {
await provider.interactionResult(this.ctx.req, this.ctx.res, {});
}
}
/* eslint-enable max-lines */
19 changes: 18 additions & 1 deletion packages/core/src/routes/experience/classes/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* we have moved some of the standalone functions into this file.
*/

import { VerificationType, type User } from '@logto/schemas';
import { MfaFactor, VerificationType, type User } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';

import RequestError from '#src/errors/RequestError/index.js';
Expand Down Expand Up @@ -112,3 +112,20 @@ export const identifyUserByVerificationRecord = async (
}
}
};

/**
* Should remove the old backup codes verification if the user is binding a new one
*/
export const mergeUserMfaVerifications = (
userMfaVerifications: User['mfaVerifications'],
newMfaVerifications: User['mfaVerifications']
): User['mfaVerifications'] => {
if (newMfaVerifications.some((verification) => verification.type === MfaFactor.BackupCode)) {
const filteredMfaVerifications = userMfaVerifications.filter(({ type }) => {
return type !== MfaFactor.BackupCode;
});
return [...filteredMfaVerifications, ...newMfaVerifications];
}

return [...userMfaVerifications, ...newMfaVerifications];
};

Check warning on line 131 in packages/core/src/routes/experience/classes/helpers.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/helpers.ts#L120-L131

Added lines #L120 - L131 were not covered by tests
Loading

0 comments on commit 399ab20

Please sign in to comment.