Skip to content

Commit

Permalink
refactor(core): refactor identifyUser method (#6154)
Browse files Browse the repository at this point in the history
refactor(core): refactor the user identification flow

refactor the user identification flow
  • Loading branch information
simeng-li authored Jul 8, 2024
1 parent 3829dcd commit 07316cf
Show file tree
Hide file tree
Showing 9 changed files with 256 additions and 139 deletions.
119 changes: 79 additions & 40 deletions packages/core/src/routes/experience/classes/experience-interaction.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { InteractionEvent, type VerificationType } from '@logto/schemas';
import { type ToZodObject } from '@logto/connector-kit';
import { InteractionEvent, VerificationType } from '@logto/schemas';
import { z } from 'zod';

import RequestError from '#src/errors/RequestError/index.js';
Expand All @@ -8,18 +9,27 @@ import assertThat from '#src/utils/assert-that.js';

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

import { validateSieVerificationMethod } from './utils.js';
import {
buildVerificationRecord,
verificationRecordDataGuard,
type VerificationRecord,
type VerificationRecordData,
} from './verifications/index.js';

type InteractionStorage = {
interactionEvent?: InteractionEvent;
userId?: string;
profile?: Record<string, unknown>;
verificationRecords?: VerificationRecordData[];
};

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

/**
* Interaction is a short-lived session session that is initiated when a user starts an interaction flow with the Logto platform.
Expand All @@ -41,8 +51,8 @@ export default class ExperienceInteraction {
private interactionEvent?: InteractionEvent;
/** The user verification record list for the current interaction. */
private readonly verificationRecords: Map<VerificationType, VerificationRecord>;
/** The accountId of the user for the current interaction. Only available once the user is identified. */
private accountId?: string;
/** The userId of the user for the current interaction. Only available once the user is identified. */
private userId?: string;
/** The user provided profile data in the current interaction that needs to be stored to database. */
private readonly profile?: Record<string, unknown>; // TODO: Fix the type

Expand All @@ -60,10 +70,10 @@ export default class ExperienceInteraction {
new RequestError({ code: 'session.interaction_not_found', status: 404 })
);

const { verificationRecords = [], profile, accountId, event } = result.data;
const { verificationRecords = [], profile, userId, interactionEvent } = result.data;

this.interactionEvent = event;
this.accountId = accountId; // TODO: @simeng-li replace with userId
this.interactionEvent = interactionEvent;
this.userId = userId; // TODO: @simeng-li replace with userId
this.profile = profile;

this.verificationRecords = new Map();
Expand All @@ -75,40 +85,63 @@ export default class ExperienceInteraction {
}

/** Set the interaction event for the current interaction */
public setInteractionEvent(event: InteractionEvent) {
public setInteractionEvent(interactionEvent: InteractionEvent) {
// TODO: conflict event check (e.g. reset password session can't be used for sign in)
this.interactionEvent = event;
this.interactionEvent = interactionEvent;
}

/** Set the verified `accountId` of the current interaction from the verification record */
public identifyUser(verificationId: string) {
/**
* Identify the user using the verification record.
*
* - Check if the verification record exists.
* - Check if the verification record is valid for the current interaction event.
* - 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 verification record is not found
* @throws RequestError with 404 if the interaction event is not set
* @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
**/
public async identifyUser(verificationId: string) {
const verificationRecord = this.getVerificationRecordById(verificationId);

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

// Throws an 404 error if the user is not found by the given verification record
// TODO: refactor using real-time user verification. Static verifiedUserId will be removed.
assertThat(
verificationRecord.verifiedUserId,
new RequestError({
code: 'user.user_not_exist',
status: 404,
})
);
// Existing user identification flow
validateSieVerificationMethod(this.interactionEvent, verificationRecord);

// Throws an 409 error if the current session has already identified a different user
if (this.accountId) {
assertThat(
this.accountId === verificationRecord.verifiedUserId,
new RequestError({ code: 'session.identity_conflict', status: 409 })
);
// User creation flow
if (this.interactionEvent === InteractionEvent.Register) {
this.createNewUser(verificationRecord);
return;
}

this.accountId = verificationRecord.verifiedUserId;
switch (verificationRecord.type) {
case VerificationType.Password:
case VerificationType.VerificationCode:
case VerificationType.Social: {
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;
}
}
}

/**
Expand Down Expand Up @@ -145,28 +178,34 @@ 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.accountId, 'session.verification_session_not_found');
assertThat(this.userId, 'session.verification_session_not_found');

const { provider } = this.tenant;

const redirectTo = await provider.interactionResult(this.ctx.req, this.ctx.res, {
login: { accountId: this.accountId },
login: { accountId: this.userId },
});

this.ctx.body = { redirectTo };
}

private get verificationRecordsArray() {
return [...this.verificationRecords.values()];
}

/** Convert the current interaction to JSON, so that it can be stored as the OIDC provider interaction result */
public toJson() {
public toJson(): InteractionStorage {
const { interactionEvent, userId, profile } = this;

return {
event: this.interactionEvent,
accountId: this.accountId,
profile: this.profile,
interactionEvent,
userId,
profile,
verificationRecords: this.verificationRecordsArray.map((record) => record.toJson()),
};
}

private get verificationRecordsArray() {
return [...this.verificationRecords.values()];
}

private createNewUser(verificationRecord: VerificationRecord) {
// TODO: create new user for the Register event
}
}
62 changes: 62 additions & 0 deletions packages/core/src/routes/experience/classes/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {
InteractionEvent,
InteractionIdentifierType,
VerificationType,
type InteractionIdentifier,
} from '@logto/schemas';

import RequestError from '#src/errors/RequestError/index.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';

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

export const findUserByIdentifier = async (
userQuery: Queries['users'],
{ type, value }: InteractionIdentifier
) => {
switch (type) {
case InteractionIdentifierType.Username: {
return userQuery.findUserByUsername(value);
}
case InteractionIdentifierType.Email: {
return userQuery.findUserByEmail(value);
}
case InteractionIdentifierType.Phone: {
return userQuery.findUserByPhone(value);
}
}
};

/**
* Check if the verification record is valid for the current interaction event.
*
* This function will compare the verification record for the current interaction event with Logto's SIE settings
*
* @throws RequestError with 400 if the verification record is not valid for the current interaction event
*/
export const validateSieVerificationMethod = (
interactionEvent: InteractionEvent,
verificationRecord: VerificationRecord
) => {
switch (interactionEvent) {
case InteractionEvent.SignIn: {
// TODO: sign-in methods validation
break;
}
case InteractionEvent.Register: {
// TODO: sign-up methods validation
break;
}
case InteractionEvent.ForgotPassword: {
// Forgot password only supports verification code type verification record
// The verification record's interaction event must be ForgotPassword
assertThat(
verificationRecord.type === VerificationType.VerificationCode &&
verificationRecord.interactionEvent === InteractionEvent.ForgotPassword,
new RequestError({ code: 'session.verification_session_not_found', status: 400 })
);
break;
}
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,20 @@ import {
InteractionEvent,
VerificationType,
verificationCodeIdentifierGuard,
type User,
type VerificationCodeIdentifier,
} from '@logto/schemas';
import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js';
import { generateStandardId } from '@logto/shared';
import { z } from 'zod';

import RequestError from '#src/errors/RequestError/index.js';
import { type createPasscodeLibrary } from '#src/libraries/passcode.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 { findUserByIdentifier } from '../../utils.js';
import { findUserByIdentifier } from '../utils.js';

import { type VerificationRecord } from './verification-record.js';

Expand All @@ -39,7 +41,6 @@ export type CodeVerificationRecordData = {
type: VerificationType.VerificationCode;
identifier: VerificationCodeIdentifier;
interactionEvent: InteractionEvent;
userId?: string;
verified: boolean;
};

Expand All @@ -48,7 +49,6 @@ export const codeVerificationRecordDataGuard = z.object({
type: z.literal(VerificationType.VerificationCode),
identifier: verificationCodeIdentifierGuard,
interactionEvent: z.nativeEnum(InteractionEvent),
userId: z.string().optional(),
verified: z.boolean(),
}) satisfies ToZodObject<CodeVerificationRecordData>;

Expand Down Expand Up @@ -102,22 +102,19 @@ export class CodeVerification implements VerificationRecord<VerificationType.Ver
* @remark
* `InteractionEvent.ForgotPassword` triggered verification results can not used as a verification record for other events.
*/
private readonly interactionEvent: InteractionEvent;
/** The userId will be set after the verification if the identifier matches any existing user's record */
private userId?: string;
public readonly interactionEvent: InteractionEvent;
private verified: boolean;

constructor(
private readonly libraries: Libraries,
private readonly queries: Queries,
data: CodeVerificationRecordData
) {
const { id, identifier, userId, verified, interactionEvent } = data;
const { id, identifier, verified, interactionEvent } = data;

this.id = id;
this.identifier = identifier;
this.interactionEvent = interactionEvent;
this.userId = userId;
this.verified = verified;
}

Expand All @@ -126,14 +123,6 @@ export class CodeVerification implements VerificationRecord<VerificationType.Ver
return this.verified;
}

/**
* Returns the userId if it is set
* @deprecated this will be removed in the upcoming PR
*/
get verifiedUserId() {
return this.userId;
}

/**
* Verify the `identifier` with the given code
*
Expand All @@ -157,20 +146,42 @@ export class CodeVerification implements VerificationRecord<VerificationType.Ver
);

this.verified = true;
}

/**
* Identify the user by the current `identifier`.
* Return undefined if the verification record is not verified or no user is found by the identifier.
*/
async identifyUser(): Promise<User> {
assertThat(
this.verified,
new RequestError({ code: 'session.verification_failed', status: 400 })
);

// Try to lookup the user by the identifier
const user = await findUserByIdentifier(this.queries.users, this.identifier);
this.userId = user?.id;

assertThat(
user,
new RequestError(
{ code: 'user.user_not_exist', status: 404 },
{
identifier: this.identifier.value,
}
)
);

return user;
}

toJson(): CodeVerificationRecordData {
const { id, type, identifier, interactionEvent, verified } = this;

return {
id: this.id,
type: this.type,
identifier: this.identifier,
interactionEvent: this.interactionEvent,
userId: this.userId,
verified: this.verified,
id,
type,
identifier,
interactionEvent,
verified,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
type SocialVerificationRecordData,
} from './social-verification.js';

type VerificationRecordData =
export type VerificationRecordData =
| PasswordVerificationRecordData
| CodeVerificationRecordData
| SocialVerificationRecordData;
Expand Down
Loading

0 comments on commit 07316cf

Please sign in to comment.