mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core): implement TOTP verification routes (#6201)
* feat(core): implmenent totp verification routes implement totp verification routes * fix(core): update comments update comments
This commit is contained in:
parent
223d7d6bbf
commit
be410acbbb
12 changed files with 569 additions and 8 deletions
|
@ -84,6 +84,10 @@ export default class ExperienceInteraction {
|
|||
}
|
||||
}
|
||||
|
||||
get identifiedUserId() {
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
/** Set the interaction event for the current interaction */
|
||||
public setInteractionEvent(interactionEvent: InteractionEvent) {
|
||||
// TODO: conflict event check (e.g. reset password session can't be used for sign in)
|
||||
|
@ -145,6 +149,10 @@ export default class ExperienceInteraction {
|
|||
this.userId = id;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Unsupported verification type for identification, such as MFA verification.
|
||||
throw new RequestError({ code: 'session.verification_failed', status: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -24,12 +24,18 @@ import {
|
|||
socialVerificationRecordDataGuard,
|
||||
type SocialVerificationRecordData,
|
||||
} from './social-verification.js';
|
||||
import {
|
||||
TotpVerification,
|
||||
totpVerificationRecordDataGuard,
|
||||
type TotpVerificationRecordData,
|
||||
} from './totp-verification.js';
|
||||
|
||||
export type VerificationRecordData =
|
||||
| PasswordVerificationRecordData
|
||||
| CodeVerificationRecordData
|
||||
| SocialVerificationRecordData
|
||||
| EnterpriseSsoVerificationRecordData;
|
||||
| EnterpriseSsoVerificationRecordData
|
||||
| TotpVerificationRecordData;
|
||||
|
||||
/**
|
||||
* Union type for all verification record types
|
||||
|
@ -43,13 +49,15 @@ export type VerificationRecord =
|
|||
| PasswordVerification
|
||||
| CodeVerification
|
||||
| SocialVerification
|
||||
| EnterpriseSsoVerification;
|
||||
| EnterpriseSsoVerification
|
||||
| TotpVerification;
|
||||
|
||||
export const verificationRecordDataGuard = z.discriminatedUnion('type', [
|
||||
passwordVerificationRecordDataGuard,
|
||||
codeVerificationRecordDataGuard,
|
||||
socialVerificationRecordDataGuard,
|
||||
enterPriseSsoVerificationRecordDataGuard,
|
||||
totpVerificationRecordDataGuard,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
@ -73,5 +81,8 @@ export const buildVerificationRecord = (
|
|||
case VerificationType.EnterpriseSso: {
|
||||
return new EnterpriseSsoVerification(libraries, queries, data);
|
||||
}
|
||||
case VerificationType.TOTP: {
|
||||
return new TotpVerification(libraries, queries, data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,183 @@
|
|||
import { type ToZodObject } from '@logto/connector-kit';
|
||||
import { MfaFactor, VerificationType, type MfaVerificationTotp, type User } from '@logto/schemas';
|
||||
import { generateStandardId, getUserDisplayName } from '@logto/shared';
|
||||
import { authenticator } from 'otplib';
|
||||
import qrcode from 'qrcode';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||
import {
|
||||
generateTotpSecret,
|
||||
validateTotpToken,
|
||||
} from '#src/routes/interaction/utils/totp-validation.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 { type VerificationRecord } from './verification-record.js';
|
||||
|
||||
const defaultDisplayName = 'Unnamed User';
|
||||
|
||||
// Type assertion for the user's TOTP mfa verification settings
|
||||
const findUserTotp = (
|
||||
mfaVerifications: User['mfaVerifications']
|
||||
): MfaVerificationTotp | undefined =>
|
||||
mfaVerifications.find((mfa): mfa is MfaVerificationTotp => mfa.type === MfaFactor.TOTP);
|
||||
|
||||
export type TotpVerificationRecordData = {
|
||||
id: string;
|
||||
type: VerificationType.TOTP;
|
||||
/** UserId is required for verifying or binding new TOTP */
|
||||
userId: string;
|
||||
secret?: string;
|
||||
verified: boolean;
|
||||
};
|
||||
|
||||
export const totpVerificationRecordDataGuard = z.object({
|
||||
id: z.string(),
|
||||
type: z.literal(VerificationType.TOTP),
|
||||
userId: z.string(),
|
||||
secret: z.string().optional(),
|
||||
verified: z.boolean(),
|
||||
}) satisfies ToZodObject<TotpVerificationRecordData>;
|
||||
|
||||
export class TotpVerification implements VerificationRecord<VerificationType.TOTP> {
|
||||
/**
|
||||
* Factory method to create a new TotpVerification instance
|
||||
*
|
||||
* @param userId The user id is required for verifying or binding new TOTP.
|
||||
* A TotpVerification instance can only be created if the interaction is identified.
|
||||
*/
|
||||
static create(libraries: Libraries, queries: Queries, userId: string) {
|
||||
return new TotpVerification(libraries, queries, {
|
||||
id: generateStandardId(),
|
||||
type: VerificationType.TOTP,
|
||||
verified: false,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
public readonly id: string;
|
||||
public readonly type = VerificationType.TOTP;
|
||||
public readonly userId: string;
|
||||
private secret?: string;
|
||||
private verified: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly libraries: Libraries,
|
||||
private readonly queries: Queries,
|
||||
data: TotpVerificationRecordData
|
||||
) {
|
||||
const { id, userId, secret, verified } = totpVerificationRecordDataGuard.parse(data);
|
||||
|
||||
this.id = id;
|
||||
this.userId = userId;
|
||||
this.secret = secret;
|
||||
this.verified = verified;
|
||||
}
|
||||
|
||||
get isVerified() {
|
||||
return this.verified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new TOTP secret and QR code for the user.
|
||||
* The secret will be stored in the instance and can be used for verifying the TOTP.
|
||||
*
|
||||
* @returns The TOTP secret and QR code as a base64 encoded image.
|
||||
*/
|
||||
async generateNewSecret(ctx: WithLogContext): Promise<{ secret: string; secretQrCode: string }> {
|
||||
this.secret = generateTotpSecret();
|
||||
|
||||
const { hostname } = ctx.URL;
|
||||
const secretQrCode = await this.generateSecretQrCode(hostname);
|
||||
|
||||
return {
|
||||
secret: this.secret,
|
||||
secretQrCode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the new created TOTP secret.
|
||||
*
|
||||
* @throws RequestError with 400, if the TOTP secret is not found in the current record or the code is invalid.
|
||||
*/
|
||||
verifyNewTotpSecret(code: string) {
|
||||
assertThat(
|
||||
this.secret && validateTotpToken(this.secret, code),
|
||||
'session.mfa.invalid_totp_code'
|
||||
);
|
||||
|
||||
this.verified = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the user's existing TOTP secret.
|
||||
*
|
||||
* @throws RequestError with 400, if the TOTP secret is not found or the code is invalid.
|
||||
*/
|
||||
async verifyUserExistingTotp(code: string) {
|
||||
const {
|
||||
users: { findUserById, updateUserById },
|
||||
} = this.queries;
|
||||
|
||||
const { mfaVerifications } = await findUserById(this.userId);
|
||||
|
||||
const totpVerification = findUserTotp(mfaVerifications);
|
||||
|
||||
// Can not found totp verification, this is an invalid request, throw invalid code error anyway for security reason
|
||||
assertThat(totpVerification, 'session.mfa.invalid_totp_code');
|
||||
|
||||
assertThat(validateTotpToken(totpVerification.key, code), 'session.mfa.invalid_totp_code');
|
||||
|
||||
this.verified = true;
|
||||
|
||||
// Update last used time
|
||||
await updateUserById(this.userId, {
|
||||
mfaVerifications: mfaVerifications.map((mfa) => {
|
||||
if (mfa.id !== totpVerification.id) {
|
||||
return mfa;
|
||||
}
|
||||
|
||||
return {
|
||||
...mfa,
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
toJson(): TotpVerificationRecordData {
|
||||
const { id, type, secret, verified, userId } = this;
|
||||
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
userId,
|
||||
secret,
|
||||
verified,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The QR code is generated using the secret, request hostname, and user information.
|
||||
* The QR code can be used to bind the TOTP secret to the user's authenticator app.
|
||||
* The QR code is returned as a base64 encoded image.
|
||||
*/
|
||||
private async generateSecretQrCode(service: string) {
|
||||
const { secret, userId } = this;
|
||||
|
||||
const {
|
||||
users: { findUserById },
|
||||
} = this.queries;
|
||||
|
||||
assertThat(secret, 'session.mfa.pending_info_not_found');
|
||||
|
||||
const { username, primaryEmail, primaryPhone, name } = await findUserById(userId);
|
||||
const displayName = getUserDisplayName({ username, primaryEmail, primaryPhone, name });
|
||||
const keyUri = authenticator.keyuri(displayName ?? defaultDisplayName, service, secret);
|
||||
|
||||
return qrcode.toDataURL(keyUri);
|
||||
}
|
||||
}
|
|
@ -26,6 +26,7 @@ import koaExperienceInteraction, {
|
|||
import enterpriseSsoVerificationRoutes from './verification-routes/enterprise-sso-verification.js';
|
||||
import passwordVerificationRoutes from './verification-routes/password-verification.js';
|
||||
import socialVerificationRoutes from './verification-routes/social-verification.js';
|
||||
import totpVerificationRoutes from './verification-routes/totp-verification.js';
|
||||
import verificationCodeRoutes from './verification-routes/verification-code.js';
|
||||
|
||||
type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
|
||||
|
@ -84,4 +85,5 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
|
|||
verificationCodeRoutes(router, tenant);
|
||||
socialVerificationRoutes(router, tenant);
|
||||
enterpriseSsoVerificationRoutes(router, tenant);
|
||||
totpVerificationRoutes(router, tenant);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
import { VerificationType, totpVerificationVerifyPayloadGuard } 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 { TotpVerification } from '../classes/verifications/totp-verification.js';
|
||||
import { experienceRoutes } from '../const.js';
|
||||
import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js';
|
||||
|
||||
export default function totpVerificationRoutes<T extends WithLogContext>(
|
||||
router: Router<unknown, WithExperienceInteractionContext<T>>,
|
||||
tenantContext: TenantContext
|
||||
) {
|
||||
const { libraries, queries } = tenantContext;
|
||||
|
||||
router.post(
|
||||
`${experienceRoutes.verification}/totp/secret`,
|
||||
koaGuard({
|
||||
response: z.object({
|
||||
verificationId: z.string(),
|
||||
secret: z.string(),
|
||||
secretQrCode: z.string(),
|
||||
}),
|
||||
status: [200, 400, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { experienceInteraction } = ctx;
|
||||
|
||||
assertThat(experienceInteraction.identifiedUserId, 'session.not_identified');
|
||||
|
||||
// TODO: Check if the MFA is enabled
|
||||
// TODO: Check if the interaction is fully verified
|
||||
|
||||
const totpVerification = TotpVerification.create(
|
||||
libraries,
|
||||
queries,
|
||||
experienceInteraction.identifiedUserId
|
||||
);
|
||||
|
||||
const { secret, secretQrCode } = await totpVerification.generateNewSecret(ctx);
|
||||
|
||||
ctx.experienceInteraction.setVerificationRecord(totpVerification);
|
||||
|
||||
await ctx.experienceInteraction.save();
|
||||
|
||||
ctx.body = {
|
||||
verificationId: totpVerification.id,
|
||||
secret,
|
||||
secretQrCode,
|
||||
};
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
`${experienceRoutes.verification}/totp/verify`,
|
||||
koaGuard({
|
||||
body: totpVerificationVerifyPayloadGuard,
|
||||
response: z.object({
|
||||
verificationId: z.string(),
|
||||
}),
|
||||
status: [200, 400, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { experienceInteraction } = ctx;
|
||||
const { verificationId, code } = ctx.guard.body;
|
||||
|
||||
assertThat(experienceInteraction.identifiedUserId, 'session.not_identified');
|
||||
|
||||
// Verify new generated secret
|
||||
if (verificationId) {
|
||||
const totpVerificationRecord =
|
||||
experienceInteraction.getVerificationRecordById(verificationId);
|
||||
|
||||
assertThat(
|
||||
totpVerificationRecord &&
|
||||
totpVerificationRecord.type === VerificationType.TOTP &&
|
||||
totpVerificationRecord.userId === experienceInteraction.identifiedUserId,
|
||||
new RequestError({
|
||||
code: 'session.verification_session_not_found',
|
||||
status: 404,
|
||||
})
|
||||
);
|
||||
|
||||
totpVerificationRecord.verifyNewTotpSecret(code);
|
||||
|
||||
await ctx.experienceInteraction.save();
|
||||
|
||||
ctx.body = {
|
||||
verificationId: totpVerificationRecord.id,
|
||||
};
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
// Verify existing totp record
|
||||
const totpVerificationRecord = TotpVerification.create(
|
||||
libraries,
|
||||
queries,
|
||||
experienceInteraction.identifiedUserId
|
||||
);
|
||||
|
||||
await totpVerificationRecord.verifyUserExistingTotp(code);
|
||||
|
||||
ctx.experienceInteraction.setVerificationRecord(totpVerificationRecord);
|
||||
|
||||
await ctx.experienceInteraction.save();
|
||||
|
||||
ctx.body = {
|
||||
verificationId: totpVerificationRecord.id,
|
||||
};
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -2,8 +2,8 @@ import {
|
|||
InteractionEvent,
|
||||
MfaFactor,
|
||||
requestVerificationCodePayloadGuard,
|
||||
webAuthnRegistrationOptionsGuard,
|
||||
webAuthnAuthenticationOptionsGuard,
|
||||
webAuthnRegistrationOptionsGuard,
|
||||
} from '@logto/schemas';
|
||||
import { getUserDisplayName } from '@logto/shared';
|
||||
import type Router from 'koa-router';
|
||||
|
|
|
@ -132,4 +132,21 @@ export class ExperienceClient extends MockClient {
|
|||
})
|
||||
.json<{ verificationId: string }>();
|
||||
}
|
||||
|
||||
public async createTotpSecret() {
|
||||
return api
|
||||
.post(`${experienceRoutes.verification}/totp/secret`, {
|
||||
headers: { cookie: this.interactionCookie },
|
||||
})
|
||||
.json<{ verificationId: string; secret: string; secretQrCode: string }>();
|
||||
}
|
||||
|
||||
public async verifyTotp(payload: { verificationId?: string; code: string }) {
|
||||
return api
|
||||
.post(`${experienceRoutes.verification}/totp/verify`, {
|
||||
headers: { cookie: this.interactionCookie },
|
||||
json: payload,
|
||||
})
|
||||
.json<{ verificationId: string }>();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import { type ExperienceClient } from '#src/client/experience/index.js';
|
||||
|
||||
export const successFullyCreateNewTotpSecret = async (client: ExperienceClient) => {
|
||||
const { secret, secretQrCode, verificationId } = await client.createTotpSecret();
|
||||
|
||||
expect(secret).toBeTruthy();
|
||||
expect(secretQrCode).toBeTruthy();
|
||||
expect(verificationId).toBeTruthy();
|
||||
|
||||
return { secret, secretQrCode, verificationId };
|
||||
};
|
||||
|
||||
export const successFullyVerifyTotp = async (
|
||||
client: ExperienceClient,
|
||||
payload: {
|
||||
code: string;
|
||||
verificationId?: string;
|
||||
}
|
||||
) => {
|
||||
const { verificationId } = await client.verifyTotp(payload);
|
||||
|
||||
expect(verificationId).toBeTruthy();
|
||||
|
||||
return verificationId;
|
||||
};
|
|
@ -0,0 +1,181 @@
|
|||
import { InteractionEvent, InteractionIdentifierType, MfaFactor } from '@logto/schemas';
|
||||
import { authenticator } from 'otplib';
|
||||
|
||||
import { createUserMfaVerification } from '#src/api/admin-user.js';
|
||||
import { type ExperienceClient } from '#src/client/experience/index.js';
|
||||
import { initExperienceClient } from '#src/helpers/client.js';
|
||||
import {
|
||||
successFullyCreateNewTotpSecret,
|
||||
successFullyVerifyTotp,
|
||||
} from '#src/helpers/experience/totp-verification.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
|
||||
import { UserApiTest, generateNewUserProfile } from '#src/helpers/user.js';
|
||||
import { devFeatureTest } from '#src/utils.js';
|
||||
|
||||
const identifyUserWithPassword = async (
|
||||
client: ExperienceClient,
|
||||
username: string,
|
||||
password: string
|
||||
) => {
|
||||
const { verificationId } = await client.verifyPassword({
|
||||
identifier: {
|
||||
type: InteractionIdentifierType.Username,
|
||||
value: username,
|
||||
},
|
||||
password,
|
||||
});
|
||||
|
||||
await client.identifyUser({ interactionEvent: InteractionEvent.SignIn, verificationId });
|
||||
|
||||
return { verificationId };
|
||||
};
|
||||
|
||||
devFeatureTest.describe('TOTP verification APIs', () => {
|
||||
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||
const userApi = new UserApiTest();
|
||||
|
||||
beforeAll(async () => {
|
||||
await enableAllPasswordSignInMethods();
|
||||
await userApi.create({ username, password });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userApi.cleanUp();
|
||||
});
|
||||
|
||||
describe('Create new TOTP secret', () => {
|
||||
it('should throw 400 if the user is not identified', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await expectRejects(client.createTotpSecret(), {
|
||||
code: 'session.not_identified',
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a new TOTP secret successfully', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await identifyUserWithPassword(client, username, password);
|
||||
|
||||
await successFullyCreateNewTotpSecret(client);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verify new TOTP secret', () => {
|
||||
it('should throw 400 if the user is not identified', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await expectRejects(client.verifyTotp({ code: '1234' }), {
|
||||
code: 'session.not_identified',
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 400 if the verification record is not found', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await identifyUserWithPassword(client, username, password);
|
||||
|
||||
await expectRejects(client.verifyTotp({ code: '1234', verificationId: 'invalid_id' }), {
|
||||
code: 'session.verification_session_not_found',
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 400 if the verification record is not a TOTP verification', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
const { verificationId } = await identifyUserWithPassword(client, username, password);
|
||||
|
||||
await expectRejects(client.verifyTotp({ code: '1234', verificationId }), {
|
||||
code: 'session.verification_session_not_found',
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 400 if the code is invalid', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await identifyUserWithPassword(client, username, password);
|
||||
|
||||
const { verificationId } = await successFullyCreateNewTotpSecret(client);
|
||||
|
||||
await expectRejects(client.verifyTotp({ code: '1234', verificationId }), {
|
||||
code: 'session.mfa.invalid_totp_code',
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should verify the new TOTP secret successfully', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await identifyUserWithPassword(client, username, password);
|
||||
|
||||
const { verificationId, secret } = await successFullyCreateNewTotpSecret(client);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
await successFullyVerifyTotp(client, { code, verificationId });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Verify existing TOTP secret', () => {
|
||||
it('should throw 400 if the user is not identified', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await expectRejects(client.verifyTotp({ code: '1234' }), {
|
||||
code: 'session.not_identified',
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 400 if the user does not have TOTP verification', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await identifyUserWithPassword(client, username, password);
|
||||
|
||||
await expectRejects(client.verifyTotp({ code: '1234' }), {
|
||||
code: 'session.mfa.invalid_totp_code',
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 400 if the code is invalid', async () => {
|
||||
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||
const user = await userApi.create({ username, password });
|
||||
|
||||
await createUserMfaVerification(user.id, MfaFactor.TOTP);
|
||||
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await identifyUserWithPassword(client, username, password);
|
||||
|
||||
await expectRejects(client.verifyTotp({ code: '1234' }), {
|
||||
code: 'session.mfa.invalid_totp_code',
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should verify the existing TOTP secret successfully', async () => {
|
||||
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||
const user = await userApi.create({ username, password });
|
||||
|
||||
const response = await createUserMfaVerification(user.id, MfaFactor.TOTP);
|
||||
|
||||
if (response.type !== MfaFactor.TOTP) {
|
||||
throw new Error('Invalid response');
|
||||
}
|
||||
|
||||
const { secret } = response;
|
||||
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await identifyUserWithPassword(client, username, password);
|
||||
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
await successFullyVerifyTotp(client, { code });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -2,14 +2,14 @@ import { InteractionEvent, MfaFactor, SignInIdentifier } from '@logto/schemas';
|
|||
import { authenticator } from 'otplib';
|
||||
|
||||
import {
|
||||
putInteraction,
|
||||
deleteUser,
|
||||
initTotp,
|
||||
postInteractionBindMfa,
|
||||
putInteraction,
|
||||
putInteractionMfa,
|
||||
skipMfaBinding,
|
||||
} from '#src/api/index.js';
|
||||
import { initClient, processSession, logoutClient } from '#src/helpers/client.js';
|
||||
import { initClient, logoutClient, processSession } from '#src/helpers/client.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import {
|
||||
enableAllPasswordSignInMethods,
|
||||
|
|
|
@ -23,6 +23,7 @@ const session = {
|
|||
interaction_not_found:
|
||||
'Interaction session not found. Please go back and start the session again.',
|
||||
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
|
||||
not_identified: 'User not identified. Please sign in first.',
|
||||
identity_conflict:
|
||||
'Identity mismatch detected. Please initiate a new session to proceed with a different identity.',
|
||||
mfa: {
|
||||
|
|
|
@ -65,7 +65,7 @@ export enum VerificationType {
|
|||
|
||||
// REMARK: API payload guard
|
||||
|
||||
/** Payload type for `POST /api/experience/verification/social/:connectorId/authorization-uri`. */
|
||||
/** Payload type for `POST /api/experience/verification/{social|sso}/:connectorId/authorization-uri`. */
|
||||
export type SocialAuthorizationUrlPayload = {
|
||||
state: string;
|
||||
redirectUri: string;
|
||||
|
@ -75,7 +75,7 @@ export const socialAuthorizationUrlPayloadGuard = z.object({
|
|||
redirectUri: z.string(),
|
||||
}) satisfies ToZodObject<SocialAuthorizationUrlPayload>;
|
||||
|
||||
/** Payload type for `POST /api/experience/verification/social/:connectorId/verify`. */
|
||||
/** Payload type for `POST /api/experience/verification/{social|sso}/:connectorId/verify`. */
|
||||
export type SocialVerificationCallbackPayload = {
|
||||
/** The callback data from the social connector. */
|
||||
connectorData: Record<string, unknown>;
|
||||
|
@ -92,12 +92,21 @@ export type PasswordVerificationPayload = {
|
|||
identifier: InteractionIdentifier;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export const passwordVerificationPayloadGuard = z.object({
|
||||
identifier: interactionIdentifierGuard,
|
||||
password: z.string().min(1),
|
||||
}) satisfies ToZodObject<PasswordVerificationPayload>;
|
||||
|
||||
/** Payload type for `POST /api/experience/verification/totp/verify`. */
|
||||
export type TotpVerificationVerifyPayload = {
|
||||
code: string;
|
||||
verificationId?: string;
|
||||
};
|
||||
export const totpVerificationVerifyPayloadGuard = z.object({
|
||||
code: z.string().min(1),
|
||||
verificationId: z.string().optional(),
|
||||
}) satisfies ToZodObject<TotpVerificationVerifyPayload>;
|
||||
|
||||
/** Payload type for `POST /api/experience/identification`. */
|
||||
export type IdentificationApiPayload = {
|
||||
interactionEvent: InteractionEvent;
|
||||
|
@ -257,8 +266,10 @@ export const bindMfaPayloadGuard = z.discriminatedUnion('type', [
|
|||
|
||||
export type BindMfaPayload = z.infer<typeof bindMfaPayloadGuard>;
|
||||
|
||||
/** @deprecated Legacy interaction API use only */
|
||||
export const totpVerificationPayloadGuard = bindTotpPayloadGuard;
|
||||
|
||||
/** @deprecated Legacy interaction API use only */
|
||||
export type TotpVerificationPayload = z.infer<typeof totpVerificationPayloadGuard>;
|
||||
|
||||
export const webAuthnVerificationPayloadGuard = bindWebAuthnPayloadGuard
|
||||
|
|
Loading…
Reference in a new issue