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:
parent
2e2b58681a
commit
0e34481f86
9 changed files with 319 additions and 44 deletions
|
@ -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);
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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 & {
|
||||
|
|
|
@ -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';
|
||||
// };
|
|
@ -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>;
|
||||
|
||||
// =================================================================================================================
|
||||
|
|
Loading…
Add table
Reference in a new issue