0
Fork 0
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:
simeng-li 2024-07-10 19:14:51 +08:00 committed by GitHub
parent 223d7d6bbf
commit be410acbbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 569 additions and 8 deletions

View file

@ -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 });
}
}
}

View file

@ -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);
}
}
};

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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();
}
);
}

View file

@ -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';

View file

@ -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 }>();
}
}

View file

@ -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;
};

View file

@ -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 });
});
});
});

View file

@ -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,

View file

@ -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: {

View file

@ -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