Skip to content

Commit

Permalink
refactor(core): refactor the interaction set profile flow
Browse files Browse the repository at this point in the history
refactor the interaction set profile flow
  • Loading branch information
simeng-li committed Jul 25, 2024
1 parent 453c8af commit b557044
Show file tree
Hide file tree
Showing 6 changed files with 139 additions and 98 deletions.
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { type ToZodObject } from '@logto/connector-kit';
import {
InteractionEvent,
VerificationType,
type UpdateProfileApiPayload,
type User,
} from '@logto/schemas';
import { conditional, removeUndefinedKeys } from '@silverhand/essentials';
import { InteractionEvent, type User } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { z } from 'zod';

import RequestError from '#src/errors/RequestError/index.js';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';

import { interactionProfileGuard, type Interaction, type InteractionProfile } from '../types.js';
import {
interactionProfileGuard,
type Interaction,
type InteractionContext,
type InteractionProfile,
} from '../types.js';

import {
getNewUserProfileFromVerificationRecord,
Expand Down Expand Up @@ -56,14 +56,14 @@ const interactionStorageGuard = z.object({
export default class ExperienceInteraction {
public readonly signInExperienceValidator: SignInExperienceValidator;
public readonly provisionLibrary: ProvisionLibrary;
readonly profile: Profile;

/** The user verification record list for the current interaction. */
private readonly verificationRecords = new VerificationRecordsMap();
/** The userId of the user for the current interaction. Only available once the user is identified. */
private userId?: string;
private userCache?: User;
/** The user provided profile data in the current interaction that needs to be stored to database. */
readonly #profile: Profile;
/** The interaction event for the current interaction. */
#interactionEvent?: InteractionEvent;

Expand All @@ -83,8 +83,14 @@ export default class ExperienceInteraction {
this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries);
this.provisionLibrary = new ProvisionLibrary(tenant, ctx);

const interactionContext: InteractionContext = {
getIdentifierUser: async () => this.getIdentifiedUser(),
getVerificationRecordByTypeAndId: (type, verificationId) =>
this.getVerificationRecordByTypeAndId(type, verificationId),
};

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

Expand All @@ -100,7 +106,7 @@ export default class ExperienceInteraction {

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

for (const record of verificationRecords) {
const instance = buildVerificationRecord(libraries, queries, record);
Expand Down Expand Up @@ -202,42 +208,6 @@ export default class ExperienceInteraction {
return record;
}

public async addUserProfile({ email, phone, username, password }: UpdateProfileApiPayload) {
// Guard current interaction event is MFA verified
await this.guardMfaVerificationStatus();

const primaryEmail =
email &&
this.getVerificationRecordByTypeAndId(
VerificationType.EmailVerificationCode,
email.verificationId
).toUserProfile().primaryEmail;

const primaryPhone =
phone &&
this.getVerificationRecordByTypeAndId(
VerificationType.PhoneVerificationCode,
phone.verificationId
).toUserProfile().primaryPhone;

await this.#profile.setProfileWithValidation(
removeUndefinedKeys({
primaryEmail,
primaryPhone,
username,
})
);

if (password) {
await this.#profile.setPasswordDigest(password);
}
}

public async resetPassword(password: string) {
await this.getIdentifiedUser();
await this.#profile.setPasswordDigest(password, true);
}

/**
* 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 @@ -249,7 +219,6 @@ export default class ExperienceInteraction {
const user = await this.getIdentifiedUser();
const mfaSettings = await this.signInExperienceValidator.getMfaSettings();
const mfaValidator = new MfaValidator(mfaSettings, user);

const isVerified = mfaValidator.isMfaVerified(this.verificationRecordsArray);

assertThat(
Expand Down Expand Up @@ -307,7 +276,7 @@ export default class ExperienceInteraction {

// Forgot Password: No need to verify MFAs and profile data for forgot password flow
if (this.#interactionEvent === InteractionEvent.ForgotPassword) {
const { passwordEncrypted, passwordEncryptionMethod } = this.#profile.data;
const { passwordEncrypted, passwordEncryptionMethod } = this.profile.data;

assertThat(
passwordEncrypted && passwordEncryptionMethod,
Expand All @@ -330,12 +299,12 @@ export default class ExperienceInteraction {
await this.guardMfaVerificationStatus();

// Revalidate the new profile data if any
await this.#profile.checkAvailability();
await this.profile.validateAvailability();

// Profile fulfilled
await this.#profile.assertUserMandatoryProfileFulfilled();
await this.profile.assertUserMandatoryProfileFulfilled();

Check warning on line 305 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#L298-L305

Added lines #L298 - L305 were not covered by tests

const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.#profile.data;
const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.profile.data;

Check warning on line 307 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#L307

Added line #L307 was not covered by tests

// Update user profile
await userQueries.updateUserById(user.id, {
Expand Down Expand Up @@ -373,7 +342,7 @@ export default class ExperienceInteraction {
return {
interactionEvent,
userId,
profile: this.#profile.data,
profile: this.profile.data,

Check warning on line 345 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#L345

Added line #L345 was not covered by tests
verificationRecords: this.verificationRecordsArray.map((record) => record.toJson()),
};
}
Expand Down Expand Up @@ -422,7 +391,7 @@ export default class ExperienceInteraction {
// Note: The profile data is not saved to the user profile until the user submits the interaction.
// Also no need to validate the synced profile data availability as it is already validated during the identification process.

Check warning on line 392 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#L386-L392

Added lines #L386 - L392 were not covered by tests
if (syncedProfile) {
this.#profile.unsafeSet(syncedProfile);
this.profile.unsafeSet(syncedProfile);

Check warning on line 394 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#L394

Added line #L394 was not covered by tests
}
}

Expand All @@ -434,7 +403,7 @@ export default class ExperienceInteraction {
*/
private async createNewUser(verificationRecord: VerificationRecord) {
const newProfile = await getNewUserProfileFromVerificationRecord(verificationRecord);
await this.#profile.profileValidator.guardProfileUniquenessAcrossUsers(newProfile);
await this.profile.profileValidator.guardProfileUniquenessAcrossUsers(newProfile);

const user = await this.provisionLibrary.createUser(newProfile);

Expand Down
44 changes: 31 additions & 13 deletions packages/core/src/routes/experience/classes/profile.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { type User } from '@logto/schemas';
import { type VerificationType } from '@logto/schemas';

import RequestError from '#src/errors/RequestError/index.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';

import { type InteractionProfile } from '../types.js';
import { type InteractionContext, type InteractionProfile } from '../types.js';

import { PasswordValidator } from './validators/password-validator.js';
import { ProfileValidator } from './validators/profile-validator.js';
import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js';
import { PasswordValidator } from './libraries/password-validator.js';
import { ProfileValidator } from './libraries/profile-validator.js';
import { SignInExperienceValidator } from './libraries/sign-in-experience-validator.js';

export class Profile {
readonly profileValidator: ProfileValidator;
Expand All @@ -19,7 +19,7 @@ export class Profile {
private readonly libraries: Libraries,
queries: Queries,
data: InteractionProfile,
private readonly getUserFromContext: () => Promise<User>
private readonly interactionContext: InteractionContext
) {
this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries);
this.profileValidator = new ProfileValidator(queries);
Expand All @@ -31,13 +31,31 @@ export class Profile {
}

Check warning on line 31 in packages/core/src/routes/experience/classes/profile.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/profile.ts#L30-L31

Added lines #L30 - L31 were not covered by tests

/**
* Sets the profile data with validation.
* Set the identified email or phone to the profile using the verification record.
*
* @throws {RequestError} 422 if the profile data already exists in the current user account.
* @throws {RequestError} 422 if the unique identifier data already exists in another user account.
*/
async setProfileByVerificationRecord(
type: VerificationType.EmailVerificationCode | VerificationType.PhoneVerificationCode,
verificationId: string
) {
const verificationRecord = this.interactionContext.getVerificationRecordByTypeAndId(
type,
verificationId
);
const profile = verificationRecord.toUserProfile();
await this.setProfileWithValidation(profile);
}

Check warning on line 49 in packages/core/src/routes/experience/classes/profile.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/profile.ts#L40-L49

Added lines #L40 - L49 were not covered by tests

/**
* Set the profile data with validation.
*
* @throws {RequestError} 422 if the profile data already exists in the current user account.
* @throws {RequestError} 422 if the unique identifier data already exists in another user account.
*/
async setProfileWithValidation(profile: InteractionProfile) {
const user = await this.getUserFromContext();
const user = await this.interactionContext.getIdentifierUser();
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, profile);
await this.profileValidator.guardProfileUniquenessAcrossUsers(profile);
this.unsafeSet(profile);
Expand All @@ -50,8 +68,8 @@ export class Profile {
* @throws {RequestError} 422 if the password does not meet the password policy.
* @throws {RequestError} 422 if the password is the same as the current user's password.
*/
async setPasswordDigest(password: string, reset = false) {
const user = await this.getUserFromContext();
async setPasswordDigestWithValidation(password: string, reset = false) {
const user = await this.interactionContext.getIdentifierUser();
const passwordPolicy = await this.signInExperienceValidator.getPasswordPolicy();
const passwordValidator = new PasswordValidator(passwordPolicy, user);
await passwordValidator.validatePassword(password, this.#data);
Expand All @@ -70,8 +88,8 @@ export class Profile {
* @throws {RequestError} 422 if the profile data already exists in the current user account.
* @throws {RequestError} 422 if the unique identifier data already exists in another user account.
*/
async checkAvailability() {
const user = await this.getUserFromContext();
async validateAvailability() {
const user = await this.interactionContext.getIdentifierUser();
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, this.#data);
await this.profileValidator.guardProfileUniquenessAcrossUsers(this.#data);
}

Check warning on line 95 in packages/core/src/routes/experience/classes/profile.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/profile.ts#L92-L95

Added lines #L92 - L95 were not covered by tests
Expand All @@ -80,7 +98,7 @@ export class Profile {
* Checks if the user has fulfilled the mandatory profile fields.
*/
async assertUserMandatoryProfileFulfilled() {
const user = await this.getUserFromContext();
const user = await this.interactionContext.getIdentifierUser();
const mandatoryProfileFields =
await this.signInExperienceValidator.getMandatoryUserProfileBySignUpMethods();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
VerificationType,
type User,
type VerificationCodeIdentifier,
type VerificationCodeSignInIdentifier,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { z } from 'zod';
Expand Down Expand Up @@ -67,6 +68,11 @@ export type CodeVerificationRecordData<T extends CodeVerificationType = CodeVeri
verified: boolean;
};

export const identifierCodeVerificationTypeMap = Object.freeze({
[SignInIdentifier.Email]: VerificationType.EmailVerificationCode,
[SignInIdentifier.Phone]: VerificationType.PhoneVerificationCode,
}) satisfies Record<VerificationCodeSignInIdentifier, CodeVerificationType>;

/**
* This is the parent class for `EmailCodeVerification` and `PhoneCodeVerification`. Not publicly exposed.
*/
Expand Down
42 changes: 38 additions & 4 deletions packages/core/src/routes/experience/profile-routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InteractionEvent, updateProfileApiPayloadGuard } from '@logto/schemas';
import { InteractionEvent, SignInIdentifier, updateProfileApiPayloadGuard } from '@logto/schemas';
import type Router from 'koa-router';
import { z } from 'zod';

Expand All @@ -8,14 +8,15 @@ import koaGuard from '#src/middleware/koa-guard.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';

import { identifierCodeVerificationTypeMap } from './classes/verifications/code-verification.js';
import { experienceRoutes } from './const.js';
import { type WithExperienceInteractionContext } from './middleware/koa-experience-interaction.js';

export default function interactionProfileRoutes<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
tenant: TenantContext
) {
router.patch(
router.post(
`${experienceRoutes.profile}`,
koaGuard({
body: updateProfileApiPayloadGuard,
Expand All @@ -33,16 +34,40 @@ export default function interactionProfileRoutes<T extends WithLogContext>(
})
);

// Guard MFA verification status
await experienceInteraction.guardMfaVerificationStatus();

const profilePayload = guard.body;

await experienceInteraction.addUserProfile(profilePayload);
switch (profilePayload.type) {
case SignInIdentifier.Email:
case SignInIdentifier.Phone: {
const verificationType = identifierCodeVerificationTypeMap[profilePayload.type];
await experienceInteraction.profile.setProfileByVerificationRecord(
verificationType,
profilePayload.verificationId
);
break;
}
case SignInIdentifier.Username: {
await experienceInteraction.profile.setProfileWithValidation({
username: profilePayload.value,
});
break;
}
case 'password': {
await experienceInteraction.profile.setPasswordDigestWithValidation(profilePayload.value);
}
}

await experienceInteraction.save();

ctx.status = 204;

return next();
}

Check warning on line 68 in packages/core/src/routes/experience/profile-routes.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/profile-routes.ts#L26-L68

Added lines #L26 - L68 were not covered by tests
);

router.put(
`${experienceRoutes.profile}/password`,
koaGuard({
Expand All @@ -63,7 +88,16 @@ export default function interactionProfileRoutes<T extends WithLogContext>(
})
);

await experienceInteraction.resetPassword(password);
// Guard interaction is identified
assertThat(
experienceInteraction.identifiedUserId,
new RequestError({
code: 'session.identifier_not_found',
status: 404,
})
);

await experienceInteraction.profile.setPasswordDigestWithValidation(password, true);
await experienceInteraction.save();

ctx.status = 204;
Expand Down
Loading

0 comments on commit b557044

Please sign in to comment.