Skip to content

Commit

Permalink
feat(core): implement TOTP verification routes (#6201)
Browse files Browse the repository at this point in the history
* feat(core): implmenent totp verification routes

implement totp verification routes

* fix(core): update comments

update comments
  • Loading branch information
simeng-li authored Jul 10, 2024
1 parent 223d7d6 commit be410ac
Show file tree
Hide file tree
Showing 12 changed files with 569 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 });
}
}
}

Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/routes/experience/classes/verifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,13 +49,15 @@ export type VerificationRecord =
| PasswordVerification
| CodeVerification
| SocialVerification
| EnterpriseSsoVerification;
| EnterpriseSsoVerification
| TotpVerification;

export const verificationRecordDataGuard = z.discriminatedUnion('type', [
passwordVerificationRecordDataGuard,
codeVerificationRecordDataGuard,
socialVerificationRecordDataGuard,
enterPriseSsoVerificationRecordDataGuard,
totpVerificationRecordDataGuard,
]);

/**
Expand All @@ -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);
}
}
};
Original file line number Diff line number Diff line change
@@ -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);
}
}
2 changes: 2 additions & 0 deletions packages/core/src/routes/experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -84,4 +85,5 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
verificationCodeRoutes(router, tenant);
socialVerificationRoutes(router, tenant);
enterpriseSsoVerificationRoutes(router, tenant);
totpVerificationRoutes(router, tenant);
}
Original file line number Diff line number Diff line change
@@ -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 changes: 1 addition & 1 deletion packages/core/src/routes/interaction/additional.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading

0 comments on commit be410ac

Please sign in to comment.