0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-13 21:30:30 -05:00

refactor(core,schemas): implement the verification-code class

implement the verification code class
This commit is contained in:
simeng-li 2024-06-07 16:02:13 +08:00
parent 2e2b58681a
commit 0e34481f86
No known key found for this signature in database
GPG key ID: 14EA7BB1541E8075
9 changed files with 319 additions and 44 deletions

View file

@ -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<Verification>;
private readonly verificationRecords: Set<VerificationRecord>;
/** 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);

View file

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

View file

@ -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<PasswordVerificationRecordData>;
@ -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;

View file

@ -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<InteractionEvent, TemplateType> = {
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<VerificationCodeVerificationRecordData>;
/**
* 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);
}
}

View file

@ -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<T extends AnonymousRouter>(
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();

View file

@ -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 extends WithLogContext = WithLogContext> =
ContextT & {

View file

@ -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';
// };

View file

@ -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<PasswordIdentifier>;
}) satisfies ToZodObject<VerificationCodeIdentifier>;
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<VerificationCodeVerificationPayload>;
/* Verification code verification end */
export const signInPayloadGuard = z.object({
identifier: directIdentifierGuard,
verification: z.union([passwordVerifierGuard, verificationCodeVerificationPayloadGuard]),
});
export type PasswordSignInPayload = z.infer<typeof passwordSignInPayloadGuard>;
/** Payload guard for the /sign-in endpoint */
export const signInPayloadGuard = passwordSignInPayloadGuard;
export type SignInPayload = z.infer<typeof signInPayloadGuard>;
// =================================================================================================================