-
-
Notifications
You must be signed in to change notification settings - Fork 461
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): implement verification code verification API (#6001)
* feat(core,schemas): implement the verification code flow implement the verification code flow * chore(core): fix rebase issue fix rebase issue
- Loading branch information
Showing
11 changed files
with
685 additions
and
8 deletions.
There are no files selected for viewing
195 changes: 195 additions & 0 deletions
195
packages/core/src/routes/experience/classes/verifications/code-verification.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,195 @@ | ||
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 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 { type VerificationRecord } from './verification-record.js'; | ||
|
||
const eventToTemplateTypeMap: Record<InteractionEvent, TemplateType> = { | ||
SignIn: TemplateType.SignIn, | ||
Register: TemplateType.Register, | ||
ForgotPassword: TemplateType.ForgotPassword, | ||
}; | ||
|
||
/** | ||
* 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 getTemplateTypeByEvent = (event: InteractionEvent): TemplateType => | ||
eventToTemplateTypeMap[event]; | ||
|
||
/** The JSON data type for the CodeVerification record */ | ||
export type CodeVerificationRecordData = { | ||
id: string; | ||
type: VerificationType.VerificationCode; | ||
identifier: VerificationCodeIdentifier; | ||
interactionEvent: InteractionEvent; | ||
userId?: string; | ||
verified: boolean; | ||
}; | ||
|
||
export const codeVerificationRecordDataGuard = 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<CodeVerificationRecordData>; | ||
|
||
/** This util method convert the interaction identifier to passcode library payload format */ | ||
const getPasscodeIdentifierPayload = ( | ||
identifier: VerificationCodeIdentifier | ||
): Parameters<ReturnType<typeof createPasscodeLibrary>['createPasscode']>[2] => | ||
identifier.type === 'email' ? { email: identifier.value } : { phone: identifier.value }; | ||
|
||
/** | ||
* CodeVerification is a verification type that verifies a given identifier by sending a verification code | ||
* to the user's email or phone. | ||
* | ||
* @remark The verification code is sent to the user's email or phone 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. | ||
* | ||
* To avoid the redundant naming, the `CodeVerification` is used instead of `VerificationCodeVerification`. | ||
*/ | ||
export class CodeVerification implements VerificationRecord<VerificationType.VerificationCode> { | ||
/** | ||
* Factory method to create a new CodeVerification record using the given identifier. | ||
* The sendVerificationCode method will be automatically triggered. | ||
*/ | ||
static async create( | ||
libraries: Libraries, | ||
queries: Queries, | ||
identifier: VerificationCodeIdentifier, | ||
interactionEvent: InteractionEvent | ||
) { | ||
const record = new CodeVerification(libraries, queries, { | ||
id: generateStandardId(), | ||
type: VerificationType.VerificationCode, | ||
identifier, | ||
interactionEvent, | ||
verified: false, | ||
}); | ||
|
||
await record.sendVerificationCode(); | ||
|
||
return record; | ||
} | ||
|
||
public readonly id: string; | ||
public readonly type = VerificationType.VerificationCode; | ||
public readonly 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. | ||
*/ | ||
private readonly interactionEvent: InteractionEvent; | ||
/** The userId will be set after the verification if the identifier matches any existing user's record */ | ||
private userId?: string; | ||
private verified: boolean; | ||
|
||
constructor( | ||
private readonly libraries: Libraries, | ||
private readonly queries: Queries, | ||
data: CodeVerificationRecordData | ||
) { | ||
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 the identifier has been verified by a given code */ | ||
get isVerified() { | ||
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 | ||
* | ||
* @remark The identifier and code will be verified in the passcode library. | ||
* No need to verify the identifier before calling this method. | ||
* | ||
* - `isVerified` will be set to true if the code is verified successfully. | ||
* - `verifiedUserId` will be set if the `identifier` matches any existing user's record. | ||
*/ | ||
async verify(identifier: VerificationCodeIdentifier, code?: string) { | ||
// 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, | ||
getPasscodeIdentifierPayload(identifier) | ||
); | ||
|
||
this.verified = true; | ||
|
||
// Try to lookup the user by the identifier | ||
const user = await findUserByIdentifier(this.queries.users, this.identifier); | ||
this.userId = user?.id; | ||
} | ||
|
||
toJson(): CodeVerificationRecordData { | ||
return { | ||
id: this.id, | ||
type: this.type, | ||
identifier: this.identifier, | ||
interactionEvent: this.interactionEvent, | ||
userId: this.userId, | ||
verified: this.verified, | ||
}; | ||
} | ||
|
||
/** | ||
* 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. | ||
*/ | ||
private async sendVerificationCode() { | ||
const { createPasscode, sendPasscode } = this.libraries.passcodes; | ||
|
||
const verificationCode = await createPasscode( | ||
this.id, | ||
getTemplateTypeByEvent(this.interactionEvent), | ||
getPasscodeIdentifierPayload(this.identifier) | ||
); | ||
|
||
await sendPasscode(verificationCode); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
96 changes: 96 additions & 0 deletions
96
packages/core/src/routes/experience/verification-routes/verification-code.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
import { | ||
InteractionEvent, | ||
VerificationType, | ||
verificationCodeIdentifierGuard, | ||
} from '@logto/schemas'; | ||
import type Router from 'koa-router'; | ||
import { z } from 'zod'; | ||
|
||
import RequestError from '#src/errors/RequestError/index.js'; | ||
import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; | ||
import koaGuard from '#src/middleware/koa-guard.js'; | ||
import type TenantContext from '#src/tenants/TenantContext.js'; | ||
import assertThat from '#src/utils/assert-that.js'; | ||
|
||
import { CodeVerification } from '../classes/verifications/code-verification.js'; | ||
import { experienceRoutes } from '../const.js'; | ||
import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; | ||
|
||
export default function verificationCodeRoutes<T extends WithLogContext>( | ||
router: Router<unknown, WithExperienceInteractionContext<T>>, | ||
{ libraries, queries }: TenantContext | ||
) { | ||
router.post( | ||
`${experienceRoutes.verification}/verification-code`, | ||
koaGuard({ | ||
body: z.object({ | ||
identifier: verificationCodeIdentifierGuard, | ||
interactionEvent: z.nativeEnum(InteractionEvent), | ||
}), | ||
response: z.object({ | ||
verificationId: z.string(), | ||
}), | ||
// 501: connector not found | ||
status: [200, 400, 404, 501], | ||
}), | ||
async (ctx, next) => { | ||
const { identifier, interactionEvent } = ctx.guard.body; | ||
|
||
const codeVerification = await CodeVerification.create( | ||
libraries, | ||
queries, | ||
identifier, | ||
interactionEvent | ||
); | ||
|
||
ctx.experienceInteraction.setVerificationRecord(codeVerification); | ||
|
||
await ctx.experienceInteraction.save(); | ||
|
||
ctx.body = { | ||
verificationId: codeVerification.id, | ||
}; | ||
|
||
await next(); | ||
} | ||
); | ||
|
||
router.post( | ||
`${experienceRoutes.verification}/verification-code/verify`, | ||
koaGuard({ | ||
body: z.object({ | ||
identifier: verificationCodeIdentifierGuard, | ||
verificationId: z.string(), | ||
code: z.string(), | ||
}), | ||
response: z.object({ | ||
verificationId: z.string(), | ||
}), | ||
// 501: connector not found | ||
status: [200, 400, 404, 501], | ||
}), | ||
async (ctx, next) => { | ||
const { verificationId, code, identifier } = ctx.guard.body; | ||
|
||
const codeVerificationRecord = | ||
ctx.experienceInteraction.getVerificationRecordById(verificationId); | ||
|
||
assertThat( | ||
codeVerificationRecord && | ||
// Make the Verification type checker happy | ||
codeVerificationRecord.type === VerificationType.VerificationCode, | ||
new RequestError({ code: 'session.verification_session_not_found', status: 404 }) | ||
); | ||
|
||
await codeVerificationRecord.verify(identifier, code); | ||
|
||
await ctx.experienceInteraction.save(); | ||
|
||
ctx.body = { | ||
verificationId: codeVerificationRecord.id, | ||
}; | ||
|
||
return next(); | ||
} | ||
); | ||
} |
Oops, something went wrong.