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 */
|
/** Set the interaction event for the current interaction */
|
||||||
public setInteractionEvent(interactionEvent: InteractionEvent) {
|
public setInteractionEvent(interactionEvent: InteractionEvent) {
|
||||||
// TODO: conflict event check (e.g. reset password session can't be used for sign in)
|
// 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;
|
this.userId = id;
|
||||||
break;
|
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,
|
socialVerificationRecordDataGuard,
|
||||||
type SocialVerificationRecordData,
|
type SocialVerificationRecordData,
|
||||||
} from './social-verification.js';
|
} from './social-verification.js';
|
||||||
|
import {
|
||||||
|
TotpVerification,
|
||||||
|
totpVerificationRecordDataGuard,
|
||||||
|
type TotpVerificationRecordData,
|
||||||
|
} from './totp-verification.js';
|
||||||
|
|
||||||
export type VerificationRecordData =
|
export type VerificationRecordData =
|
||||||
| PasswordVerificationRecordData
|
| PasswordVerificationRecordData
|
||||||
| CodeVerificationRecordData
|
| CodeVerificationRecordData
|
||||||
| SocialVerificationRecordData
|
| SocialVerificationRecordData
|
||||||
| EnterpriseSsoVerificationRecordData;
|
| EnterpriseSsoVerificationRecordData
|
||||||
|
| TotpVerificationRecordData;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Union type for all verification record types
|
* Union type for all verification record types
|
||||||
|
@ -43,13 +49,15 @@ export type VerificationRecord =
|
||||||
| PasswordVerification
|
| PasswordVerification
|
||||||
| CodeVerification
|
| CodeVerification
|
||||||
| SocialVerification
|
| SocialVerification
|
||||||
| EnterpriseSsoVerification;
|
| EnterpriseSsoVerification
|
||||||
|
| TotpVerification;
|
||||||
|
|
||||||
export const verificationRecordDataGuard = z.discriminatedUnion('type', [
|
export const verificationRecordDataGuard = z.discriminatedUnion('type', [
|
||||||
passwordVerificationRecordDataGuard,
|
passwordVerificationRecordDataGuard,
|
||||||
codeVerificationRecordDataGuard,
|
codeVerificationRecordDataGuard,
|
||||||
socialVerificationRecordDataGuard,
|
socialVerificationRecordDataGuard,
|
||||||
enterPriseSsoVerificationRecordDataGuard,
|
enterPriseSsoVerificationRecordDataGuard,
|
||||||
|
totpVerificationRecordDataGuard,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -73,5 +81,8 @@ export const buildVerificationRecord = (
|
||||||
case VerificationType.EnterpriseSso: {
|
case VerificationType.EnterpriseSso: {
|
||||||
return new EnterpriseSsoVerification(libraries, queries, data);
|
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 enterpriseSsoVerificationRoutes from './verification-routes/enterprise-sso-verification.js';
|
||||||
import passwordVerificationRoutes from './verification-routes/password-verification.js';
|
import passwordVerificationRoutes from './verification-routes/password-verification.js';
|
||||||
import socialVerificationRoutes from './verification-routes/social-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';
|
import verificationCodeRoutes from './verification-routes/verification-code.js';
|
||||||
|
|
||||||
type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
|
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);
|
verificationCodeRoutes(router, tenant);
|
||||||
socialVerificationRoutes(router, tenant);
|
socialVerificationRoutes(router, tenant);
|
||||||
enterpriseSsoVerificationRoutes(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,
|
InteractionEvent,
|
||||||
MfaFactor,
|
MfaFactor,
|
||||||
requestVerificationCodePayloadGuard,
|
requestVerificationCodePayloadGuard,
|
||||||
webAuthnRegistrationOptionsGuard,
|
|
||||||
webAuthnAuthenticationOptionsGuard,
|
webAuthnAuthenticationOptionsGuard,
|
||||||
|
webAuthnRegistrationOptionsGuard,
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import { getUserDisplayName } from '@logto/shared';
|
import { getUserDisplayName } from '@logto/shared';
|
||||||
import type Router from 'koa-router';
|
import type Router from 'koa-router';
|
||||||
|
|
|
@ -132,4 +132,21 @@ export class ExperienceClient extends MockClient {
|
||||||
})
|
})
|
||||||
.json<{ verificationId: string }>();
|
.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 { authenticator } from 'otplib';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
putInteraction,
|
|
||||||
deleteUser,
|
deleteUser,
|
||||||
initTotp,
|
initTotp,
|
||||||
postInteractionBindMfa,
|
postInteractionBindMfa,
|
||||||
|
putInteraction,
|
||||||
putInteractionMfa,
|
putInteractionMfa,
|
||||||
skipMfaBinding,
|
skipMfaBinding,
|
||||||
} from '#src/api/index.js';
|
} 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 { expectRejects } from '#src/helpers/index.js';
|
||||||
import {
|
import {
|
||||||
enableAllPasswordSignInMethods,
|
enableAllPasswordSignInMethods,
|
||||||
|
|
|
@ -23,6 +23,7 @@ const session = {
|
||||||
interaction_not_found:
|
interaction_not_found:
|
||||||
'Interaction session not found. Please go back and start the session again.',
|
'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_supported_for_forgot_password: 'This operation is not supported for forgot password.',
|
||||||
|
not_identified: 'User not identified. Please sign in first.',
|
||||||
identity_conflict:
|
identity_conflict:
|
||||||
'Identity mismatch detected. Please initiate a new session to proceed with a different identity.',
|
'Identity mismatch detected. Please initiate a new session to proceed with a different identity.',
|
||||||
mfa: {
|
mfa: {
|
||||||
|
|
|
@ -65,7 +65,7 @@ export enum VerificationType {
|
||||||
|
|
||||||
// REMARK: API payload guard
|
// 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 = {
|
export type SocialAuthorizationUrlPayload = {
|
||||||
state: string;
|
state: string;
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
|
@ -75,7 +75,7 @@ export const socialAuthorizationUrlPayloadGuard = z.object({
|
||||||
redirectUri: z.string(),
|
redirectUri: z.string(),
|
||||||
}) satisfies ToZodObject<SocialAuthorizationUrlPayload>;
|
}) 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 = {
|
export type SocialVerificationCallbackPayload = {
|
||||||
/** The callback data from the social connector. */
|
/** The callback data from the social connector. */
|
||||||
connectorData: Record<string, unknown>;
|
connectorData: Record<string, unknown>;
|
||||||
|
@ -92,12 +92,21 @@ export type PasswordVerificationPayload = {
|
||||||
identifier: InteractionIdentifier;
|
identifier: InteractionIdentifier;
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const passwordVerificationPayloadGuard = z.object({
|
export const passwordVerificationPayloadGuard = z.object({
|
||||||
identifier: interactionIdentifierGuard,
|
identifier: interactionIdentifierGuard,
|
||||||
password: z.string().min(1),
|
password: z.string().min(1),
|
||||||
}) satisfies ToZodObject<PasswordVerificationPayload>;
|
}) 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`. */
|
/** Payload type for `POST /api/experience/identification`. */
|
||||||
export type IdentificationApiPayload = {
|
export type IdentificationApiPayload = {
|
||||||
interactionEvent: InteractionEvent;
|
interactionEvent: InteractionEvent;
|
||||||
|
@ -257,8 +266,10 @@ export const bindMfaPayloadGuard = z.discriminatedUnion('type', [
|
||||||
|
|
||||||
export type BindMfaPayload = z.infer<typeof bindMfaPayloadGuard>;
|
export type BindMfaPayload = z.infer<typeof bindMfaPayloadGuard>;
|
||||||
|
|
||||||
|
/** @deprecated Legacy interaction API use only */
|
||||||
export const totpVerificationPayloadGuard = bindTotpPayloadGuard;
|
export const totpVerificationPayloadGuard = bindTotpPayloadGuard;
|
||||||
|
|
||||||
|
/** @deprecated Legacy interaction API use only */
|
||||||
export type TotpVerificationPayload = z.infer<typeof totpVerificationPayloadGuard>;
|
export type TotpVerificationPayload = z.infer<typeof totpVerificationPayloadGuard>;
|
||||||
|
|
||||||
export const webAuthnVerificationPayloadGuard = bindWebAuthnPayloadGuard
|
export const webAuthnVerificationPayloadGuard = bindWebAuthnPayloadGuard
|
||||||
|
|
Loading…
Reference in a new issue