diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index c6be23bfb26..35b6d79c67e 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -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'; @@ -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; + 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; /** * Interaction is a short-lived session session that is initiated when a user starts an interaction flow with the Logto platform. @@ -41,8 +51,8 @@ export default class ExperienceInteraction { private interactionEvent?: InteractionEvent; /** The user verification record list for the current interaction. */ private readonly verificationRecords: Map; - /** 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; // TODO: Fix the type @@ -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(); @@ -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; + } + } } /** @@ -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 + } } diff --git a/packages/core/src/routes/experience/classes/utils.ts b/packages/core/src/routes/experience/classes/utils.ts new file mode 100644 index 00000000000..dfaa4884756 --- /dev/null +++ b/packages/core/src/routes/experience/classes/utils.ts @@ -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; + } + } +}; diff --git a/packages/core/src/routes/experience/classes/verifications/code-verification.ts b/packages/core/src/routes/experience/classes/verifications/code-verification.ts index 05bc7fe5dac..ccc8351af92 100644 --- a/packages/core/src/routes/experience/classes/verifications/code-verification.ts +++ b/packages/core/src/routes/experience/classes/verifications/code-verification.ts @@ -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'; @@ -39,7 +41,6 @@ export type CodeVerificationRecordData = { type: VerificationType.VerificationCode; identifier: VerificationCodeIdentifier; interactionEvent: InteractionEvent; - userId?: string; verified: boolean; }; @@ -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; @@ -102,9 +102,7 @@ export class CodeVerification implements VerificationRecord { + 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, }; } diff --git a/packages/core/src/routes/experience/classes/verifications/index.ts b/packages/core/src/routes/experience/classes/verifications/index.ts index 472c807da18..018e4470630 100644 --- a/packages/core/src/routes/experience/classes/verifications/index.ts +++ b/packages/core/src/routes/experience/classes/verifications/index.ts @@ -20,7 +20,7 @@ import { type SocialVerificationRecordData, } from './social-verification.js'; -type VerificationRecordData = +export type VerificationRecordData = | PasswordVerificationRecordData | CodeVerificationRecordData | SocialVerificationRecordData; diff --git a/packages/core/src/routes/experience/classes/verifications/password-verification.ts b/packages/core/src/routes/experience/classes/verifications/password-verification.ts index 245dfcc5da5..fd9b5675124 100644 --- a/packages/core/src/routes/experience/classes/verifications/password-verification.ts +++ b/packages/core/src/routes/experience/classes/verifications/password-verification.ts @@ -2,6 +2,7 @@ import { VerificationType, interactionIdentifierGuard, type InteractionIdentifier, + type User, } from '@logto/schemas'; import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js'; import { generateStandardId } from '@logto/shared'; @@ -12,7 +13,7 @@ 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'; @@ -20,15 +21,14 @@ export type PasswordVerificationRecordData = { id: string; type: VerificationType.Password; identifier: InteractionIdentifier; - /** The unique identifier of the user that has been verified. */ - userId?: string; + verified: boolean; }; export const passwordVerificationRecordDataGuard = z.object({ id: z.string(), type: z.literal(VerificationType.Password), identifier: interactionIdentifierGuard, - userId: z.string().optional(), + verified: z.boolean(), }) satisfies ToZodObject; export class PasswordVerification implements VerificationRecord { @@ -38,13 +38,14 @@ export class PasswordVerification implements VerificationRecord { + assertThat( + this.verified, + new RequestError({ code: 'session.verification_failed', status: 400 }) + ); + + const user = await findUserByIdentifier(this.queries.users, this.identifier); + + assertThat(user, new RequestError({ code: 'user.user_not_exist', status: 404 })); + + return user; } toJson(): PasswordVerificationRecordData { - const { id, type, identifier, userId } = this; + const { id, type, identifier, verified } = this; return { id, type, identifier, - userId, + verified, }; } } diff --git a/packages/core/src/routes/experience/classes/verifications/social-verification.ts b/packages/core/src/routes/experience/classes/verifications/social-verification.ts index 2044ba6fbeb..5e29d1a5c73 100644 --- a/packages/core/src/routes/experience/classes/verifications/social-verification.ts +++ b/packages/core/src/routes/experience/classes/verifications/social-verification.ts @@ -8,6 +8,7 @@ import { import { generateStandardId } from '@logto/shared'; import { z } from 'zod'; +import RequestError from '#src/errors/RequestError/index.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import { createSocialAuthorizationUrl, @@ -16,6 +17,7 @@ import { import type Libraries from '#src/tenants/Libraries.js'; import type Queries from '#src/tenants/Queries.js'; import type TenantContext from '#src/tenants/TenantContext.js'; +import assertThat from '#src/utils/assert-that.js'; import { type VerificationRecord } from './verification-record.js'; @@ -28,7 +30,6 @@ export type SocialVerificationRecordData = { * The social identity returned by the connector. */ socialUserInfo?: SocialUserInfo; - userId?: string; }; export const socialVerificationRecordDataGuard = z.object({ @@ -36,7 +37,6 @@ export const socialVerificationRecordDataGuard = z.object({ connectorId: z.string(), type: z.literal(VerificationType.Social), socialUserInfo: socialUserInfoGuard.optional(), - userId: z.string().optional(), }) satisfies ToZodObject; export class SocialVerification implements VerificationRecord { @@ -56,24 +56,16 @@ export class SocialVerification implements VerificationRecord { + assertThat( + this.isVerified, + new RequestError({ code: 'session.verification_failed', status: 400 }) + ); const user = await this.findUserBySocialIdentity(); - this.userId = user?.id; + + if (!user) { + const relatedUser = await this.findRelatedUserBySocialIdentity(); + + throw new RequestError( + { + code: 'user.identity_not_exist', + status: 422, + }, + { + ...(relatedUser && { relatedUser: relatedUser[0] }), + } + ); + } + + return user; } - async findUserBySocialIdentity(): Promise { + toJson(): SocialVerificationRecordData { + const { id, connectorId, type, socialUserInfo } = this; + + return { + id, + connectorId, + type, + socialUserInfo, + }; + } + + private async findUserBySocialIdentity(): Promise { const { socials } = this.libraries; const { users: { findUserByIdentity }, @@ -163,7 +188,9 @@ export class SocialVerification implements VerificationRecord { + private async findRelatedUserBySocialIdentity(): ReturnType< + typeof socials.findSocialRelatedUser + > { const { socials } = this.libraries; if (!this.socialUserInfo) { @@ -172,15 +199,4 @@ export class SocialVerification implements VerificationRecord = T extends Router ? Context : nev export default function experienceApiRoutes( ...[anonymousRouter, tenant]: RouterInitArgs ) { - const { queries, libraries } = tenant; + const { queries } = tenant; const router = // @ts-expect-error for good koa types @@ -46,18 +46,15 @@ export default function experienceApiRoutes( experienceRoutes.identification, koaGuard({ body: identificationApiPayloadGuard, - status: [204, 400, 404], + status: [204, 400, 401, 404], }), async (ctx, next) => { const { interactionEvent, verificationId } = ctx.guard.body; + // TODO: implement a separate POST interaction route to handle the initiation of the interaction event ctx.experienceInteraction.setInteractionEvent(interactionEvent); - // TODO: SIE verification method check - // TODO: forgot password verification method check, only allow email and phone verification code - // TODO: user suspension check - - ctx.experienceInteraction.identifyUser(verificationId); + await ctx.experienceInteraction.identifyUser(verificationId); await ctx.experienceInteraction.save(); diff --git a/packages/core/src/routes/experience/utils.ts b/packages/core/src/routes/experience/utils.ts deleted file mode 100644 index e8985de2428..00000000000 --- a/packages/core/src/routes/experience/utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { InteractionIdentifierType, type InteractionIdentifier } from '@logto/schemas'; - -import type Queries from '#src/tenants/Queries.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); - } - } -};