Skip to content

Commit 7a37134

Browse files
authored
feat(core): add api for sending mfa verification code (#7684)
* feat(core): add api for sending mfa verification code * refactor(core): add mfa code verification class
1 parent 76f049d commit 7a37134

File tree

13 files changed

+674
-151
lines changed

13 files changed

+674
-151
lines changed

packages/core/src/routes/experience/classes/helpers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ export const identifyUserByVerificationRecord = async (
9999
switch (verificationRecord.type) {
100100
case VerificationType.Password:
101101
case VerificationType.EmailVerificationCode:
102-
case VerificationType.PhoneVerificationCode: {
102+
case VerificationType.PhoneVerificationCode:
103+
case VerificationType.MfaEmailVerificationCode:
104+
case VerificationType.MfaPhoneVerificationCode: {
103105
return {
104106
user: await verificationRecord.identifyUser(),
105107
};

packages/core/src/routes/experience/classes/libraries/mfa-validator.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import {
1111
import { getAllUserEnabledMfaVerifications } from '../helpers.js';
1212
import { type BackupCodeVerification } from '../verifications/backup-code-verification.js';
1313
import {
14-
type EmailCodeVerification,
15-
type PhoneCodeVerification,
14+
type MfaEmailCodeVerification,
15+
type MfaPhoneCodeVerification,
1616
} from '../verifications/code-verification.js';
1717
import { type VerificationRecord } from '../verifications/index.js';
1818
import { type TotpVerification } from '../verifications/totp-verification.js';
@@ -22,31 +22,31 @@ const mfaVerificationTypes = Object.freeze([
2222
VerificationType.TOTP,
2323
VerificationType.BackupCode,
2424
VerificationType.WebAuthn,
25-
VerificationType.EmailVerificationCode,
26-
VerificationType.PhoneVerificationCode,
25+
VerificationType.MfaEmailVerificationCode,
26+
VerificationType.MfaPhoneVerificationCode,
2727
]);
2828

2929
type MfaVerificationType =
3030
| VerificationType.TOTP
3131
| VerificationType.BackupCode
3232
| VerificationType.WebAuthn
33-
| VerificationType.EmailVerificationCode
34-
| VerificationType.PhoneVerificationCode;
33+
| VerificationType.MfaEmailVerificationCode
34+
| VerificationType.MfaPhoneVerificationCode;
3535

3636
const mfaVerificationTypeToMfaFactorMap = Object.freeze({
3737
[VerificationType.TOTP]: MfaFactor.TOTP,
3838
[VerificationType.BackupCode]: MfaFactor.BackupCode,
3939
[VerificationType.WebAuthn]: MfaFactor.WebAuthn,
40-
[VerificationType.EmailVerificationCode]: MfaFactor.EmailVerificationCode,
41-
[VerificationType.PhoneVerificationCode]: MfaFactor.PhoneVerificationCode,
40+
[VerificationType.MfaEmailVerificationCode]: MfaFactor.EmailVerificationCode,
41+
[VerificationType.MfaPhoneVerificationCode]: MfaFactor.PhoneVerificationCode,
4242
}) satisfies Record<MfaVerificationType, MfaFactor>;
4343

4444
type MfaVerificationRecord =
4545
| TotpVerification
4646
| WebAuthnVerification
4747
| BackupCodeVerification
48-
| EmailCodeVerification
49-
| PhoneCodeVerification;
48+
| MfaEmailCodeVerification
49+
| MfaPhoneCodeVerification;
5050

5151
const isMfaVerificationRecord = (
5252
verification: VerificationRecord

packages/core/src/routes/experience/classes/verifications/code-verification.ts

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ const getPasscodeIdentifierPayload = (
5252
type CodeVerificationIdentifierMap = {
5353
[VerificationType.EmailVerificationCode]: { primaryEmail: string };
5454
[VerificationType.PhoneVerificationCode]: { primaryPhone: string };
55+
[VerificationType.MfaEmailVerificationCode]: Record<string, unknown>;
56+
[VerificationType.MfaPhoneVerificationCode]: Record<string, unknown>;
5557
};
5658

5759
/**
@@ -88,17 +90,6 @@ abstract class CodeVerification<T extends CodeVerificationType>
8890
return this.verified;
8991
}
9092

91-
get isNewBindMfaVerification() {
92-
// For EmailCodeVerification and PhoneCodeVerification, the binding is always completed before submitting the interaction.
93-
// So this method always returns false.
94-
// So that it can be used right after the new Email/Phone is bound to the user.
95-
// The flow: user binds a new email/phone -> user info updated to the DB -> user submits the interaction
96-
// -> check user enabled MFA verifications -> the new email/phone is included in the enabled MFA verifications
97-
// -> but the user does not need to verify the new email/phone again -> reuse the verification record
98-
// So this verification record are used for the new bind MFA verification, and also can be used for the verification.
99-
return false;
100-
}
101-
10293
/**
10394
* Send the verification code to the current `identifier`
10495
*
@@ -242,6 +233,32 @@ export class PhoneCodeVerification extends CodeVerification<VerificationType.Pho
242233
}
243234
}
244235

236+
export class MfaEmailCodeVerification extends CodeVerification<VerificationType.MfaEmailVerificationCode> {
237+
public readonly type = VerificationType.MfaEmailVerificationCode;
238+
239+
toUserProfile(): Record<string, unknown> {
240+
return {};
241+
}
242+
243+
get isNewBindMfaVerification(): boolean {
244+
// This class is only used for MFA verification
245+
return false;
246+
}
247+
}
248+
249+
export class MfaPhoneCodeVerification extends CodeVerification<VerificationType.MfaPhoneVerificationCode> {
250+
public readonly type = VerificationType.MfaPhoneVerificationCode;
251+
252+
toUserProfile(): Record<string, unknown> {
253+
return {};
254+
}
255+
256+
get isNewBindMfaVerification(): boolean {
257+
// This class is only used for MFA verification
258+
return false;
259+
}
260+
}
261+
245262
/**
246263
* Factory method to create a new `EmailCodeVerification` / `PhoneCodeVerification` record using the given identifier.
247264
*/
@@ -276,3 +293,40 @@ export const createNewCodeVerificationRecord = (
276293
}
277294
}
278295
};
296+
297+
/**
298+
* Factory method to create a new `MfaEmailCodeVerification` / `MfaPhoneCodeVerification` record using the given identifier.
299+
*/
300+
export const createNewMfaCodeVerificationRecord = (
301+
libraries: Libraries,
302+
queries: Queries,
303+
identifier:
304+
| VerificationCodeIdentifier<SignInIdentifier.Email>
305+
| VerificationCodeIdentifier<SignInIdentifier.Phone>,
306+
verified = false
307+
): MfaEmailCodeVerification | MfaPhoneCodeVerification => {
308+
const { type } = identifier;
309+
310+
switch (type) {
311+
case SignInIdentifier.Email: {
312+
return new MfaEmailCodeVerification(libraries, queries, {
313+
id: generateStandardId(),
314+
type: VerificationType.MfaEmailVerificationCode,
315+
identifier,
316+
// TODO @wangsijie: replace to new template type
317+
templateType: TemplateType.SignIn,
318+
verified,
319+
});
320+
}
321+
case SignInIdentifier.Phone: {
322+
return new MfaPhoneCodeVerification(libraries, queries, {
323+
id: generateStandardId(),
324+
type: VerificationType.MfaPhoneVerificationCode,
325+
identifier,
326+
// TODO @wangsijie: replace to new template type
327+
templateType: TemplateType.SignIn,
328+
verified,
329+
});
330+
}
331+
}
332+
};

packages/core/src/routes/experience/classes/verifications/index.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { VerificationType } from '@logto/schemas';
1+
import {
2+
mfaEmailCodeVerificationRecordDataGuard,
3+
mfaPhoneCodeVerificationRecordDataGuard,
4+
VerificationType,
5+
} from '@logto/schemas';
26
import { z } from 'zod';
37

48
import type Libraries from '#src/tenants/Libraries.js';
@@ -16,6 +20,8 @@ import {
1620
emailCodeVerificationRecordDataGuard,
1721
PhoneCodeVerification,
1822
phoneCodeVerificationRecordDataGuard,
23+
MfaEmailCodeVerification,
24+
MfaPhoneCodeVerification,
1925
type CodeVerificationRecordData,
2026
} from './code-verification.js';
2127
import {
@@ -69,6 +75,8 @@ export type VerificationRecordData =
6975
| PasswordVerificationRecordData
7076
| CodeVerificationRecordData<VerificationType.EmailVerificationCode>
7177
| CodeVerificationRecordData<VerificationType.PhoneVerificationCode>
78+
| CodeVerificationRecordData<VerificationType.MfaEmailVerificationCode>
79+
| CodeVerificationRecordData<VerificationType.MfaPhoneVerificationCode>
7280
| SocialVerificationRecordData
7381
| EnterpriseSsoVerificationRecordData
7482
| TotpVerificationRecordData
@@ -81,6 +89,8 @@ export type SanitizedVerificationRecordData =
8189
| PasswordVerificationRecordData
8290
| CodeVerificationRecordData<VerificationType.EmailVerificationCode>
8391
| CodeVerificationRecordData<VerificationType.PhoneVerificationCode>
92+
| CodeVerificationRecordData<VerificationType.MfaEmailVerificationCode>
93+
| CodeVerificationRecordData<VerificationType.MfaPhoneVerificationCode>
8494
| SanitizedSocialVerificationRecordData
8595
| SanitizedEnterpriseSsoVerificationRecordData
8696
| SanitizedTotpVerificationRecordData
@@ -99,6 +109,8 @@ export type VerificationRecordMap = AssertVerificationMap<{
99109
[VerificationType.Password]: PasswordVerification;
100110
[VerificationType.EmailVerificationCode]: EmailCodeVerification;
101111
[VerificationType.PhoneVerificationCode]: PhoneCodeVerification;
112+
[VerificationType.MfaEmailVerificationCode]: MfaEmailCodeVerification;
113+
[VerificationType.MfaPhoneVerificationCode]: MfaPhoneCodeVerification;
102114
[VerificationType.Social]: SocialVerification;
103115
[VerificationType.EnterpriseSso]: EnterpriseSsoVerification;
104116
[VerificationType.TOTP]: TotpVerification;
@@ -123,6 +135,8 @@ export const verificationRecordDataGuard = z.discriminatedUnion('type', [
123135
passwordVerificationRecordDataGuard,
124136
emailCodeVerificationRecordDataGuard,
125137
phoneCodeVerificationRecordDataGuard,
138+
mfaEmailCodeVerificationRecordDataGuard,
139+
mfaPhoneCodeVerificationRecordDataGuard,
126140
socialVerificationRecordDataGuard,
127141
enterpriseSsoVerificationRecordDataGuard,
128142
totpVerificationRecordDataGuard,
@@ -136,6 +150,8 @@ export const publicVerificationRecordDataGuard = z.discriminatedUnion('type', [
136150
passwordVerificationRecordDataGuard,
137151
emailCodeVerificationRecordDataGuard,
138152
phoneCodeVerificationRecordDataGuard,
153+
mfaEmailCodeVerificationRecordDataGuard,
154+
mfaPhoneCodeVerificationRecordDataGuard,
139155
sanitizedSocialVerificationRecordDataGuard,
140156
sanitizedEnterpriseSsoVerificationRecordDataGuard,
141157
sanitizedTotpVerificationRecordDataGuard,
@@ -148,6 +164,7 @@ export const publicVerificationRecordDataGuard = z.discriminatedUnion('type', [
148164
/**
149165
* The factory method to build a new `VerificationRecord` instance based on the provided `VerificationRecordData`.
150166
*/
167+
// eslint-disable-next-line complexity
151168
export const buildVerificationRecord = (
152169
libraries: Libraries,
153170
queries: Queries,
@@ -163,6 +180,12 @@ export const buildVerificationRecord = (
163180
case VerificationType.PhoneVerificationCode: {
164181
return new PhoneCodeVerification(libraries, queries, data);
165182
}
183+
case VerificationType.MfaEmailVerificationCode: {
184+
return new MfaEmailCodeVerification(libraries, queries, data);
185+
}
186+
case VerificationType.MfaPhoneVerificationCode: {
187+
return new MfaPhoneCodeVerification(libraries, queries, data);
188+
}
166189
case VerificationType.Social: {
167190
return new SocialVerification(libraries, queries, data);
168191
}

packages/core/src/routes/experience/profile-routes.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import assertThat from '#src/utils/assert-that.js';
1616

1717
import { EnvSet } from '../../env-set/index.js';
1818

19+
import { createNewMfaCodeVerificationRecord } from './classes/verifications/code-verification.js';
1920
import { experienceRoutes } from './const.js';
2021
import { type ExperienceInteractionRouterContext } from './types.js';
2122

@@ -51,7 +52,7 @@ function verifiedInteractionGuard<
5152

5253
export default function interactionProfileRoutes<T extends ExperienceInteractionRouterContext>(
5354
router: Router<unknown, T>,
54-
tenant: TenantContext
55+
{ libraries, queries }: TenantContext
5556
) {
5657
router.post(
5758
`${experienceRoutes.profile}`,
@@ -228,6 +229,21 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
228229
verificationId,
229230
log
230231
);
232+
const { primaryEmail } = experienceInteraction.profile.data;
233+
// If the primary email is set, create a new MFA code verification record
234+
// to bypass the MFA verification step.
235+
if (primaryEmail) {
236+
const codeVerification = createNewMfaCodeVerificationRecord(
237+
libraries,
238+
queries,
239+
{
240+
type: SignInIdentifier.Email,
241+
value: primaryEmail,
242+
},
243+
true
244+
);
245+
experienceInteraction.setVerificationRecord(codeVerification);
246+
}
231247
break;
232248
}
233249
case MfaFactor.PhoneVerificationCode: {
@@ -240,6 +256,21 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
240256
verificationId,
241257
log
242258
);
259+
const { primaryPhone } = experienceInteraction.profile.data;
260+
// If the primary phone is set, create a new MFA code verification record
261+
// to bypass the MFA verification step.
262+
if (primaryPhone) {
263+
const codeVerification = createNewMfaCodeVerificationRecord(
264+
libraries,
265+
queries,
266+
{
267+
type: SignInIdentifier.Phone,
268+
value: primaryPhone,
269+
},
270+
true
271+
);
272+
experienceInteraction.setVerificationRecord(codeVerification);
273+
}
243274
break;
244275
}
245276
}

0 commit comments

Comments
 (0)