From 0e34481f8619866aaa28cb9dbbdf59c88b32f7f4 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Fri, 7 Jun 2024 16:02:13 +0800 Subject: [PATCH] refactor(core,schemas): implement the verification-code class implement the verification code class --- .../{ => classes}/interaction-session.ts | 24 ++- .../{ => classes}/verifications/index.ts | 17 +- .../verifications/password-verification.ts | 15 +- .../verification-code-verification.ts | 188 ++++++++++++++++++ .../verifications/verification.ts | 0 packages/core/src/routes/experience/index.ts | 46 ++++- .../middleware/koa-interaction-session.ts | 2 +- .../experience/{verifications => }/utils.ts | 14 +- packages/schemas/src/types/interactions.ts | 57 ++++-- 9 files changed, 319 insertions(+), 44 deletions(-) rename packages/core/src/routes/experience/{ => classes}/interaction-session.ts (92%) rename packages/core/src/routes/experience/{ => classes}/verifications/index.ts (53%) rename packages/core/src/routes/experience/{ => classes}/verifications/password-verification.ts (87%) create mode 100644 packages/core/src/routes/experience/classes/verifications/verification-code-verification.ts rename packages/core/src/routes/experience/{ => classes}/verifications/verification.ts (100%) rename packages/core/src/routes/experience/{verifications => }/utils.ts (57%) diff --git a/packages/core/src/routes/experience/interaction-session.ts b/packages/core/src/routes/experience/classes/interaction-session.ts similarity index 92% rename from packages/core/src/routes/experience/interaction-session.ts rename to packages/core/src/routes/experience/classes/interaction-session.ts index 55d3a53f0..ef859e88c 100644 --- a/packages/core/src/routes/experience/interaction-session.ts +++ b/packages/core/src/routes/experience/classes/interaction-session.ts @@ -6,11 +6,12 @@ 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 './type.js'; +import type { Interaction } from '../type.js'; + import { buildVerificationRecord, verificationRecordDataGuard, - type Verification, + type VerificationRecord, } from './verifications/index.js'; const interactionSessionResultGuard = z.object({ @@ -42,7 +43,7 @@ export default class InteractionSession { /** The interaction event for the current interaction session */ private interactionEvent?: InteractionEvent; /** The user verification record list for the current interaction session */ - private readonly verificationRecords: Set; + private readonly verificationRecords: Set; /** The accountId of the user for the current interaction session. Only available once the user is identified */ private accountId?: string; /** The user provided profile data in the current interaction session that needs to be stored to user DB */ @@ -84,10 +85,23 @@ export default class InteractionSession { const verificationRecord = this.getVerificationRecordById(verificationId); assertThat( - verificationRecord?.verifiedUserId, + verificationRecord, new RequestError({ code: 'session.identifier_not_found', status: 404 }) ); + assertThat( + verificationRecord.verifiedUserId, + new RequestError( + { + code: 'user.user_not_exist', + status: 404, + }, + { + identifier: verificationRecord.identifier.value, + } + ) + ); + this.accountId = verificationRecord.verifiedUserId; } @@ -95,7 +109,7 @@ export default class InteractionSession { * Append a new verification record to the current interaction session. * @remark If a record with the same type already exists, it will be replaced. */ - public appendVerificationRecord(record: Verification) { + public appendVerificationRecord(record: VerificationRecord) { const { type } = record; const existingRecord = this.getVerificationRecordByType(type); diff --git a/packages/core/src/routes/experience/verifications/index.ts b/packages/core/src/routes/experience/classes/verifications/index.ts similarity index 53% rename from packages/core/src/routes/experience/verifications/index.ts rename to packages/core/src/routes/experience/classes/verifications/index.ts index 6525e1bfd..28c10a014 100644 --- a/packages/core/src/routes/experience/verifications/index.ts +++ b/packages/core/src/routes/experience/classes/verifications/index.ts @@ -9,15 +9,25 @@ import { passwordVerificationRecordDataGuard, type PasswordVerificationRecordData, } from './password-verification.js'; +import { + VerificationCodeVerification, + verificationCodeVerificationRecordDataGuard, + type VerificationCodeVerificationRecordData, +} from './verification-code-verification.js'; -export { Verification } from './verification.js'; +export { PasswordVerification } from './password-verification.js'; -type VerificationRecordData = PasswordVerificationRecordData; +type VerificationRecordData = + | PasswordVerificationRecordData + | VerificationCodeVerificationRecordData; export const verificationRecordDataGuard = z.discriminatedUnion('type', [ passwordVerificationRecordDataGuard, + verificationCodeVerificationRecordDataGuard, ]); +export type VerificationRecord = PasswordVerification | VerificationCodeVerification; + export const buildVerificationRecord = ( libraries: Libraries, queries: Queries, @@ -27,5 +37,8 @@ export const buildVerificationRecord = ( case VerificationType.Password: { return new PasswordVerification(libraries, queries, data); } + case VerificationType.VerificationCode: { + return new VerificationCodeVerification(libraries, queries, data); + } } }; diff --git a/packages/core/src/routes/experience/verifications/password-verification.ts b/packages/core/src/routes/experience/classes/verifications/password-verification.ts similarity index 87% rename from packages/core/src/routes/experience/verifications/password-verification.ts rename to packages/core/src/routes/experience/classes/verifications/password-verification.ts index ab75d23b7..3a1af209a 100644 --- a/packages/core/src/routes/experience/verifications/password-verification.ts +++ b/packages/core/src/routes/experience/classes/verifications/password-verification.ts @@ -1,4 +1,4 @@ -import { VerificationType, passwordIdentifierGuard, type PasswordIdentifier } from '@logto/schemas'; +import { VerificationType, directIdentifierGuard, type DirectIdentifier } from '@logto/schemas'; import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js'; import { generateStandardId } from '@logto/shared'; import { z } from 'zod'; @@ -8,21 +8,22 @@ 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 Verification } from './verification.js'; export type PasswordVerificationRecordData = { id: string; type: VerificationType.Password; - identifier: PasswordIdentifier; - // The userId of the user that has been verified + identifier: DirectIdentifier; + /* The userId of the user that has been verified */ userId?: string; }; export const passwordVerificationRecordDataGuard = z.object({ id: z.string(), type: z.literal(VerificationType.Password), - identifier: passwordIdentifierGuard, + identifier: directIdentifierGuard, userId: z.string().optional(), }) satisfies ToZodObject; @@ -32,7 +33,7 @@ export const passwordVerificationRecordDataGuard = z.object({ */ export class PasswordVerification implements Verification { /** Factory method to create a new PasswordVerification record using the given identifier */ - static create(libraries: Libraries, queries: Queries, identifier: PasswordIdentifier) { + static create(libraries: Libraries, queries: Queries, identifier: DirectIdentifier) { return new PasswordVerification(libraries, queries, { id: generateStandardId(), type: VerificationType.Password, @@ -41,7 +42,7 @@ export class PasswordVerification implements Verification { } readonly type = VerificationType.Password; - public readonly identifier: PasswordIdentifier; + public readonly identifier: DirectIdentifier; public readonly id: string; private userId?: string; diff --git a/packages/core/src/routes/experience/classes/verifications/verification-code-verification.ts b/packages/core/src/routes/experience/classes/verifications/verification-code-verification.ts new file mode 100644 index 000000000..334d524ad --- /dev/null +++ b/packages/core/src/routes/experience/classes/verifications/verification-code-verification.ts @@ -0,0 +1,188 @@ +import { TemplateType } from '@logto/connector-kit'; +import { + InteractionEvent, + VerificationType, + verificationCodeIdentifierGuard, + 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 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 { type Verification } from './verification.js'; + +/** + * To make the typescript type checking work. A valid TemplateType is required. + * This is a work around to map the latest interaction event type to old TemplateType. + * + * @remark This is a temporary solution until the connector-kit is updated to use the latest interaction event types. + **/ +const eventToTemplateTypeMap: Record = { + SignIn: TemplateType.SignIn, + Register: TemplateType.Register, + ForgotPassword: TemplateType.ForgotPassword, +}; +const getTemplateTypeByEvent = (event: InteractionEvent): TemplateType => + eventToTemplateTypeMap[event]; + +export type VerificationCodeVerificationRecordData = { + id: string; + type: VerificationType.VerificationCode; + identifier: VerificationCodeIdentifier; + /** + * The interaction event that triggered the verification. + * This will be used to determine the template type for the verification code. + * @remark + * `InteractionEvent.ForgotPassword` triggered verification results can not used as a verification record for other events. + */ + interactionEvent: InteractionEvent; + /** The userId of the user that has been verified. Only available after the verification of existing identifier */ + userId?: string; + verified: boolean; +}; + +export const verificationCodeVerificationRecordDataGuard = z.object({ + id: z.string(), + type: z.literal(VerificationType.VerificationCode), + identifier: verificationCodeIdentifierGuard, + interactionEvent: z.nativeEnum(InteractionEvent), + userId: z.string().optional(), + verified: z.boolean(), +}) satisfies ToZodObject; + +/** + * VerificationCodeVerification is a verification factor the verifies a given identifier by sending a verification code + * to the user's email or phone number. + * + * @remark The verification code is sent to the user's email or phone number and the user is required to enter the code to verify. + * If the identifier is for a existing user, the userId will be set after the verification. + */ +export class VerificationCodeVerification implements Verification { + /** + * Factory method to create a new VerificationCodeVerification record using the given identifier. + * The sendVerificationCode method will be automatically triggered on the creation of the record. + */ + static async create( + libraries: Libraries, + queries: Queries, + identifier: VerificationCodeIdentifier, + interactionEvent: InteractionEvent + ) { + const record = new VerificationCodeVerification(libraries, queries, { + id: generateStandardId(), + type: VerificationType.VerificationCode, + identifier, + interactionEvent, + verified: false, + }); + + await record.sendVerificationCode(); + + return record; + } + + readonly type = VerificationType.VerificationCode; + public readonly identifier: VerificationCodeIdentifier; + public readonly id: string; + private readonly interactionEvent: InteractionEvent; + private userId?: string; + private verified: boolean; + + constructor( + private readonly libraries: Libraries, + private readonly queries: Queries, + data: VerificationCodeVerificationRecordData + ) { + const { id, identifier, userId, verified, interactionEvent } = data; + + this.id = id; + this.identifier = identifier; + this.interactionEvent = interactionEvent; + this.userId = userId; + this.verified = verified; + } + + /** Returns true if a userId is set */ + get isVerified() { + return this.verified; + } + + /** Returns the userId if it is set */ + get verifiedUserId() { + return this.userId; + } + + /** + * Verify the `identifier` with the given code + * + * @remark The identifier must match the current identifier of the verification record. + * The code will be verified by checking the passcode record in the DB. + * + * `isVerified` will be set to true if the code is verified successfully. + * A `verifiedUserId` will be set if the `identifier` matches an existing user. + */ + async verify(identifier: VerificationCodeIdentifier, code?: string) { + // Throw code not found error is the input identifier is not match with the verification record + assertThat(identifier === this.identifier, 'verification_code.not_found'); + + // Throw code not found error if the code is not provided + assertThat(code, 'verification_code.not_found'); + + const { verifyPasscode } = this.libraries.passcodes; + + await verifyPasscode( + this.id, + getTemplateTypeByEvent(this.interactionEvent), + code, + this.codeIdentifierPayload + ); + + this.verified = true; + + const user = await findUserByIdentifier(this.queries.users, this.identifier); + this.userId = user?.id; + } + + toJson(): VerificationCodeVerificationRecordData { + return { + id: this.id, + type: this.type, + identifier: this.identifier, + interactionEvent: this.interactionEvent, + userId: this.userId, + verified: this.verified, + }; + } + + /** Format the `identifier` data for passcode library method use */ + private get codeIdentifierPayload() { + return this.identifier.type === 'email' + ? { email: this.identifier.value } + : { phone: this.identifier.value }; + } + + /** + * Send the verification code to the current `identifier` + * + * @remark Instead of session jti, + * the verification id is used as `interaction_jti` to uniquely identify the passcode record in DB + * for the current interaction session. + */ + private async sendVerificationCode() { + const { createPasscode, sendPasscode } = this.libraries.passcodes; + + const verificationCode = await createPasscode( + this.id, + getTemplateTypeByEvent(this.interactionEvent), + this.codeIdentifierPayload + ); + + await sendPasscode(verificationCode); + } +} diff --git a/packages/core/src/routes/experience/verifications/verification.ts b/packages/core/src/routes/experience/classes/verifications/verification.ts similarity index 100% rename from packages/core/src/routes/experience/verifications/verification.ts rename to packages/core/src/routes/experience/classes/verifications/verification.ts diff --git a/packages/core/src/routes/experience/index.ts b/packages/core/src/routes/experience/index.ts index 3d8800fc6..e12c96821 100644 --- a/packages/core/src/routes/experience/index.ts +++ b/packages/core/src/routes/experience/index.ts @@ -10,7 +10,7 @@ * The experience APIs can be used by developers to build custom user interaction experiences. */ -import { InteractionEvent, signInPayloadGuard } from '@logto/schemas'; +import { InteractionEvent, VerificationType, signInPayloadGuard } from '@logto/schemas'; import type Router from 'koa-router'; import koaAuditLog from '#src/middleware/koa-audit-log.js'; @@ -18,10 +18,10 @@ import koaGuard from '#src/middleware/koa-guard.js'; import { type AnonymousRouter, type RouterInitArgs } from '../types.js'; +import { PasswordVerification } from './classes/verifications/index.js'; import koaInteractionSession, { type WithInteractionSessionContext, } from './middleware/koa-interaction-session.js'; -import { PasswordVerification } from './verifications/password-verification.js'; const experienceApiRoutesPrefix = '/experience'; @@ -51,13 +51,43 @@ export default function experienceApiRoutes( ctx.interactionSession.setInteractionEvent(InteractionEvent.SignIn); - // TODO: Add support for other verification types - const { value } = verification; - const passwordVerification = PasswordVerification.create(libraries, queries, identifier); - await passwordVerification.verify(value); - ctx.interactionSession.appendVerificationRecord(passwordVerification); + switch (verification.type) { + case VerificationType.Password: { + const { value } = verification; - ctx.interactionSession.identifyUser(passwordVerification.id); + const passwordVerification = PasswordVerification.create(libraries, queries, identifier); + + await passwordVerification.verify(value); + + ctx.interactionSession.appendVerificationRecord(passwordVerification); + ctx.interactionSession.identifyUser(passwordVerification.id); + + break; + } + case VerificationType.VerificationCode: { + // // Username is not supported for verification code method now + // assertThat(isVerificationCodeIdentifier(identifier), 'guard.invalid_input'); + + // const { verificationId, value } = verification; + + // const verificationCodeVerification = + // ctx.interactionSession.getVerificationRecordById(verificationId); + + // assertThat( + // verificationCodeVerification && + // // Make the Verification type checker happy + // verificationCodeVerification.type === VerificationType.VerificationCode, + // new RequestError({ code: 'session.verification_session_not_found', status: 404 }) + // ); + + // if (!verificationCodeVerification.isVerified) { + // await verificationCodeVerification.verify(identifier, value); + // } + // ctx.interactionSession.identifyUser(verificationCodeVerification.id); + + break; + } + } await ctx.interactionSession.save(); diff --git a/packages/core/src/routes/experience/middleware/koa-interaction-session.ts b/packages/core/src/routes/experience/middleware/koa-interaction-session.ts index 1aa3f5990..04b083f90 100644 --- a/packages/core/src/routes/experience/middleware/koa-interaction-session.ts +++ b/packages/core/src/routes/experience/middleware/koa-interaction-session.ts @@ -3,7 +3,7 @@ import type { MiddlewareType } from 'koa'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import type TenantContext from '#src/tenants/TenantContext.js'; -import InteractionSession from '../interaction-session.js'; +import InteractionSession from '../classes/interaction-session.js'; export type WithInteractionSessionContext = ContextT & { diff --git a/packages/core/src/routes/experience/verifications/utils.ts b/packages/core/src/routes/experience/utils.ts similarity index 57% rename from packages/core/src/routes/experience/verifications/utils.ts rename to packages/core/src/routes/experience/utils.ts index 24fc08def..80c549eac 100644 --- a/packages/core/src/routes/experience/verifications/utils.ts +++ b/packages/core/src/routes/experience/utils.ts @@ -2,14 +2,9 @@ import { type DirectIdentifier } from '@logto/schemas'; import type Queries from '#src/tenants/Queries.js'; -type IdentifierPayload = { - type: DirectIdentifier; - value: string; -}; - export const findUserByIdentifier = async ( userQuery: Queries['users'], - { type, value }: IdentifierPayload + { type, value }: DirectIdentifier ) => { if (type === 'username') { return userQuery.findUserByUsername(value); @@ -21,3 +16,10 @@ export const findUserByIdentifier = async ( return userQuery.findUserByPhone(value); }; + +/** Narrow down the DirectIdentifier input to VerificationCodeIdentifier */ +// export const isVerificationCodeIdentifier = ( +// identifier: DirectIdentifier +// ): identifier is VerificationCodeIdentifier => { +// return identifier.type !== 'username'; +// }; diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index 1048dd1fc..1375066c5 100644 --- a/packages/schemas/src/types/interactions.ts +++ b/packages/schemas/src/types/interactions.ts @@ -27,7 +27,15 @@ export enum InteractionEvent { // ================================================================================================================= /** First party identifiers that can be used directly to identify a user */ -export type DirectIdentifier = 'username' | 'email' | 'phone'; +export type DirectIdentifier = { + type: 'username' | 'email' | 'phone'; + value: string; +}; + +export const directIdentifierGuard = z.object({ + type: z.enum(['username', 'email', 'phone']), + value: z.string(), +}); /** Logto supported interaction verification types */ export enum VerificationType { @@ -39,27 +47,46 @@ export enum VerificationType { BackupCode = 'BackupCode', } -export type PasswordIdentifier = { - type: DirectIdentifier; +/* Password verification start */ +export const passwordVerifierGuard = z.object({ + type: z.literal(VerificationType.Password), + value: z.string(), +}); +/* Password verification end */ + +/* Verification code verification start */ + +/** Only email and phone are supported as verification code identifiers */ +export type VerificationCodeIdentifier = { + type: 'email' | 'phone'; value: string; }; -export const passwordIdentifierGuard = z.object({ - type: z.enum(['username', 'email', 'phone']), +export const verificationCodeIdentifierGuard = z.object({ + type: z.enum(['email', 'phone']), value: z.string(), -}) satisfies ToZodObject; +}) satisfies ToZodObject; -export const passwordSignInPayloadGuard = z.object({ - identifier: passwordIdentifierGuard, - verification: z.object({ - type: z.literal(VerificationType.Password), - value: z.string(), - }), +export type VerificationCodeVerificationPayload = { + type: VerificationType.VerificationCode; + /** The verification code send to the identifier. Can be omitted if the identifier has been verified */ + value?: string; + /** The unique ID of the verification record associated with the identifier */ + verificationId: string; +}; + +export const verificationCodeVerificationPayloadGuard = z.object({ + type: z.literal(VerificationType.VerificationCode), + value: z.string().optional(), + verificationId: z.string(), +}) satisfies ToZodObject; +/* Verification code verification end */ + +export const signInPayloadGuard = z.object({ + identifier: directIdentifierGuard, + verification: z.union([passwordVerifierGuard, verificationCodeVerificationPayloadGuard]), }); -export type PasswordSignInPayload = z.infer; -/** Payload guard for the /sign-in endpoint */ -export const signInPayloadGuard = passwordSignInPayloadGuard; export type SignInPayload = z.infer; // =================================================================================================================