-
-
Notifications
You must be signed in to change notification settings - Fork 475
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): implement TOTP verification routes (#6201)
* feat(core): implmenent totp verification routes implement totp verification routes * fix(core): update comments update comments
- Loading branch information
Showing
12 changed files
with
569 additions
and
8 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
183 changes: 183 additions & 0 deletions
183
packages/core/src/routes/experience/classes/verifications/totp-verification.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
122 changes: 122 additions & 0 deletions
122
packages/core/src/routes/experience/verification-routes/totp-verification.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.