Skip to content
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

refactor(core,schemas): refactor the register flow #6401

Merged
merged 4 commits into from
Aug 7, 2024
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe('ExperienceInteraction class', () => {
);

experienceInteraction.setVerificationRecord(emailVerificationRecord);
await experienceInteraction.identifyUser(emailVerificationRecord.id);
await experienceInteraction.createUser(emailVerificationRecord.id);

expect(userLibraries.insertUser).toHaveBeenCalledWith(
{
Expand Down
211 changes: 98 additions & 113 deletions packages/core/src/routes/experience/classes/experience-interaction.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
/* eslint-disable max-lines */
import { type ToZodObject } from '@logto/connector-kit';
import {
InteractionEvent,
SignInIdentifier,
VerificationType,
type InteractionIdentifier,
type User,
} from '@logto/schemas';
import { InteractionEvent, VerificationType, type User } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { z } from 'zod';

Expand Down Expand Up @@ -34,7 +28,6 @@
import { Mfa, mfaDataGuard, userMfaDataKey, type MfaData } from './mfa.js';
import { Profile } from './profile.js';
import { toUserSocialIdentityData } from './utils.js';
import { identifierCodeVerificationTypeMap } from './verifications/code-verification.js';
import {
buildVerificationRecord,
verificationRecordDataGuard,
Expand Down Expand Up @@ -105,6 +98,7 @@
this.provisionLibrary = new ProvisionLibrary(tenant, ctx);

const interactionContext: InteractionContext = {
getInteractionEvent: () => this.#interactionEvent,
getIdentifiedUser: async () => this.getIdentifiedUser(),
getVerificationRecordByTypeAndId: (type, verificationId) =>
this.getVerificationRecordByTypeAndId(type, verificationId),
Expand Down Expand Up @@ -153,7 +147,9 @@
}

/**
* Set the interaction event for the current interaction
* Switch the interaction event for the current interaction sign-in <> register
*
* - any pending profile data will be cleared
*
* @throws RequestError with 403 if the interaction event is not allowed by the `SignInExperienceValidator`
* @throws RequestError with 400 if the interaction event is `ForgotPassword` and the current interaction event is not `ForgotPassword`
Expand All @@ -170,6 +166,10 @@
new RequestError({ code: 'session.not_supported_for_forgot_password', status: 400 })
);

if (this.#interactionEvent !== interactionEvent) {
simeng-li marked this conversation as resolved.
Show resolved Hide resolved
this.profile.cleanUp();
}

Check warning on line 172 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#L169-L172

Added lines #L169 - L172 were not covered by tests
this.#interactionEvent = interactionEvent;
}

Expand All @@ -178,44 +178,117 @@
*
* - Check if the verification record exists.
* - Verify the verification record with {@link SignInExperienceValidator}.
* - Create a new user using the verification record if the current interaction event is `Register`.
* - Identify the user using the verification record if the current interaction event is `SignIn` or `ForgotPassword`.
* - Set the user id to the current interaction.
*
* @throws RequestError with 404 if the interaction event is not set.
* @throws RequestError with 404 if the verification record is not found.
* @throws RequestError with 422 if the verification record is not enabled in the SIE settings.
* @see {@link identifyExistingUser} for more exceptions that can be thrown in the SignIn and ForgotPassword events.
* @see {@link createNewUser} for more exceptions that can be thrown in the Register event.
* @param linkSocialIdentity Applies only to the SocialIdentity verification record sign-in events only.
* If true, the social identity will be linked to related user.
*
* @throws {RequestError} with 400 if the verification record is not verified or not valid for identifying a user
* @throws {RequestError} with 403 if the interaction event is not allowed
* @throws {RequestError} with 404 if the user is not found
* @throws {RequestError} with 401 if the user is suspended
* @throws {RequestError} with 409 if the current session has already identified a different user
**/
public async identifyUser(verificationId: string, linkSocialIdentity?: boolean, log?: LogEntry) {
assertThat(
simeng-li marked this conversation as resolved.
Show resolved Hide resolved
this.interactionEvent !== InteractionEvent.Register,
new RequestError({ code: 'session.invalid_interaction_type', status: 400 })
);

Check warning on line 197 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#L193-L197

Added lines #L193 - L197 were not covered by tests
const verificationRecord = this.getVerificationRecordById(verificationId);

log?.append({
verification: verificationRecord?.toJson(),
});

assertThat(
this.interactionEvent,
new RequestError({ code: 'session.interaction_not_found', status: 404 })
);

assertThat(
verificationRecord,
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);

await this.signInExperienceValidator.verifyIdentificationMethod(
await this.signInExperienceValidator.guardIdentificationMethod(

Check warning on line 209 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#L209

Added line #L209 was not covered by tests
this.interactionEvent,
verificationRecord
);

if (this.interactionEvent === InteractionEvent.Register) {
await this.createNewUser(verificationRecord);
const { user, syncedProfile } = await identifyUserByVerificationRecord(
verificationRecord,
linkSocialIdentity
);

const { id, isSuspended } = user;
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));

// Throws an 409 error if the current session has already identified a different user
if (this.userId) {
assertThat(
this.userId === id,
new RequestError({ code: 'session.identity_conflict', status: 409 })
);

Check warning on line 227 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#L214-L227

Added lines #L214 - L227 were not covered by tests
return;
}

await this.identifyExistingUser(verificationRecord, linkSocialIdentity);
// Update the current interaction with the identified user
this.userCache = user;
this.userId = id;

// Sync social/enterprise SSO identity profile data.
// 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.
if (syncedProfile) {
const log = this.ctx.createLog(`Interaction.${this.interactionEvent}.Profile.Update`);
log.append({ syncedProfile });
this.profile.unsafeSet(syncedProfile);
}
}

Check warning on line 243 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#L231-L243

Added lines #L231 - L243 were not covered by tests

/**
* Create new user using the profile data in the current interaction.
*
* - if a `verificationId` is provided, the profile data will be updated with the verification record data.
* - id no `verificationId` is provided, directly create a new user with the current profile data.
*
* @throws {RequestError} with 403 if the register is not allowed by the sign-in experience settings
* @throws {RequestError} with 404 if a `verificationId` is provided but the verification record is not found
* @throws {RequestError} with 400 if the verification record can not be used for creating a new user or not verified
* @throws {RequestError} with 422 if the profile data is not unique across users
* @throws {RequestError} with 422 if any of required profile fields are missing
*/
public async createUser(verificationId?: string, log?: LogEntry) {
simeng-li marked this conversation as resolved.
Show resolved Hide resolved
assertThat(
this.interactionEvent === InteractionEvent.Register,
new RequestError({ code: 'session.invalid_interaction_type', status: 400 })
);

await this.signInExperienceValidator.guardInteractionEvent(InteractionEvent.Register);

if (verificationId) {
const verificationRecord = this.getVerificationRecordById(verificationId);

assertThat(
verificationRecord,
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);

log?.append({
verification: verificationRecord.toJson(),
});

const identifierProfile = await getNewUserProfileFromVerificationRecord(verificationRecord);

await this.profile.setProfileWithValidation(identifierProfile);

// Save the updated profile data to the interaction storage
await this.save();
}

await this.profile.assertUserMandatoryProfileFulfilled();

const user = await this.provisionLibrary.createUser(this.profile.data);

this.userId = user.id;
this.userCache = user;
this.profile.cleanUp();
}

/**
Expand Down Expand Up @@ -425,80 +498,6 @@
return this.verificationRecords.array();
}

/**
* Identify the existing user using the verification record.
*
* @param linkSocialIdentity Applies only to the SocialIdentity verification record sign-in events only.
* If true, the social identity will be linked to related user.
*
* @throws RequestError with 400 if the verification record is not verified or not valid for identifying a user
* @throws RequestError with 404 if the user is not found
* @throws RequestError with 401 if the user is suspended
* @throws RequestError with 409 if the current session has already identified a different user
*/
private async identifyExistingUser(
verificationRecord: VerificationRecord,
linkSocialIdentity?: boolean
) {
const { user, syncedProfile } = await identifyUserByVerificationRecord(
verificationRecord,
linkSocialIdentity
);

const { id, isSuspended } = user;
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));

// Throws an 409 error if the current session has already identified a different user
if (this.userId) {
assertThat(
this.userId === id,
new RequestError({ code: 'session.identity_conflict', status: 409 })
);
return;
}

// Update the current interaction with the identified user
this.userCache = user;
this.userId = id;

// Sync social/enterprise SSO identity profile data.
// 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.
if (syncedProfile) {
const log = this.ctx.createLog(`Interaction.${this.interactionEvent}.Profile.Update`);
log.append({ syncedProfile });
this.profile.unsafeSet(syncedProfile);
}
}

/**
* Create a new user using the verification record.
*
* @throws {RequestError} with 422 if a new password identity verification is provided, but identifier (email/phone) is not verified
* @throws {RequestError} with 400 if the verification record can not be used for creating a new user or not verified
* @throws {RequestError} with 422 if the profile data is not unique across users
* @throws {RequestError} with 422 if the password is required for the sign-up settings but only email/phone verification record is provided
*/
private async createNewUser(verificationRecord: VerificationRecord) {
if (verificationRecord.type === VerificationType.NewPasswordIdentity) {
const { identifier } = verificationRecord;
assertThat(
this.isIdentifierVerified(identifier),
new RequestError(
{ code: 'session.identifier_not_verified', status: 422 },
{ identifier: identifier.value }
)
);
}

const newProfile = await getNewUserProfileFromVerificationRecord(verificationRecord);
await this.profile.profileValidator.guardProfileUniquenessAcrossUsers(newProfile);

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

this.userId = user.id;
}

/**
* Assert the interaction is identified and return the identified user.
* @throws RequestError with 404 if the if the user is not identified or not found
Expand Down Expand Up @@ -531,20 +530,6 @@
return this.verificationRecordsArray.find((record) => record.id === verificationId);
}

private isIdentifierVerified(identifier: InteractionIdentifier) {
const { type, value } = identifier;

if (type === SignInIdentifier.Username) {
return true;
}

const verificationRecord = this.verificationRecords.get(
identifierCodeVerificationTypeMap[type]
);

return verificationRecord?.identifier.value === value && verificationRecord.isVerified;
}

private get hasVerifiedSsoIdentity() {
const ssoVerificationRecord = this.verificationRecords.get(VerificationType.EnterpriseSso);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,17 @@
// eslint-disable-next-line complexity
public getMissingUserProfile(
profile: InteractionProfile,
user: User,
mandatoryUserProfile: Set<MissingProfile>
mandatoryUserProfile: Set<MissingProfile>,
user?: User
simeng-li marked this conversation as resolved.
Show resolved Hide resolved
): Set<MissingProfile> {
const missingProfile = new Set<MissingProfile>();

if (mandatoryUserProfile.has(MissingProfile.password)) {
// Social and enterprise SSO identities can take place the role of password
const isUserPasswordSet =
Boolean(user.passwordEncrypted) || Object.keys(user.identities).length > 0;
const isUserPasswordSet = user
? // Social and enterprise SSO identities can take place the role of password
Boolean(user.passwordEncrypted) || Object.keys(user.identities).length > 0
: false;

Check warning on line 145 in packages/core/src/routes/experience/classes/libraries/profile-validator.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/libraries/profile-validator.ts#L141-L145

Added lines #L141 - L145 were not covered by tests
const isProfilePasswordSet = Boolean(
profile.passwordEncrypted ?? profile.socialIdentity ?? profile.enterpriseSsoIdentity
);
Expand All @@ -150,14 +152,14 @@
}
}

if (mandatoryUserProfile.has(MissingProfile.username) && !user.username && !profile.username) {
if (mandatoryUserProfile.has(MissingProfile.username) && !user?.username && !profile.username) {
missingProfile.add(MissingProfile.username);
}

if (
mandatoryUserProfile.has(MissingProfile.emailOrPhone) &&
!user.primaryPhone &&
!user.primaryEmail &&
!user?.primaryPhone &&
!user?.primaryEmail &&
!profile.primaryPhone &&
!profile.primaryEmail
) {
Expand All @@ -166,15 +168,15 @@

if (
mandatoryUserProfile.has(MissingProfile.email) &&
!user.primaryEmail &&
!user?.primaryEmail &&
!profile.primaryEmail
) {
missingProfile.add(MissingProfile.email);
}

if (
mandatoryUserProfile.has(MissingProfile.phone) &&
!user.primaryPhone &&
!user?.primaryPhone &&
!profile.primaryPhone
) {
missingProfile.add(MissingProfile.phone);
Expand Down
Loading
Loading