0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

refactor(core,schemas): refactor the registration flow

refactor the registraction flow
This commit is contained in:
simeng-li 2024-08-06 11:08:24 +08:00
parent d74fb15aab
commit 7fb00bca7a
No known key found for this signature in database
GPG key ID: 14EA7BB1541E8075
19 changed files with 582 additions and 497 deletions

View file

@ -105,7 +105,7 @@ describe('ExperienceInteraction class', () => {
);
experienceInteraction.setVerificationRecord(emailVerificationRecord);
await experienceInteraction.identifyUser(emailVerificationRecord.id);
await experienceInteraction.createUser(emailVerificationRecord.id);
expect(userLibraries.insertUser).toHaveBeenCalledWith(
{

View file

@ -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';
@ -34,7 +28,6 @@ import { SignInExperienceValidator } from './libraries/sign-in-experience-valida
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,
@ -105,6 +98,7 @@ export default class ExperienceInteraction {
this.provisionLibrary = new ProvisionLibrary(tenant, ctx);
const interactionContext: InteractionContext = {
getInteractionEvent: () => this.#interactionEvent,
getIdentifiedUser: async () => this.getIdentifiedUser(),
getVerificationRecordByTypeAndId: (type, verificationId) =>
this.getVerificationRecordByTypeAndId(type, verificationId),
@ -153,7 +147,9 @@ export default class ExperienceInteraction {
}
/**
* 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`
@ -170,6 +166,10 @@ export default class ExperienceInteraction {
new RequestError({ code: 'session.not_supported_for_forgot_password', status: 400 })
);
if (this.#interactionEvent !== interactionEvent) {
this.profile.cleanUp();
}
this.#interactionEvent = interactionEvent;
}
@ -178,44 +178,117 @@ export default class ExperienceInteraction {
*
* - 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(
this.interactionEvent !== InteractionEvent.Register,
new RequestError({ code: 'session.invalid_interaction_type', status: 400 })
);
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(
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 })
);
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);
}
}
/**
* 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) {
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();
}
/**
@ -425,80 +498,6 @@ export default class ExperienceInteraction {
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
@ -531,20 +530,6 @@ export default class ExperienceInteraction {
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);

View file

@ -132,15 +132,17 @@ export class ProfileValidator {
// eslint-disable-next-line complexity
public getMissingUserProfile(
profile: InteractionProfile,
user: User,
mandatoryUserProfile: Set<MissingProfile>
mandatoryUserProfile: Set<MissingProfile>,
user?: User
): 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;
const isProfilePasswordSet = Boolean(
profile.passwordEncrypted ?? profile.socialIdentity ?? profile.enterpriseSsoIdentity
);
@ -150,14 +152,14 @@ export class ProfileValidator {
}
}
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
) {
@ -166,7 +168,7 @@ export class ProfileValidator {
if (
mandatoryUserProfile.has(MissingProfile.email) &&
!user.primaryEmail &&
!user?.primaryEmail &&
!profile.primaryEmail
) {
missingProfile.add(MissingProfile.email);
@ -174,7 +176,7 @@ export class ProfileValidator {
if (
mandatoryUserProfile.has(MissingProfile.phone) &&
!user.primaryPhone &&
!user?.primaryPhone &&
!profile.primaryPhone
) {
missingProfile.add(MissingProfile.phone);

View file

@ -1,5 +1,3 @@
/* eslint-disable max-lines */
import { type LogtoErrorCode } from '@logto/phrases';
import {
InteractionEvent,
type SignInExperience,
@ -318,13 +316,13 @@ describe('SignInExperienceValidator', () => {
await (accepted
? expect(
signInExperienceSettings.verifyIdentificationMethod(
signInExperienceSettings.guardIdentificationMethod(
InteractionEvent.SignIn,
verificationRecord
)
).resolves.not.toThrow()
: expect(
signInExperienceSettings.verifyIdentificationMethod(
signInExperienceSettings.guardIdentificationMethod(
InteractionEvent.SignIn,
verificationRecord
)
@ -335,162 +333,6 @@ describe('SignInExperienceValidator', () => {
});
});
describe('verifyIdentificationMethod (Register)', () => {
const registerVerificationTestCases: Record<
string,
{
signInExperience: SignInExperience;
cases: Array<{
verificationRecord: VerificationRecord;
accepted: boolean;
errorCode?: LogtoErrorCode;
}>;
}
> = Object.freeze({
'only username is enabled for sign-up': {
signInExperience: mockSignInExperience,
cases: [
{
verificationRecord: newPasswordIdentityVerificationRecord,
accepted: true,
},
{
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email],
accepted: false,
},
{
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone],
accepted: false,
},
],
},
'email is enabled for sign-up': {
signInExperience: {
...mockSignInExperience,
signUp: {
identifiers: [SignInIdentifier.Email],
password: false,
verify: true,
},
},
cases: [
{
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email],
accepted: true,
},
{
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone],
accepted: false,
},
{
verificationRecord: emailNewPasswordIdentityVerificationRecord,
accepted: false,
},
],
},
'email and phone are enabled for sign-up': {
signInExperience: {
...mockSignInExperience,
signUp: {
identifiers: [SignInIdentifier.Email, SignInIdentifier.Phone],
password: false,
verify: true,
},
},
cases: [
{
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email],
accepted: true,
},
{
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone],
accepted: true,
},
],
},
'email are enabled for sign-up but password is required': {
signInExperience: {
...mockSignInExperience,
signUp: {
identifiers: [SignInIdentifier.Email],
password: true,
verify: true,
},
},
cases: [
{
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email],
accepted: false,
errorCode: 'user.password_required_in_profile',
},
{
verificationRecord: emailNewPasswordIdentityVerificationRecord,
accepted: true,
},
],
},
'enterprise sso enabled': {
signInExperience: {
...mockSignInExperience,
singleSignOnEnabled: true,
},
cases: [
{
verificationRecord: enterpriseSsoVerificationRecords,
accepted: true,
},
],
},
'enterprise sso disabled': {
signInExperience: {
...mockSignInExperience,
singleSignOnEnabled: false,
},
cases: [
{
verificationRecord: enterpriseSsoVerificationRecords,
accepted: false,
},
],
},
});
describe.each(Object.keys(registerVerificationTestCases))(`%s`, (testCase) => {
const { signInExperience, cases } = registerVerificationTestCases[testCase]!;
it.each(cases)(
'guard verification record %p',
async ({ verificationRecord, accepted, errorCode }) => {
signInExperiences.findDefaultSignInExperience.mockResolvedValueOnce(signInExperience);
const signInExperienceSettings = new SignInExperienceValidator(
mockTenant.libraries,
mockTenant.queries
);
await (accepted
? expect(
signInExperienceSettings.verifyIdentificationMethod(
InteractionEvent.Register,
verificationRecord
)
).resolves.not.toThrow()
: expect(
signInExperienceSettings.verifyIdentificationMethod(
InteractionEvent.Register,
verificationRecord
)
).rejects.toMatchError(
new RequestError({
code: errorCode ?? 'user.sign_up_method_not_enabled',
status: 422,
})
));
}
);
});
});
describe('guardSsoOnlyEmailIdentifier: identifier with SSO enabled domain should throw', () => {
const mockSsoConnector = {
domains: [emailDomain],
@ -515,7 +357,7 @@ describe('SignInExperienceValidator', () => {
);
await expect(
signInExperienceSettings.verifyIdentificationMethod(
signInExperienceSettings.guardIdentificationMethod(
InteractionEvent.SignIn,
passwordVerificationRecords[SignInIdentifier.Email]
)
@ -531,7 +373,7 @@ describe('SignInExperienceValidator', () => {
);
await expect(
signInExperienceSettings.verifyIdentificationMethod(
signInExperienceSettings.guardIdentificationMethod(
InteractionEvent.SignIn,
verificationCodeVerificationRecords[SignInIdentifier.Email]
)
@ -547,7 +389,7 @@ describe('SignInExperienceValidator', () => {
);
await expect(
signInExperienceSettings.verifyIdentificationMethod(
signInExperienceSettings.guardIdentificationMethod(
InteractionEvent.SignIn,
socialVerificationRecord
)
@ -555,4 +397,3 @@ describe('SignInExperienceValidator', () => {
});
});
});
/* eslint-enable max-lines */

View file

@ -50,6 +50,9 @@ export class SignInExperienceValidator {
private readonly queries: Queries
) {}
/**
* @throws {RequestError} with status 403 if the interaction event is not allowed
*/
public async guardInteractionEvent(event: InteractionEvent) {
const { signInMode } = await this.getSignInExperienceData();
@ -74,26 +77,22 @@ export class SignInExperienceValidator {
}
}
public async verifyIdentificationMethod(
event: InteractionEvent,
public async guardIdentificationMethod(
event: InteractionEvent.ForgotPassword | InteractionEvent.SignIn,
verificationRecord: VerificationRecord
) {
await this.guardInteractionEvent(event);
switch (event) {
case InteractionEvent.SignIn: {
await this.guardSignInVerificationMethod(verificationRecord);
break;
}
case InteractionEvent.Register: {
await this.guardRegisterVerificationMethod(verificationRecord);
break;
}
case InteractionEvent.ForgotPassword: {
this.guardForgotPasswordVerificationMethod(verificationRecord);
break;
}
}
await this.guardSsoOnlyEmailIdentifier(verificationRecord);
}
public async getEnabledSsoConnectorsByEmail(email: string) {
@ -169,8 +168,7 @@ export class SignInExperienceValidator {
* Email identifier with SSO enabled domain will be blocked.
* Can only verify/identify via SSO verification record.
*
* - VerificationCode with email identifier
* - Social userinfo with email
* @throws {RequestError} with status 422 if the email identifier is SSO enabled
**/
private async guardSsoOnlyEmailIdentifier(verificationRecord: VerificationRecord) {
const emailIdentifier = getEmailIdentifierFromVerificationRecord(verificationRecord);
@ -195,6 +193,10 @@ export class SignInExperienceValidator {
);
}
/**
* @throws {RequestError} with status 422 if the verification record type is not enabled
* @throws {RequestError} with status 422 if the email identifier is SSO enabled
*/
private async guardSignInVerificationMethod(verificationRecord: VerificationRecord) {
const {
signIn: { methods: signInMethods },
@ -236,56 +238,8 @@ export class SignInExperienceValidator {
throw new RequestError({ code: 'user.sign_in_method_not_enabled', status: 422 });
}
}
}
private async guardRegisterVerificationMethod(verificationRecord: VerificationRecord) {
const { signUp, singleSignOnEnabled } = await this.getSignInExperienceData();
switch (verificationRecord.type) {
// Username and password registration
case VerificationType.NewPasswordIdentity: {
const {
identifier: { type },
} = verificationRecord;
assertThat(
signUp.identifiers.includes(type) && signUp.password,
new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 })
);
break;
}
case VerificationType.EmailVerificationCode:
case VerificationType.PhoneVerificationCode: {
const {
identifier: { type },
} = verificationRecord;
assertThat(
signUp.identifiers.includes(type) && signUp.verify,
new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 })
);
assertThat(
!signUp.password,
new RequestError({ code: 'user.password_required_in_profile', status: 422 })
);
break;
}
case VerificationType.Social: {
// No need to verify social verification method
break;
}
case VerificationType.EnterpriseSso: {
assertThat(
singleSignOnEnabled,
new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 })
);
break;
}
default: {
throw new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 });
}
}
await this.guardSsoOnlyEmailIdentifier(verificationRecord);
}
/** Forgot password only supports verification code type verification record */

View file

@ -1,4 +1,5 @@
import { type VerificationType } from '@logto/schemas';
import { InteractionEvent, VerificationType } from '@logto/schemas';
import { trySafe } from '@silverhand/essentials';
import RequestError from '#src/errors/RequestError/index.js';
import { type LogEntry } from '#src/middleware/koa-audit-log.js';
@ -52,18 +53,44 @@ export class Profile {
});
const profile = verificationRecord.toUserProfile();
await this.setProfileWithValidation(profile);
}
async setProfileBySocialVerificationRecord(verificationId: string, log?: LogEntry) {
const verificationRecord = this.interactionContext.getVerificationRecordByTypeAndId(
VerificationType.Social,
verificationId
);
log?.append({
verification: verificationRecord.toJson(),
});
const profile = await verificationRecord.toUserProfile();
await this.setProfileWithValidation(profile);
const user = await this.safeGetIdentifiedUser();
const isNewUserIdentity = !user;
// Sync the email and phone to the user profile only for new user identity
const syncedProfile = await verificationRecord.toSyncedProfile(isNewUserIdentity);
this.unsafePrepend(syncedProfile);
}
/**
* 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 profile data already exists in the current user account. (Existing user profile only)
* @throws {RequestError} 422 if the unique identifier data already exists in another user account.
*/
async setProfileWithValidation(profile: InteractionProfile) {
const user = await this.interactionContext.getIdentifiedUser();
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, profile);
const user = await this.safeGetIdentifiedUser();
if (user) {
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, profile);
}
await this.profileValidator.guardProfileUniquenessAcrossUsers(profile);
this.unsafeSet(profile);
}
@ -73,16 +100,16 @@ export class Profile {
*
* @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.
* @throws {RequestError} 422 if the password is the same as the current user's password. (Existing user profile only)
*/
async setPasswordDigestWithValidation(password: string, reset = false) {
const user = await this.interactionContext.getIdentifiedUser();
const user = await this.safeGetIdentifiedUser();
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) {
if (user && !reset) {
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, passwordDigests);
}
@ -92,35 +119,44 @@ export class Profile {
/**
* 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 profile data already exists in the current user account. (Existing user profile only)
* @throws {RequestError} 422 if the unique identifier data already exists in another user account.
*/
async validateAvailability() {
const user = await this.interactionContext.getIdentifiedUser();
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, this.#data);
const user = await this.safeGetIdentifiedUser();
if (user) {
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, this.#data);
}
await this.profileValidator.guardProfileUniquenessAcrossUsers(this.#data);
}
/**
* Checks if the user has fulfilled the mandatory profile fields.
*
* - Skip the check if the profile contains an enterprise SSO identity.
*/
async assertUserMandatoryProfileFulfilled() {
const user = await this.interactionContext.getIdentifiedUser();
const user = await this.safeGetIdentifiedUser();
if (this.#data.enterpriseSsoIdentity) {
return;
}
const mandatoryProfileFields =
await this.signInExperienceValidator.getMandatoryUserProfileBySignUpMethods();
const missingProfile = this.profileValidator.getMissingUserProfile(
this.#data,
user,
mandatoryProfileFields
mandatoryProfileFields,
user
);
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] }
@ -133,4 +169,41 @@ export class Profile {
...profile,
};
}
/**
* Prepend the profile data to the existing profile data.
* Avoid overwriting the existing profile data.
*/
unsafePrepend(profile: InteractionProfile) {
this.#data = {
...profile,
...this.#data,
};
}
cleanUp() {
this.#data = {};
}
get notEmpty() {
return Object.keys(this.#data).length > 0;
}
/**
* Safely get the identified user from the interaction context.
* If the interaction event is register, the user will be retrieved safely.
*
* @returns The identified user from the interaction context.
*/
private async safeGetIdentifiedUser() {
const { getInteractionEvent, getIdentifiedUser } = this.interactionContext;
const interactionEvent = getInteractionEvent();
if (interactionEvent === InteractionEvent.Register) {
return trySafe(async () => getIdentifiedUser());
}
return getIdentifiedUser();
}
}

View file

@ -98,7 +98,7 @@ export class NewPasswordIdentityVerification
* @throws {RequestError} with status 422 if the password is not provided
* @throws {RequestError} with status 422 if the password does not meet the password policy
*/
async verify(password?: string) {
async verify(password: string) {
const { identifier } = this;
const identifierProfile = interactionIdentifierToUserProfile(identifier);
await this.profileValidator.guardProfileUniquenessAcrossUsers(identifierProfile);

View file

@ -14,8 +14,10 @@ import { identificationApiPayloadGuard, InteractionEvent } from '@logto/schemas'
import type Router from 'koa-router';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaInteractionDetails from '#src/middleware/koa-interaction-details.js';
import assertThat from '#src/utils/assert-that.js';
import { type AnonymousRouter, type RouterInitArgs } from '../types.js';
@ -109,7 +111,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
experienceRoutes.identification,
koaGuard({
body: identificationApiPayloadGuard,
status: [201, 204, 400, 401, 404, 409, 422],
status: [201, 204, 400, 401, 403, 404, 409, 422],
}),
async (ctx, next) => {
const { verificationId, linkSocialIdentity } = ctx.guard.body;
@ -126,7 +128,19 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
},
});
await experienceInteraction.identifyUser(verificationId, linkSocialIdentity, log);
if (experienceInteraction.interactionEvent === InteractionEvent.Register) {
await experienceInteraction.createUser(verificationId, log);
} else {
assertThat(
verificationId,
new RequestError({
code: 'guard.invalid_input',
status: 400,
details: 'verificationId is missing',
})
);
await experienceInteraction.identifyUser(verificationId, linkSocialIdentity, log);
}
await experienceInteraction.save();

View file

@ -57,12 +57,26 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
body: updateProfileApiPayloadGuard,
status: [204, 400, 403, 404, 422],
}),
verifiedInteractionGuard(),
async (ctx, next) => {
const { experienceInteraction, guard, createLog } = ctx;
const profilePayload = guard.body;
const { interactionEvent } = experienceInteraction;
const log = createLog(`Interaction.${experienceInteraction.interactionEvent}.Profile.Update`);
const log = createLog(`Interaction.${interactionEvent}.Profile.Update`);
// Guard current interaction event is not ForgotPassword
assertThat(
interactionEvent !== InteractionEvent.ForgotPassword,
new RequestError({
code: 'session.not_supported_for_forgot_password',
statue: 400,
})
);
// Guard MFA verification status for SignIn interaction only
if (interactionEvent === InteractionEvent.SignIn) {
await experienceInteraction.guardMfaVerificationStatus();
}
log.append({
payload: profilePayload,
@ -74,7 +88,8 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
const verificationType = identifierCodeVerificationTypeMap[profilePayload.type];
await experienceInteraction.profile.setProfileByVerificationRecord(
verificationType,
profilePayload.verificationId
profilePayload.verificationId,
log
);
break;
}
@ -86,6 +101,13 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
}
case 'password': {
await experienceInteraction.profile.setPasswordDigestWithValidation(profilePayload.value);
break;
}
case 'social': {
await experienceInteraction.profile.setProfileBySocialVerificationRecord(
profilePayload.verificationId,
log
);
}
}
@ -142,7 +164,7 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
koaGuard({ status: [204, 400, 403, 404, 422] }),
verifiedInteractionGuard(),
async (ctx, next) => {
const { experienceInteraction, guard } = ctx;
const { experienceInteraction } = ctx;
await experienceInteraction.mfa.skip();
await experienceInteraction.save();

View file

@ -1,6 +1,7 @@
import { type SocialUserInfo, socialUserInfoGuard, type ToZodObject } from '@logto/connector-kit';
import {
type CreateUser,
type InteractionEvent,
type User,
Users,
UserSsoIdentities,
@ -69,6 +70,7 @@ export const interactionProfileGuard = Users.createGuard
* The interaction context provides the callback functions to get the user and verification record from the interaction
*/
export type InteractionContext = {
getInteractionEvent: () => InteractionEvent;
getIdentifiedUser: () => Promise<User>;
getVerificationRecordByTypeAndId: <K extends keyof VerificationRecordMap>(
type: K,

View file

@ -1,4 +1,5 @@
import { interactionIdentifierGuard, VerificationType } from '@logto/schemas';
import { usernameRegEx } from '@logto/core-kit';
import { SignInIdentifier, VerificationType } from '@logto/schemas';
import { Action } from '@logto/schemas/lib/types/log/interaction.js';
import type Router from 'koa-router';
import { z } from 'zod';
@ -18,8 +19,12 @@ export default function newPasswordIdentityVerificationRoutes<
`${experienceRoutes.verification}/new-password-identity`,
koaGuard({
body: z.object({
identifier: interactionIdentifierGuard,
password: z.string().optional(),
identifier: z.object({
// Only username is supported for now
type: z.literal(SignInIdentifier.Username),
value: z.string().regex(usernameRegEx),
}),
password: z.string(),
}),
status: [200, 400, 422],
response: z.object({

View file

@ -27,7 +27,7 @@ export const identifyUser = async (cookie: string, payload: IdentificationApiPay
.json();
export class ExperienceClient extends MockClient {
public async identifyUser(payload: IdentificationApiPayload) {
public async identifyUser(payload: IdentificationApiPayload = {}) {
return api.post(experienceRoutes.identification, {
headers: { cookie: this.interactionCookie },
json: payload,

View file

@ -117,19 +117,15 @@ export const registerNewUserWithVerificationCode = async (
if (options?.fulfillPassword) {
await expectRejects(client.identifyUser({ verificationId }), {
code: 'user.password_required_in_profile',
code: 'user.missing_profile',
status: 422,
});
const password = generatePassword();
const { verificationId: newPasswordIdentityVerificationId } =
await client.createNewPasswordIdentityVerification({
identifier,
password,
});
await client.updateProfile({ type: 'password', value: password });
await client.identifyUser({ verificationId: newPasswordIdentityVerificationId });
await client.identifyUser();
} else {
await client.identifyUser({ verificationId });
}

View file

@ -1,16 +1,18 @@
import { SignInIdentifier } from '@logto/schemas';
import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
import { deleteUser } from '#src/api/admin-user.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { initExperienceClient, logoutClient, processSession } from '#src/helpers/client.js';
import {
registerNewUserUsernamePassword,
signInWithPassword,
} from '#src/helpers/experience/index.js';
import { generateNewUserProfile } from '#src/helpers/user.js';
import { devFeatureTest } from '#src/utils.js';
import { expectRejects } from '#src/helpers/index.js';
import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js';
import { devFeatureTest, generateUsername } from '#src/utils.js';
devFeatureTest.describe('register new user with username and password', () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const userApi = new UserApiTest();
beforeAll(async () => {
// Disable password policy here to make sure the test is not affected by the password policy.
@ -24,7 +26,12 @@ devFeatureTest.describe('register new user with username and password', () => {
});
});
afterAll(async () => {
await userApi.cleanUp();
});
it('should register new user with username and password and able to sign-in using the same credentials', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const userId = await registerNewUserUsernamePassword(username, password);
await signInWithPassword({
@ -37,4 +44,42 @@ devFeatureTest.describe('register new user with username and password', () => {
await deleteUser(userId);
});
it('should register new user with username and password step by step and able to sign-in using the same credentials', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const existUsername = generateUsername();
await userApi.create({ username: existUsername });
const client = await initExperienceClient(InteractionEvent.Register);
await expectRejects(
client.updateProfile({ type: SignInIdentifier.Username, value: existUsername }),
{
status: 422,
code: 'user.username_already_in_use',
}
);
await client.updateProfile({ type: SignInIdentifier.Username, value: username });
await expectRejects(client.identifyUser(), {
status: 422,
code: 'user.missing_profile',
});
await client.updateProfile({ type: 'password', value: password });
await client.identifyUser();
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);
});
});

View file

@ -15,7 +15,7 @@ import {
import { expectRejects } from '#src/helpers/index.js';
import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-experience.js';
import { generateNewUser } from '#src/helpers/user.js';
import { devFeatureTest, generateEmail, generatePassword, generatePhone } from '#src/utils.js';
import { devFeatureTest, generateEmail, generatePhone } from '#src/utils.js';
const verificationIdentifierType: readonly [SignInIdentifier.Email, SignInIdentifier.Phone] =
Object.freeze([SignInIdentifier.Email, SignInIdentifier.Phone]);
@ -119,50 +119,6 @@ devFeatureTest.describe('Register interaction with verification code happy path'
});
});
it.each(verificationIdentifierType)(
'Should throw identifier not verified error when trying to fulfill password without verifying %p identifier',
async (identifier) => {
const client = await initExperienceClient(InteractionEvent.Register);
const interactionIdentifier = {
type: identifier,
value: identifier === SignInIdentifier.Email ? generateEmail() : generatePhone(),
};
const { verificationId } = await client.createNewPasswordIdentityVerification({
identifier: interactionIdentifier,
password: generatePassword(),
});
await expectRejects(client.identifyUser({ verificationId }), {
code: 'session.identifier_not_verified',
status: 422,
});
const { verificationId: codeVerificationId, code } = await successfullySendVerificationCode(
client,
{
identifier: interactionIdentifier,
interactionEvent: InteractionEvent.Register,
}
);
await successfullyVerifyVerificationCode(client, {
identifier: interactionIdentifier,
verificationId: codeVerificationId,
code,
});
await client.identifyUser({ verificationId });
const { redirectTo } = await client.submitInteraction();
const userId = await processSession(client, redirectTo);
await logoutClient(client);
await deleteUser(userId);
}
);
it.each(verificationIdentifierType)(
'Should fail to sign-up with existing %p identifier and directly sign-in instead',
async (identifierType) => {
@ -193,16 +149,6 @@ devFeatureTest.describe('Register interaction with verification code happy path'
client.identifyUser({
verificationId,
}),
{
code: `user.password_required_in_profile`,
status: 422,
}
);
await expectRejects(
client.createNewPasswordIdentityVerification({
identifier,
}),
{
code: `user.${identifierType}_already_in_use`,
status: 422,

View file

@ -1,19 +1,30 @@
import { MfaFactor, SignInIdentifier } from '@logto/schemas';
import { InteractionEvent, MfaFactor, SignInIdentifier } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { createUserMfaVerification, deleteUser, getUser } from '#src/api/admin-user.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { SsoConnectorApi } from '#src/api/sso-connector.js';
import { initExperienceClient } from '#src/helpers/client.js';
import { setEmailConnector, setSmsConnector } from '#src/helpers/connector.js';
import { signInWithEnterpriseSso } from '#src/helpers/experience/index.js';
import { enableMandatoryMfaWithTotp } from '#src/helpers/sign-in-experience.js';
import { generateNewUser } from '#src/helpers/user.js';
import { devFeatureTest, generateEmail } from '#src/utils.js';
import {
successfullySendVerificationCode,
successfullyVerifyVerificationCode,
} from '#src/helpers/experience/verification-code.js';
import { expectRejects } from '#src/helpers/index.js';
import {
enableAllVerificationCodeSignInMethods,
enableMandatoryMfaWithTotp,
} from '#src/helpers/sign-in-experience.js';
import { generateNewUser, UserApiTest } from '#src/helpers/user.js';
import { devFeatureTest, generateEmail, generatePassword } from '#src/utils.js';
devFeatureTest.describe('enterprise sso sign-in and sign-up', () => {
const ssoConnectorApi = new SsoConnectorApi();
const domain = 'foo.com';
const enterpriseSsoIdentityId = generateStandardId();
const email = generateEmail(domain);
const userApi = new UserApiTest();
beforeAll(async () => {
await ssoConnectorApi.createMockOidcConnector([domain]);
@ -24,7 +35,7 @@ devFeatureTest.describe('enterprise sso sign-in and sign-up', () => {
});
afterAll(async () => {
await ssoConnectorApi.cleanUp();
await Promise.all([ssoConnectorApi.cleanUp(), userApi.cleanUp()]);
});
it('should successfully sign-up with enterprise sso and sync email', async () => {
@ -121,4 +132,60 @@ devFeatureTest.describe('enterprise sso sign-in and sign-up', () => {
await deleteUser(userId);
});
});
describe('should block email identifier from non-enterprise sso verifications if the SSO is enabled', () => {
const password = generatePassword();
const email = generateEmail(domain);
const identifier = Object.freeze({ type: SignInIdentifier.Email, value: email });
beforeAll(async () => {
await Promise.all([setEmailConnector(), setSmsConnector()]);
await enableAllVerificationCodeSignInMethods();
await userApi.create({ primaryEmail: email, password });
});
it('should reject when trying to sign-in with email verification code', async () => {
const client = await initExperienceClient();
const { verificationId, code } = await successfullySendVerificationCode(client, {
identifier,
interactionEvent: InteractionEvent.Register,
});
await successfullyVerifyVerificationCode(client, {
identifier,
verificationId,
code,
});
await expectRejects(
client.identifyUser({
verificationId,
}),
{
code: `session.sso_enabled`,
status: 422,
}
);
});
it('should reject when trying to sign-in with email password', async () => {
const client = await initExperienceClient();
const { verificationId } = await client.verifyPassword({
identifier,
password,
});
await expectRejects(
client.identifyUser({
verificationId,
}),
{
code: `session.sso_enabled`,
status: 422,
}
);
});
});
});

View file

@ -18,8 +18,15 @@ import {
successFullyCreateSocialVerification,
successFullyVerifySocialAuthorization,
} from '#src/helpers/experience/social-verification.js';
import {
successfullySendVerificationCode,
successfullyVerifyVerificationCode,
} from '#src/helpers/experience/verification-code.js';
import { expectRejects } from '#src/helpers/index.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import {
enableAllPasswordSignInMethods,
enableAllVerificationCodeSignInMethods,
} from '#src/helpers/sign-in-experience.js';
import { generateNewUser } from '#src/helpers/user.js';
import { devFeatureTest, generateEmail, generateUsername } from '#src/utils.js';
@ -158,43 +165,187 @@ devFeatureTest.describe('social sign-in and sign-up', () => {
});
await client.updateInteractionEvent({ interactionEvent: InteractionEvent.Register });
await client.identifyUser({ verificationId });
await expectRejects(client.submitInteraction(), {
await expectRejects(client.identifyUser({ verificationId }), {
code: 'user.missing_profile',
status: 422,
});
await client.updateProfile({ type: SignInIdentifier.Username, value: generateUsername() });
await client.identifyUser();
const { redirectTo } = await client.submitInteraction();
const userId = await processSession(client, redirectTo);
await logoutClient(client);
await deleteUser(userId);
});
it('should directly sync trusted email', async () => {
await enableAllPasswordSignInMethods({
identifiers: [SignInIdentifier.Email],
password: true,
verify: true,
describe('fulfill missing email', () => {
beforeAll(async () => {
await enableAllVerificationCodeSignInMethods({
identifiers: [SignInIdentifier.Email],
password: true,
verify: true,
});
});
const userId = await signInWithSocial(
connectorIdMap.get(mockSocialConnectorId)!,
{
id: socialUserId,
email,
},
{
registerNewUser: true,
}
);
it('should directly sync trusted email', async () => {
const userId = await signInWithSocial(
connectorIdMap.get(mockSocialConnectorId)!,
{
id: socialUserId,
email,
},
{
registerNewUser: true,
}
);
const { primaryEmail } = await getUser(userId);
expect(primaryEmail).toBe(email);
const { primaryEmail } = await getUser(userId);
expect(primaryEmail).toBe(email);
await deleteUser(userId);
await deleteUser(userId);
});
it('should ask to provide email if no verified email is returned from social', async () => {
const connectorId = connectorIdMap.get(mockSocialConnectorId)!;
const client = await initExperienceClient();
const { verificationId } = await successFullyCreateSocialVerification(client, connectorId, {
redirectUri,
state,
});
await successFullyVerifySocialAuthorization(client, connectorId, {
verificationId,
connectorData: {
state,
redirectUri,
code: 'fake_code',
userId: generateStandardId(),
},
});
await expectRejects(client.identifyUser({ verificationId }), {
code: 'user.identity_not_exist',
status: 404,
});
await client.updateInteractionEvent({ interactionEvent: InteractionEvent.Register });
await expectRejects(client.identifyUser({ verificationId }), {
code: 'user.missing_profile',
status: 422,
});
const identifier = Object.freeze({ type: SignInIdentifier.Email, value: generateEmail() });
const { code, verificationId: emailVerificationId } =
await successfullySendVerificationCode(client, {
identifier,
interactionEvent: InteractionEvent.Register,
});
await successfullyVerifyVerificationCode(client, {
identifier,
verificationId: emailVerificationId,
code,
});
await client.updateProfile({
type: SignInIdentifier.Email,
verificationId: emailVerificationId,
});
await client.identifyUser();
const { redirectTo } = await client.submitInteraction();
const userId = await processSession(client, redirectTo);
await logoutClient(client);
await deleteUser(userId);
});
it('should ask to sign-in and link social if the email is already in use', async () => {
const { userProfile, user } = await generateNewUser({
primaryEmail: true,
});
const { primaryEmail } = userProfile;
const connectorId = connectorIdMap.get(mockSocialConnectorId)!;
const client = await initExperienceClient();
const { verificationId } = await successFullyCreateSocialVerification(client, connectorId, {
redirectUri,
state,
});
await successFullyVerifySocialAuthorization(client, connectorId, {
verificationId,
connectorData: {
state,
redirectUri,
code: 'fake_code',
userId: generateStandardId(),
},
});
await expectRejects(client.identifyUser({ verificationId }), {
code: 'user.identity_not_exist',
status: 404,
});
await client.updateInteractionEvent({ interactionEvent: InteractionEvent.Register });
await expectRejects(client.identifyUser({ verificationId }), {
code: 'user.missing_profile',
status: 422,
});
const identifier = Object.freeze({ type: SignInIdentifier.Email, value: primaryEmail });
const { code, verificationId: emailVerificationId } =
await successfullySendVerificationCode(client, {
identifier,
interactionEvent: InteractionEvent.Register,
});
await successfullyVerifyVerificationCode(client, {
identifier,
verificationId: emailVerificationId,
code,
});
await expectRejects(
client.updateProfile({
type: SignInIdentifier.Email,
verificationId: emailVerificationId,
}),
{
code: 'user.email_already_in_use',
status: 422,
}
);
await client.updateInteractionEvent({ interactionEvent: InteractionEvent.SignIn });
await client.identifyUser({ verificationId: emailVerificationId });
await client.updateProfile({ type: 'social', verificationId });
const { redirectTo } = await client.submitInteraction();
const userId = await processSession(client, redirectTo);
await logoutClient(client);
expect(userId).toBe(user.id);
const { identities } = await getUser(userId);
expect(identities[mockSocialConnectorTarget]).toBeTruthy();
await deleteUser(userId);
});
});
});
});

View file

@ -58,7 +58,7 @@ devFeatureTest.describe('password verifications', () => {
);
});
it('should throw error if email is registered', async () => {
it('email password is not supported', async () => {
const { primaryEmail, password } = generateNewUserProfile({
primaryEmail: true,
password: true,
@ -77,33 +77,8 @@ devFeatureTest.describe('password verifications', () => {
password,
}),
{
code: 'user.email_already_in_use',
status: 422,
}
);
});
it('should throw error if phone is registered', async () => {
const { primaryPhone, password } = generateNewUserProfile({
primaryPhone: true,
password: true,
});
await userApi.create({ primaryPhone, password });
const client = await initExperienceClient();
await expectRejects(
client.createNewPasswordIdentityVerification({
identifier: {
type: SignInIdentifier.Phone,
value: primaryPhone,
},
password,
}),
{
code: 'user.phone_already_in_use',
status: 422,
code: 'guard.invalid_input',
status: 400,
}
);
});

View file

@ -122,8 +122,11 @@ export const backupCodeVerificationVerifyPayloadGuard = z.object({
/** Payload type for `POST /api/experience/identification`. */
export type IdentificationApiPayload = {
/** The ID of the verification record that is used to identify the user. */
verificationId: string;
/**
* The ID of the verification record that is used to identify the user.
* Optional for the register interaction event
*/
verificationId?: string;
/**
* Link social identity to a related user account with the same email or phone.
* Only applicable for social verification records and a related user account is found.
@ -131,7 +134,7 @@ export type IdentificationApiPayload = {
linkSocialIdentity?: boolean;
};
export const identificationApiPayloadGuard = z.object({
verificationId: z.string(),
verificationId: z.string().optional(),
linkSocialIdentity: z.boolean().optional(),
}) satisfies ToZodObject<IdentificationApiPayload>;
@ -147,7 +150,7 @@ export const CreateExperienceApiPayloadGuard = z.object({
export const updateProfileApiPayloadGuard = z.discriminatedUnion('type', [
z.object({
type: z.literal(SignInIdentifier.Username),
value: z.string(),
value: z.string().regex(usernameRegEx),
}),
z.object({
type: z.literal('password'),
@ -161,6 +164,10 @@ export const updateProfileApiPayloadGuard = z.discriminatedUnion('type', [
type: z.literal(SignInIdentifier.Phone),
verificationId: z.string(),
}),
z.object({
type: z.literal('social'),
verificationId: z.string(),
}),
]);
export type UpdateProfileApiPayload = z.infer<typeof updateProfileApiPayloadGuard>;