mirror of
https://github.com/logto-io/logto.git
synced 2025-03-24 22:41:28 -05:00
feat(core,schemas): implement profile fulfillment flow (#6293)
* feat(core,schemas): implement profile fulfillment flow implement profile fulfillment flow * fix(test): fix integration tests fix integration tests * fix(core): fix rebase issue fix rebase issue * refactor(core): refactor the interaction set profile flow refactor the interaction set profile flow
This commit is contained in:
parent
89b8662a15
commit
248ee7fb08
15 changed files with 562 additions and 58 deletions
|
@ -8,16 +8,21 @@ 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,
|
||||
identifyUserByVerificationRecord,
|
||||
} from './helpers.js';
|
||||
import { MfaValidator } from './libraries/mfa-validator.js';
|
||||
import { ProfileValidator } from './libraries/profile-validator.js';
|
||||
import { ProvisionLibrary } from './libraries/provision-library.js';
|
||||
import { SignInExperienceValidator } from './libraries/sign-in-experience-validator.js';
|
||||
import { Profile } from './profile.js';
|
||||
import { toUserSocialIdentityData } from './utils.js';
|
||||
import {
|
||||
buildVerificationRecord,
|
||||
|
@ -50,8 +55,8 @@ const interactionStorageGuard = z.object({
|
|||
*/
|
||||
export default class ExperienceInteraction {
|
||||
public readonly signInExperienceValidator: SignInExperienceValidator;
|
||||
public readonly profileValidator: ProfileValidator;
|
||||
public readonly provisionLibrary: ProvisionLibrary;
|
||||
readonly profile: Profile;
|
||||
|
||||
/** The user verification record list for the current interaction. */
|
||||
private readonly verificationRecords = new VerificationRecordsMap();
|
||||
|
@ -59,7 +64,6 @@ export default class ExperienceInteraction {
|
|||
private userId?: string;
|
||||
private userCache?: User;
|
||||
/** The user provided profile data in the current interaction that needs to be stored to database. */
|
||||
#profile?: InteractionProfile;
|
||||
/** The interaction event for the current interaction. */
|
||||
#interactionEvent?: InteractionEvent;
|
||||
|
||||
|
@ -79,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.profileValidator = new ProfileValidator(libraries, queries);
|
||||
this.profile = new Profile(libraries, queries, {}, interactionContext);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -92,14 +102,11 @@ export default class ExperienceInteraction {
|
|||
new RequestError({ code: 'session.interaction_not_found', status: 404 })
|
||||
);
|
||||
|
||||
const { verificationRecords = [], profile, userId, interactionEvent } = result.data;
|
||||
const { verificationRecords = [], profile = {}, userId, interactionEvent } = result.data;
|
||||
|
||||
this.#interactionEvent = interactionEvent;
|
||||
this.userId = userId;
|
||||
this.#profile = profile;
|
||||
|
||||
// Profile validator requires the userId for existing user profile update validation
|
||||
this.profileValidator = new ProfileValidator(libraries, queries);
|
||||
this.profile = new Profile(libraries, queries, profile, interactionContext);
|
||||
|
||||
for (const record of verificationRecords) {
|
||||
const instance = buildVerificationRecord(libraries, queries, record);
|
||||
|
@ -203,14 +210,15 @@ export default class ExperienceInteraction {
|
|||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @throws — RequestError with 404 if the if the user is not identified or not found
|
||||
* @throws {RequestError} with 403 if the mfa verification is required but not verified
|
||||
*/
|
||||
public async guardMfaVerificationStatus() {
|
||||
const user = await this.getIdentifiedUser();
|
||||
const mfaSettings = await this.signInExperienceValidator.getMfaSettings();
|
||||
const mfaValidator = new MfaValidator(mfaSettings, user);
|
||||
|
||||
const isVerified = mfaValidator.isMfaVerified(this.verificationRecordsArray);
|
||||
|
||||
assertThat(
|
||||
|
@ -242,7 +250,16 @@ export default class ExperienceInteraction {
|
|||
this.ctx.prependAllLogEntries({ interaction: interactionData });
|
||||
}
|
||||
|
||||
/** Submit the current interaction result to the OIDC provider and clear the interaction data */
|
||||
/**
|
||||
* Submit the current interaction result to the OIDC provider and clear the interaction data
|
||||
*
|
||||
* @throws {RequestError} with 404 if the interaction event is not set
|
||||
* @throws {RequestError} with 404 if the user is not identified
|
||||
* @throws {RequestError} with 403 if the mfa verification is required but not verified
|
||||
* @throws {RequestError} with 422 if the profile data is conflicting with the current user account
|
||||
* @throws {RequestError} with 422 if the profile data is not unique across users
|
||||
* @throws {RequestError} with 422 if the required profile fields are missing
|
||||
**/
|
||||
public async submit() {
|
||||
const {
|
||||
queries: { users: userQueries, userSsoIdentities: userSsoIdentitiesQueries },
|
||||
|
@ -257,14 +274,37 @@ export default class ExperienceInteraction {
|
|||
// Identified
|
||||
const user = await this.getIdentifiedUser();
|
||||
|
||||
// Verified
|
||||
if (this.#interactionEvent !== InteractionEvent.ForgotPassword) {
|
||||
await this.guardMfaVerificationStatus();
|
||||
// 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;
|
||||
|
||||
assertThat(
|
||||
passwordEncrypted && passwordEncryptionMethod,
|
||||
new RequestError({ code: 'user.new_password_required_in_profile', status: 422 })
|
||||
);
|
||||
|
||||
await userQueries.updateUserById(user.id, {
|
||||
passwordEncrypted,
|
||||
passwordEncryptionMethod,
|
||||
});
|
||||
|
||||
await this.cleanUp();
|
||||
|
||||
// TODO: User data updated hook
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.#profile ?? {};
|
||||
// Verified
|
||||
await this.guardMfaVerificationStatus();
|
||||
|
||||
// TODO: profile updates validation
|
||||
// Revalidate the new profile data if any
|
||||
await this.profile.validateAvailability();
|
||||
|
||||
// Profile fulfilled
|
||||
await this.profile.assertUserMandatoryProfileFulfilled();
|
||||
|
||||
const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.profile.data;
|
||||
|
||||
// Update user profile
|
||||
await userQueries.updateUserById(user.id, {
|
||||
|
@ -280,8 +320,6 @@ export default class ExperienceInteraction {
|
|||
lastSignInAt: Date.now(),
|
||||
});
|
||||
|
||||
// TODO: missing profile fields validation
|
||||
|
||||
if (enterpriseSsoIdentity) {
|
||||
await this.provisionLibrary.addSsoIdentityToUser(user.id, enterpriseSsoIdentity);
|
||||
}
|
||||
|
@ -292,6 +330,8 @@ export default class ExperienceInteraction {
|
|||
login: { accountId: user.id },
|
||||
});
|
||||
|
||||
// TODO: PostInteractionHooks
|
||||
|
||||
this.ctx.body = { redirectTo };
|
||||
}
|
||||
|
||||
|
@ -302,7 +342,7 @@ export default class ExperienceInteraction {
|
|||
return {
|
||||
interactionEvent,
|
||||
userId,
|
||||
profile: this.#profile,
|
||||
profile: this.profile.data,
|
||||
verificationRecords: this.verificationRecordsArray.map((record) => record.toJson()),
|
||||
};
|
||||
}
|
||||
|
@ -343,44 +383,36 @@ export default class ExperienceInteraction {
|
|||
return;
|
||||
}
|
||||
|
||||
// Sync social/enterprise SSO identity profile data
|
||||
if (syncedProfile) {
|
||||
this.setProfile(syncedProfile);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
this.profile.unsafeSet(syncedProfile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user using the verification record.
|
||||
*
|
||||
* @throws {RequestError} with 422 if the profile data is not unique across users
|
||||
* @throws {RequestError} with 400 if the verification record is invalid for creating a new user or not verified
|
||||
* @throws {RequestError} with 422 if the profile data is not unique across users
|
||||
*/
|
||||
private async createNewUser(verificationRecord: VerificationRecord) {
|
||||
const newProfile = await getNewUserProfileFromVerificationRecord(verificationRecord);
|
||||
await this.profileValidator.guardProfileUniquenessAcrossUsers(newProfile);
|
||||
await this.profile.profileValidator.guardProfileUniquenessAcrossUsers(newProfile);
|
||||
|
||||
const user = await this.provisionLibrary.createUser(newProfile);
|
||||
|
||||
this.userId = user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Private method to set the profile data without validation.
|
||||
*/
|
||||
private setProfile(profile: InteractionProfile) {
|
||||
this.#profile = {
|
||||
...this.#profile,
|
||||
...profile,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the interaction is identified and return the identified user.
|
||||
*
|
||||
* @throws RequestError with 400 if the user is not identified
|
||||
* @throws RequestError with 404 if the user is not found
|
||||
* @throws RequestError with 404 if the if the user is not identified or not found
|
||||
*/
|
||||
private async getIdentifiedUser(): Promise<User> {
|
||||
if (this.userCache) {
|
||||
|
@ -388,7 +420,13 @@ export default class ExperienceInteraction {
|
|||
}
|
||||
|
||||
// Identified
|
||||
assertThat(this.userId, 'session.identifier_not_found');
|
||||
assertThat(
|
||||
this.userId,
|
||||
new RequestError({
|
||||
code: 'session.identifier_not_found',
|
||||
status: 404,
|
||||
})
|
||||
);
|
||||
|
||||
const {
|
||||
queries: { users: userQueries },
|
||||
|
@ -403,4 +441,12 @@ export default class ExperienceInteraction {
|
|||
private getVerificationRecordById(verificationId: string) {
|
||||
return this.verificationRecordsArray.find((record) => record.id === verificationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up the interaction storage.
|
||||
*/
|
||||
private async cleanUp() {
|
||||
const { provider } = this.tenant;
|
||||
await provider.interactionResult(this.ctx.req, this.ctx.res, {});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { PasswordPolicyChecker, type UserInfo } from '@logto/core-kit';
|
||||
import { type SignInExperience, type User } from '@logto/schemas';
|
||||
import { UsersPasswordEncryptionMethod, type SignInExperience, type User } from '@logto/schemas';
|
||||
import { argon2Verify } from 'hash-wasm';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { encryptUserPassword } from '#src/libraries/user.utils.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import { type InteractionProfile } from '../../types.js';
|
||||
|
||||
|
@ -32,8 +34,10 @@ export class PasswordValidator {
|
|||
}
|
||||
|
||||
/**
|
||||
* Validate password against the password policy
|
||||
* @throws {RequestError} with status 422 if the password does not meet the policy
|
||||
* Validate password against the given password policy and current user's profile.
|
||||
*
|
||||
* @throws {RequestError} with status code 422 if the password is against the policy.
|
||||
* @throws {RequestError} with status code 422 if the password is the same as the current user's password.
|
||||
*/
|
||||
public async validatePassword(password: string, profile: InteractionProfile) {
|
||||
const userInfo = getUserInfo({
|
||||
|
@ -49,6 +53,18 @@ export class PasswordValidator {
|
|||
if (issues.length > 0) {
|
||||
throw new RequestError({ code: 'password.rejected', status: 422 }, { issues });
|
||||
}
|
||||
|
||||
if (this.user) {
|
||||
const { passwordEncrypted: oldPasswordEncrypted, passwordEncryptionMethod } = this.user;
|
||||
|
||||
assertThat(
|
||||
!oldPasswordEncrypted ||
|
||||
// If the password is not encrypted with Argon2i, allow to reset the same password with Argon2i
|
||||
passwordEncryptionMethod !== UsersPasswordEncryptionMethod.Argon2i ||
|
||||
!(await argon2Verify({ password, hash: oldPasswordEncrypted })),
|
||||
new RequestError({ code: 'user.same_password', status: 422 })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async createPasswordDigest(password: string) {
|
||||
|
|
|
@ -1,17 +1,15 @@
|
|||
import { type User, MissingProfile } 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 assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import type { InteractionProfile } from '../../types.js';
|
||||
|
||||
export class ProfileValidator {
|
||||
constructor(
|
||||
private readonly libraries: Libraries,
|
||||
private readonly queries: Queries
|
||||
) {}
|
||||
constructor(private readonly queries: Queries) {}
|
||||
|
||||
public async guardProfileUniquenessAcrossUsers(profile: InteractionProfile) {
|
||||
public async guardProfileUniquenessAcrossUsers(profile: InteractionProfile = {}) {
|
||||
const { hasUser, hasUserWithEmail, hasUserWithPhone, hasUserWithIdentity } = this.queries.users;
|
||||
const { userSsoIdentities } = this.queries;
|
||||
|
||||
|
@ -78,4 +76,110 @@ export class ProfileValidator {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the profile existence in the current user account
|
||||
*
|
||||
* @remarks
|
||||
* This method is used to validate the profile for the register and sign-in interaction only
|
||||
* In the register and sign-in interaction, password can only be set if it does not exist in the current user account.
|
||||
*
|
||||
*/
|
||||
public guardProfileNotExistInCurrentUserAccount(user: User, profile: InteractionProfile = {}) {
|
||||
const { username, primaryEmail, primaryPhone, passwordEncrypted } = profile;
|
||||
|
||||
if (username) {
|
||||
assertThat(
|
||||
!user.username,
|
||||
new RequestError({
|
||||
code: 'user.username_exists_in_profile',
|
||||
status: 422,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (primaryEmail) {
|
||||
assertThat(
|
||||
!user.primaryEmail,
|
||||
new RequestError({
|
||||
code: 'user.email_exists_in_profile',
|
||||
status: 422,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (primaryPhone) {
|
||||
assertThat(
|
||||
!user.primaryPhone,
|
||||
new RequestError({
|
||||
code: 'user.phone_exists_in_profile',
|
||||
status: 422,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (passwordEncrypted) {
|
||||
assertThat(
|
||||
!user.passwordEncrypted,
|
||||
new RequestError({
|
||||
code: 'user.password_exists_in_profile',
|
||||
status: 422,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line complexity
|
||||
public getMissingUserProfile(
|
||||
profile: InteractionProfile,
|
||||
user: User,
|
||||
mandatoryUserProfile: Set<MissingProfile>
|
||||
): 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 isProfilePasswordSet = Boolean(
|
||||
profile.passwordEncrypted ?? profile.socialIdentity ?? profile.enterpriseSsoIdentity
|
||||
);
|
||||
|
||||
if (!isUserPasswordSet && !isProfilePasswordSet) {
|
||||
missingProfile.add(MissingProfile.password);
|
||||
}
|
||||
}
|
||||
|
||||
if (mandatoryUserProfile.has(MissingProfile.username) && !user.username && !profile.username) {
|
||||
missingProfile.add(MissingProfile.username);
|
||||
}
|
||||
|
||||
if (
|
||||
mandatoryUserProfile.has(MissingProfile.emailOrPhone) &&
|
||||
!user.primaryPhone &&
|
||||
!user.primaryEmail &&
|
||||
!profile.primaryPhone &&
|
||||
!profile.primaryEmail
|
||||
) {
|
||||
missingProfile.add(MissingProfile.emailOrPhone);
|
||||
}
|
||||
|
||||
if (
|
||||
mandatoryUserProfile.has(MissingProfile.email) &&
|
||||
!user.primaryEmail &&
|
||||
!profile.primaryEmail
|
||||
) {
|
||||
missingProfile.add(MissingProfile.email);
|
||||
}
|
||||
|
||||
if (
|
||||
mandatoryUserProfile.has(MissingProfile.phone) &&
|
||||
!user.primaryPhone &&
|
||||
!profile.primaryPhone
|
||||
) {
|
||||
missingProfile.add(MissingProfile.phone);
|
||||
}
|
||||
|
||||
return missingProfile;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
InteractionEvent,
|
||||
MissingProfile,
|
||||
type SignInExperience,
|
||||
SignInIdentifier,
|
||||
SignInMode,
|
||||
|
@ -128,6 +129,39 @@ export class SignInExperienceValidator {
|
|||
return this.signInExperienceDataCache;
|
||||
}
|
||||
|
||||
public async getMandatoryUserProfileBySignUpMethods(): Promise<Set<MissingProfile>> {
|
||||
const {
|
||||
signUp: { identifiers, password },
|
||||
} = await this.getSignInExperienceData();
|
||||
const mandatoryUserProfile = new Set<MissingProfile>();
|
||||
|
||||
if (password) {
|
||||
mandatoryUserProfile.add(MissingProfile.password);
|
||||
}
|
||||
|
||||
if (identifiers.includes(SignInIdentifier.Username)) {
|
||||
mandatoryUserProfile.add(MissingProfile.username);
|
||||
}
|
||||
|
||||
if (
|
||||
identifiers.includes(SignInIdentifier.Email) &&
|
||||
identifiers.includes(SignInIdentifier.Phone)
|
||||
) {
|
||||
mandatoryUserProfile.add(MissingProfile.emailOrPhone);
|
||||
return mandatoryUserProfile;
|
||||
}
|
||||
|
||||
if (identifiers.includes(SignInIdentifier.Email)) {
|
||||
mandatoryUserProfile.add(MissingProfile.email);
|
||||
}
|
||||
|
||||
if (identifiers.includes(SignInIdentifier.Phone)) {
|
||||
mandatoryUserProfile.add(MissingProfile.phone);
|
||||
}
|
||||
|
||||
return mandatoryUserProfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard the verification records contains email identifier with SSO enabled
|
||||
*
|
||||
|
|
129
packages/core/src/routes/experience/classes/profile.ts
Normal file
129
packages/core/src/routes/experience/classes/profile.ts
Normal file
|
@ -0,0 +1,129 @@
|
|||
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 InteractionContext, type InteractionProfile } from '../types.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;
|
||||
private readonly signInExperienceValidator: SignInExperienceValidator;
|
||||
#data: InteractionProfile;
|
||||
|
||||
constructor(
|
||||
private readonly libraries: Libraries,
|
||||
queries: Queries,
|
||||
data: InteractionProfile,
|
||||
private readonly interactionContext: InteractionContext
|
||||
) {
|
||||
this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries);
|
||||
this.profileValidator = new ProfileValidator(queries);
|
||||
this.#data = data;
|
||||
}
|
||||
|
||||
get data() {
|
||||
return this.#data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.interactionContext.getIdentifierUser();
|
||||
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, profile);
|
||||
await this.profileValidator.guardProfileUniquenessAcrossUsers(profile);
|
||||
this.unsafeSet(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set password with password policy validation.
|
||||
*
|
||||
* @param reset - If true the password will be set without checking if it already exists in the current user account.
|
||||
* @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 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);
|
||||
const passwordDigests = await passwordValidator.createPasswordDigest(password);
|
||||
|
||||
if (!reset) {
|
||||
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, passwordDigests);
|
||||
}
|
||||
|
||||
this.unsafeSet(passwordDigests);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the profile data is valid.
|
||||
*
|
||||
* @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 validateAvailability() {
|
||||
const user = await this.interactionContext.getIdentifierUser();
|
||||
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, this.#data);
|
||||
await this.profileValidator.guardProfileUniquenessAcrossUsers(this.#data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user has fulfilled the mandatory profile fields.
|
||||
*/
|
||||
async assertUserMandatoryProfileFulfilled() {
|
||||
const user = await this.interactionContext.getIdentifierUser();
|
||||
const mandatoryProfileFields =
|
||||
await this.signInExperienceValidator.getMandatoryUserProfileBySignUpMethods();
|
||||
|
||||
const missingProfile = this.profileValidator.getMissingUserProfile(
|
||||
this.#data,
|
||||
user,
|
||||
mandatoryProfileFields
|
||||
);
|
||||
|
||||
if (missingProfile.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: find missing profile fields from the social identity if any
|
||||
|
||||
throw new RequestError(
|
||||
{ code: 'user.missing_profile', status: 422 },
|
||||
{ missingProfile: [...missingProfile] }
|
||||
);
|
||||
}
|
||||
|
||||
unsafeSet(profile: InteractionProfile) {
|
||||
this.#data = {
|
||||
...this.#data,
|
||||
...profile,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -5,6 +5,7 @@ import {
|
|||
VerificationType,
|
||||
type User,
|
||||
type VerificationCodeIdentifier,
|
||||
type VerificationCodeSignInIdentifier,
|
||||
} from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { z } from 'zod';
|
||||
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -81,7 +81,7 @@ export class NewPasswordIdentityVerification
|
|||
this.identifier = identifier;
|
||||
this.passwordEncrypted = passwordEncrypted;
|
||||
this.passwordEncryptionMethod = passwordEncryptionMethod;
|
||||
this.profileValidator = new ProfileValidator(libraries, queries);
|
||||
this.profileValidator = new ProfileValidator(queries);
|
||||
this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries);
|
||||
}
|
||||
|
||||
|
|
|
@ -4,4 +4,5 @@ export const experienceRoutes = Object.freeze({
|
|||
prefix,
|
||||
identification: `${prefix}/identification`,
|
||||
verification: `${prefix}/verification`,
|
||||
profile: `${prefix}/profile`,
|
||||
});
|
||||
|
|
|
@ -24,6 +24,7 @@ import { experienceRoutes } from './const.js';
|
|||
import koaExperienceInteraction, {
|
||||
type WithExperienceInteractionContext,
|
||||
} from './middleware/koa-experience-interaction.js';
|
||||
import profileRoutes from './profile-routes.js';
|
||||
import backupCodeVerificationRoutes from './verification-routes/backup-code-verification.js';
|
||||
import enterpriseSsoVerificationRoutes from './verification-routes/enterprise-sso-verification.js';
|
||||
import newPasswordIdentityVerificationRoutes from './verification-routes/new-password-identity-verification.js';
|
||||
|
@ -128,10 +129,12 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
|
|||
router.post(
|
||||
`${experienceRoutes.prefix}/submit`,
|
||||
koaGuard({
|
||||
status: [200, 400, 403, 422],
|
||||
response: z.object({
|
||||
redirectTo: z.string(),
|
||||
}),
|
||||
status: [200, 400, 403, 404, 422],
|
||||
response: z
|
||||
.object({
|
||||
redirectTo: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
await ctx.experienceInteraction.submit();
|
||||
|
@ -147,4 +150,6 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
|
|||
totpVerificationRoutes(router, tenant);
|
||||
backupCodeVerificationRoutes(router, tenant);
|
||||
newPasswordIdentityVerificationRoutes(router, tenant);
|
||||
|
||||
profileRoutes(router, tenant);
|
||||
}
|
||||
|
|
108
packages/core/src/routes/experience/profile-routes.ts
Normal file
108
packages/core/src/routes/experience/profile-routes.ts
Normal file
|
@ -0,0 +1,108 @@
|
|||
import { InteractionEvent, SignInIdentifier, updateProfileApiPayloadGuard } from '@logto/schemas';
|
||||
import type Router from 'koa-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||
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.post(
|
||||
`${experienceRoutes.profile}`,
|
||||
koaGuard({
|
||||
body: updateProfileApiPayloadGuard,
|
||||
status: [204, 400, 403, 404, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { experienceInteraction, guard } = ctx;
|
||||
|
||||
// Guard current interaction event is not ForgotPassword
|
||||
assertThat(
|
||||
experienceInteraction.interactionEvent !== InteractionEvent.ForgotPassword,
|
||||
new RequestError({
|
||||
code: 'session.not_supported_for_forgot_password',
|
||||
statue: 400,
|
||||
})
|
||||
);
|
||||
|
||||
// Guard MFA verification status
|
||||
await experienceInteraction.guardMfaVerificationStatus();
|
||||
|
||||
const profilePayload = guard.body;
|
||||
|
||||
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();
|
||||
}
|
||||
);
|
||||
|
||||
router.put(
|
||||
`${experienceRoutes.profile}/password`,
|
||||
koaGuard({
|
||||
body: z.object({
|
||||
password: z.string(),
|
||||
}),
|
||||
status: [204, 400, 404, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { experienceInteraction, guard } = ctx;
|
||||
const { password } = guard.body;
|
||||
|
||||
assertThat(
|
||||
experienceInteraction.interactionEvent === InteractionEvent.ForgotPassword,
|
||||
new RequestError({
|
||||
code: 'session.invalid_interaction_type',
|
||||
status: 400,
|
||||
})
|
||||
);
|
||||
|
||||
// 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;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -1,8 +1,16 @@
|
|||
import { type SocialUserInfo, socialUserInfoGuard, type ToZodObject } from '@logto/connector-kit';
|
||||
import { type CreateUser, Users, UserSsoIdentities, type UserSsoIdentity } from '@logto/schemas';
|
||||
import {
|
||||
type CreateUser,
|
||||
type User,
|
||||
Users,
|
||||
UserSsoIdentities,
|
||||
type UserSsoIdentity,
|
||||
} from '@logto/schemas';
|
||||
import type Provider from 'oidc-provider';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type VerificationRecordMap } from './classes/verifications/index.js';
|
||||
|
||||
export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
|
||||
|
||||
export type InteractionProfile = {
|
||||
|
@ -51,3 +59,14 @@ export const interactionProfileGuard = Users.createGuard
|
|||
})
|
||||
.optional(),
|
||||
}) satisfies ToZodObject<InteractionProfile>;
|
||||
|
||||
/**
|
||||
* The interaction context provides the callback functions to get the user and verification record from the interaction
|
||||
*/
|
||||
export type InteractionContext = {
|
||||
getIdentifierUser: () => Promise<User>;
|
||||
getVerificationRecordByTypeAndId: <K extends keyof VerificationRecordMap>(
|
||||
type: K,
|
||||
verificationId: string
|
||||
) => VerificationRecordMap[K];
|
||||
};
|
||||
|
|
|
@ -15,7 +15,10 @@ devFeatureTest.describe('enterprise sso sign-in and sign-up', () => {
|
|||
|
||||
beforeAll(async () => {
|
||||
await ssoConnectorApi.createMockOidcConnector([domain]);
|
||||
await updateSignInExperience({ singleSignOnEnabled: true });
|
||||
await updateSignInExperience({
|
||||
singleSignOnEnabled: true,
|
||||
signUp: { identifiers: [], password: false, verify: false },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
|
@ -6,6 +6,7 @@ import {
|
|||
mockSocialConnectorTarget,
|
||||
} from '#src/__mocks__/connectors-mock.js';
|
||||
import { deleteUser, getUser } from '#src/api/admin-user.js';
|
||||
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
|
||||
import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js';
|
||||
import { signInWithSocial } from '#src/helpers/experience/index.js';
|
||||
import { generateNewUser } from '#src/helpers/user.js';
|
||||
|
@ -18,6 +19,13 @@ devFeatureTest.describe('social sign-in and sign-up', () => {
|
|||
|
||||
beforeAll(async () => {
|
||||
await clearConnectorsByTypes([ConnectorType.Social]);
|
||||
await updateSignInExperience({
|
||||
signUp: {
|
||||
identifiers: [],
|
||||
password: false,
|
||||
verify: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { id: socialConnectorId } = await setSocialConnector();
|
||||
connectorIdMap.set(mockSocialConnectorId, socialConnectorId);
|
||||
|
|
|
@ -22,6 +22,8 @@ const session = {
|
|||
identifier_not_found: 'User identifier not found. Please go back and sign in again.',
|
||||
interaction_not_found:
|
||||
'Interaction session not found. Please go back and start the session again.',
|
||||
invalid_interaction_type:
|
||||
'This operation is not supported for the current interaction. Please initiate a new session.',
|
||||
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
|
||||
identity_conflict:
|
||||
'Identity mismatch detected. Please initiate a new session to proceed with a different identity.',
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
@ -163,6 +164,27 @@ export const CreateExperienceApiPayloadGuard = z.object({
|
|||
interactionEvent: z.nativeEnum(InteractionEvent),
|
||||
}) satisfies ToZodObject<CreateExperienceApiPayload>;
|
||||
|
||||
/** Payload type for `POST /api/experience/profile */
|
||||
export const updateProfileApiPayloadGuard = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(SignInIdentifier.Username),
|
||||
value: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('password'),
|
||||
value: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(SignInIdentifier.Email),
|
||||
verificationId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(SignInIdentifier.Phone),
|
||||
verificationId: z.string(),
|
||||
}),
|
||||
]);
|
||||
export type UpdateProfileApiPayload = z.infer<typeof updateProfileApiPayloadGuard>;
|
||||
|
||||
// ====== Experience API payload guard and types definitions end ======
|
||||
|
||||
/**
|
||||
|
@ -410,3 +432,4 @@ export const verifyMfaResultGuard = z.object({
|
|||
});
|
||||
|
||||
export type VerifyMfaResult = z.infer<typeof verifyMfaResultGuard>;
|
||||
/* eslint-enable max-lines */
|
||||
|
|
Loading…
Add table
Reference in a new issue