Skip to content

Commit

Permalink
feat(core,schemas): implement the register flow (#6237)
Browse files Browse the repository at this point in the history
* feat(core,schemas): implement the register flow

implement the register flow

* refactor(core,schemas): relocate the profile type defs

relocate the profile type defs

* fix(core): fix the validation guard logic

fix the validation guard logic

* fix(core): fix social and sso identity not created bug

fix social and sso identity not created bug

* fix(core): fix social identities profile key

fix social identities profile key

* fix(core): fix sso query method

fix sso query method
  • Loading branch information
simeng-li committed Jul 15, 2024
1 parent f94fb51 commit 84ac935
Show file tree
Hide file tree
Showing 10 changed files with 449 additions and 73 deletions.
137 changes: 91 additions & 46 deletions packages/core/src/routes/experience/classes/experience-interaction.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { type ToZodObject } from '@logto/connector-kit';
import { InteractionEvent, VerificationType } from '@logto/schemas';
import { InteractionEvent, type VerificationType } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { conditional } from '@silverhand/essentials';
import { z } from 'zod';

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

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

import { getNewUserProfileFromVerificationRecord, toUserSocialIdentityData } from './utils.js';
import { ProfileValidator } from './validators/profile-validator.js';
import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js';
import {
buildVerificationRecord,
Expand All @@ -20,14 +24,14 @@ import {
type InteractionStorage = {
interactionEvent?: InteractionEvent;
userId?: string;
profile?: Record<string, unknown>;
profile?: InteractionProfile;
verificationRecords?: VerificationRecordData[];
};

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

Expand All @@ -39,6 +43,7 @@ const interactionStorageGuard = z.object({
*/
export default class ExperienceInteraction {
public readonly signInExperienceValidator: SignInExperienceValidator;
public readonly profileValidator: ProfileValidator;

/** The user verification record list for the current interaction. */
private readonly verificationRecords = new Map<VerificationType, VerificationRecord>();
Expand All @@ -65,6 +70,7 @@ export default class ExperienceInteraction {
this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries);

if (!interactionDetails) {
this.profileValidator = new ProfileValidator(libraries, queries);
return;
}

Expand All @@ -82,6 +88,9 @@ export default class ExperienceInteraction {
this.userId = userId;
this.profile = profile;

// Profile validator requires the userId for existing user profile update validation
this.profileValidator = new ProfileValidator(libraries, queries, userId);

for (const record of verificationRecords) {
const instance = buildVerificationRecord(libraries, queries, record);
this.verificationRecords.set(instance.type, instance);
Expand Down Expand Up @@ -123,16 +132,16 @@ export default class ExperienceInteraction {
* Identify the user using the verification record.
*
* - Check if the verification record exists.
* - Verify the verification record with `SignInExperienceValidator`.
* - 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 400 if the verification record is not valid for the current interaction event
* @throws RequestError with 401 if the user is suspended
* @throws RequestError with 409 if the current session has already identified a different user
* @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.
**/
public async identifyUser(verificationId: string) {
const verificationRecord = this.getVerificationRecordById(verificationId);
Expand Down Expand Up @@ -196,9 +205,20 @@ export default class ExperienceInteraction {

/** Submit the current interaction result to the OIDC provider and clear the interaction data */
public async submit() {
// TODO: refine the error code
assertThat(this.userId, 'session.verification_session_not_found');

// TODO: mfa validation
// TODO: missing profile fields validation

const {
queries: { users: userQueries },
} = this.tenant;

// Update user profile
await userQueries.updateUserById(this.userId, {
lastSignInAt: Date.now(),
});

const { provider } = this.tenant;

const redirectTo = await provider.interactionResult(this.ctx.req, this.ctx.res, {
Expand All @@ -224,46 +244,71 @@ export default class ExperienceInteraction {
return [...this.verificationRecords.values()];
}

/**
* Identify the existing user using the verification record.
*
* @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) {
switch (verificationRecord.type) {
case VerificationType.Password:
case VerificationType.VerificationCode:
case VerificationType.Social:
case VerificationType.EnterpriseSso: {
// TODO: social sign-in with verified email
const { id, isSuspended } = await verificationRecord.identifyUser();

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;
}

this.userId = id;
break;
}
default: {
// Unsupported verification type for identification, such as MFA verification.
throw new RequestError({ code: 'session.verification_failed', status: 400 });
}
// Check verification record can be used to identify a user using the `identifyUser` method.
// E.g. MFA verification record does not have the `identifyUser` method, cannot be used to identify a user.
assertThat(
'identifyUser' in verificationRecord,
new RequestError({ code: 'session.verification_failed', status: 400 })
);

const { id, isSuspended } = await verificationRecord.identifyUser();

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;
}

this.userId = id;
}

private async createNewUser(verificationRecord: VerificationRecord) {
// TODO: To be implemented
switch (verificationRecord.type) {
case VerificationType.VerificationCode: {
break;
}
default: {
// Unsupported verification type for user creation, such as MFA verification.
throw new RequestError({ code: 'session.verification_failed', status: 400 });
}
const {
libraries: {
users: { generateUserId, insertUser },
},
queries: { userSsoIdentities: userSsoIdentitiesQueries },
} = this.tenant;

const newProfile = await getNewUserProfileFromVerificationRecord(verificationRecord);

await this.profileValidator.guardProfileUniquenessAcrossUsers(newProfile);

// TODO: new user provisioning and hooks

const { socialIdentity, enterpriseSsoIdentity, ...rest } = newProfile;

const [user] = await insertUser(
{
id: await generateUserId(),
...rest,
...conditional(socialIdentity && { identities: toUserSocialIdentityData(socialIdentity) }),
},
[]
);

if (enterpriseSsoIdentity) {
await userSsoIdentitiesQueries.insert({
id: generateStandardId(),
userId: user.id,
...enterpriseSsoIdentity,
});
}

this.userId = user.id;
}
}
52 changes: 51 additions & 1 deletion packages/core/src/routes/experience/classes/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { SignInIdentifier, type InteractionIdentifier } from '@logto/schemas';
import {
SignInIdentifier,
VerificationType,
type InteractionIdentifier,
type User,
} from '@logto/schemas';

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

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

import { type VerificationRecord } from './verifications/index.js';

export const findUserByIdentifier = async (
userQuery: Queries['users'],
{ type, value }: InteractionIdentifier
Expand All @@ -18,3 +28,43 @@ export const findUserByIdentifier = async (
}
}
};

/**
* @throws {RequestError} -400 if the verification record type is not supported for user creation.
* @throws {RequestError} -400 if the verification record is not verified.
*/
export const getNewUserProfileFromVerificationRecord = async (
verificationRecord: VerificationRecord
): Promise<InteractionProfile> => {
switch (verificationRecord.type) {
case VerificationType.VerificationCode: {
return verificationRecord.toUserProfile();
}
case VerificationType.EnterpriseSso:
case VerificationType.Social: {
const identityProfile = await verificationRecord.toUserProfile();
const syncedProfile = await verificationRecord.toSyncedProfile(true);
return { ...identityProfile, ...syncedProfile };
}
default: {
// Unsupported verification type for user creation, such as MFA verification.
throw new RequestError({ code: 'session.verification_failed', status: 400 });
}
}
};

/**
* Convert the interaction profile `socialIdentity` to `User['identities']` data format
*/
export const toUserSocialIdentityData = (
socialIdentity: Required<InteractionProfile>['socialIdentity']
): User['identities'] => {
const { target, userInfo } = socialIdentity;

return {
[target]: {
userId: userInfo.id,
details: userInfo,
},
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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,
/** UserId is required for existing user profile update validation */
private readonly userId?: string
) {}

public async guardProfileUniquenessAcrossUsers(profile: InteractionProfile) {
const { hasUser, hasUserWithEmail, hasUserWithPhone, hasUserWithIdentity } = this.queries.users;
const { userSsoIdentities } = this.queries;

const { username, primaryEmail, primaryPhone, socialIdentity, enterpriseSsoIdentity } = profile;

if (username) {
assertThat(
!(await hasUser(username)),
new RequestError({
code: 'user.username_already_in_use',
status: 422,
})
);
}

if (primaryEmail) {
assertThat(
!(await hasUserWithEmail(primaryEmail)),
new RequestError({
code: 'user.email_already_in_use',
status: 422,
})
);
}

if (primaryPhone) {
assertThat(
!(await hasUserWithPhone(primaryPhone)),
new RequestError({
code: 'user.phone_already_in_use',
status: 422,
})
);
}

if (socialIdentity) {
const {
target,
userInfo: { id },
} = socialIdentity;

assertThat(
!(await hasUserWithIdentity(target, id)),
new RequestError({
code: 'user.identity_already_in_use',
status: 422,
})
);
}

if (enterpriseSsoIdentity) {
const { issuer, identityId } = enterpriseSsoIdentity;
const userSsoIdentity = await userSsoIdentities.findUserSsoIdentityBySsoIdentityId(
issuer,
identityId
);

assertThat(
!userSsoIdentity,
new RequestError({
code: 'user.identity_already_in_use',
status: 422,
})
);
}

// TODO: Password validation
}
}
Loading

0 comments on commit 84ac935

Please sign in to comment.