0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(core): refactor backup code generates flow (#6339)

refactor(core): refactor backup code generate flow

refactor backup code generate flow
This commit is contained in:
simeng-li 2024-07-26 15:33:40 +08:00 committed by GitHub
parent 34c8bedef6
commit 05082b56a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 87 additions and 116 deletions

View file

@ -18,7 +18,6 @@ import { deduplicate } from '@silverhand/essentials';
import { z } from 'zod'; import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import { generateBackupCodes } from '#src/routes/interaction/utils/backup-code-validation.js';
import type Libraries from '#src/tenants/Libraries.js'; import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
@ -32,8 +31,6 @@ export type MfaData = {
totp?: BindTotp; totp?: BindTotp;
webAuthn?: BindWebAuthn[]; webAuthn?: BindWebAuthn[];
backupCode?: BindBackupCode; backupCode?: BindBackupCode;
/** The backup codes that have been generated but not yet added to the bindMfa queue */
pendingBackupCodes?: string[];
}; };
export const mfaDataGuard = z.object({ export const mfaDataGuard = z.object({
@ -41,7 +38,6 @@ export const mfaDataGuard = z.object({
totp: bindTotpGuard.optional(), totp: bindTotpGuard.optional(),
webAuthn: z.array(bindWebAuthnGuard).optional(), webAuthn: z.array(bindWebAuthnGuard).optional(),
backupCode: bindBackupCodeGuard.optional(), backupCode: bindBackupCodeGuard.optional(),
pendingBackupCodes: z.array(z.string()).optional(),
}) satisfies ToZodObject<MfaData>; }) satisfies ToZodObject<MfaData>;
export const userMfaDataKey = 'mfa'; export const userMfaDataKey = 'mfa';
@ -81,15 +77,6 @@ export class Mfa {
#totp?: BindTotp; #totp?: BindTotp;
#webAuthn?: BindWebAuthn[]; #webAuthn?: BindWebAuthn[];
#backupCode?: BindBackupCode; #backupCode?: BindBackupCode;
/**
* We split the backup codes binding flow into two steps:
* 1. Generate backup codes
* 2. Add backup codes
*
* This is to prevent the user may not receive the backup codes after generating them.
* User need to explicitly send the binding request to add the backup codes.
*/
#pendingBackupCodes?: string[];
constructor( constructor(
private readonly libraries: Libraries, private readonly libraries: Libraries,
@ -98,13 +85,12 @@ export class Mfa {
private readonly interactionContext: InteractionContext private readonly interactionContext: InteractionContext
) { ) {
this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries); this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries);
const { mfaSkipped, totp, webAuthn, backupCode, pendingBackupCodes } = data; const { mfaSkipped, totp, webAuthn, backupCode } = data;
this.#mfaSkipped = mfaSkipped; this.#mfaSkipped = mfaSkipped;
this.#totp = totp; this.#totp = totp;
this.#webAuthn = webAuthn; this.#webAuthn = webAuthn;
this.#backupCode = backupCode; this.#backupCode = backupCode;
this.#pendingBackupCodes = pendingBackupCodes;
} }
get mfaSkipped() { get mfaSkipped() {
@ -224,19 +210,25 @@ export class Mfa {
} }
/** /**
* Generates new backup codes for the user. * Add new backup codes to the user account.
* *
* - Any existing backup code factor will be replaced with the new one.
*
* @throws {RequestError} with status 404 if no pending backup codes are found
* @throws {RequestError} with status 422 if Backup Code is not enabled in the sign-in experience * @throws {RequestError} with status 422 if Backup Code is not enabled in the sign-in experience
* @throws {RequestError} with status 422 if the backup code is the only MFA factor * @throws {RequestError} with status 422 if the backup code is the only MFA factor
**/ */
async generateBackupCodes() { async addBackupCodeByVerificationId(verificationId: string) {
const verificationRecord = this.interactionContext.getVerificationRecordByTypeAndId(
VerificationType.BackupCode,
verificationId
);
await this.checkMfaFactorsEnabledInSignInExperience([MfaFactor.BackupCode]); await this.checkMfaFactorsEnabledInSignInExperience([MfaFactor.BackupCode]);
const { mfaVerifications } = await this.interactionContext.getIdentifierUser(); const { mfaVerifications } = await this.interactionContext.getIdentifierUser();
const userHasOtherMfa = mfaVerifications.some((mfa) => mfa.type !== MfaFactor.BackupCode); const userHasOtherMfa = mfaVerifications.some((mfa) => mfa.type !== MfaFactor.BackupCode);
const hasOtherNewMfa = Boolean(this.#totp ?? this.#webAuthn?.length); const hasOtherNewMfa = Boolean(this.#totp ?? this.#webAuthn?.length);
assertThat( assertThat(
userHasOtherMfa || hasOtherNewMfa, userHasOtherMfa || hasOtherNewMfa,
new RequestError({ new RequestError({
@ -245,35 +237,7 @@ export class Mfa {
}) })
); );
const codes = generateBackupCodes(); this.#backupCode = verificationRecord.toBindMfa();
this.#pendingBackupCodes = codes;
return this.#pendingBackupCodes;
}
/**
* Add backup codes to the user account.
*
* - This is to ensure the user has received the backup codes before adding them to the account.
* - Any existing backup code factor will be replaced with the new one.
*
* @throws {RequestError} with status 404 if no pending backup codes are found
*/
async addBackupCodes() {
assertThat(
this.#pendingBackupCodes?.length,
new RequestError({
code: 'session.mfa.pending_info_not_found',
status: 404,
})
);
this.#backupCode = {
type: MfaFactor.BackupCode,
codes: this.#pendingBackupCodes,
};
this.#pendingBackupCodes = undefined;
} }
/** /**
@ -338,7 +302,6 @@ export class Mfa {
totp: this.#totp, totp: this.#totp,
webAuthn: this.#webAuthn, webAuthn: this.#webAuthn,
backupCode: this.#backupCode, backupCode: this.#backupCode,
pendingBackupCodes: this.#pendingBackupCodes,
}; };
} }

View file

@ -1,8 +1,14 @@
import { type ToZodObject } from '@logto/connector-kit'; import { type ToZodObject } from '@logto/connector-kit';
import { MfaFactor, VerificationType, type MfaVerificationBackupCode } from '@logto/schemas'; import {
MfaFactor,
VerificationType,
type BindBackupCode,
type MfaVerificationBackupCode,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared'; import { generateStandardId } from '@logto/shared';
import { z } from 'zod'; import { z } from 'zod';
import { generateBackupCodes } from '#src/routes/interaction/utils/backup-code-validation.js';
import type Libraries from '#src/tenants/Libraries.js'; import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
@ -15,6 +21,7 @@ export type BackupCodeVerificationRecordData = {
/** UserId is required for backup code verification */ /** UserId is required for backup code verification */
userId: string; userId: string;
code?: string; code?: string;
backupCodes?: string[];
}; };
export const backupCodeVerificationRecordDataGuard = z.object({ export const backupCodeVerificationRecordDataGuard = z.object({
@ -22,6 +29,7 @@ export const backupCodeVerificationRecordDataGuard = z.object({
type: z.literal(VerificationType.BackupCode), type: z.literal(VerificationType.BackupCode),
userId: z.string(), userId: z.string(),
code: z.string().optional(), code: z.string().optional(),
backupCodes: z.string().array().optional(),
}) satisfies ToZodObject<BackupCodeVerificationRecordData>; }) satisfies ToZodObject<BackupCodeVerificationRecordData>;
export class BackupCodeVerification implements MfaVerificationRecord<VerificationType.BackupCode> { export class BackupCodeVerification implements MfaVerificationRecord<VerificationType.BackupCode> {
@ -43,17 +51,19 @@ export class BackupCodeVerification implements MfaVerificationRecord<Verificatio
public readonly type = VerificationType.BackupCode; public readonly type = VerificationType.BackupCode;
public readonly userId: string; public readonly userId: string;
private code?: string; private code?: string;
private backupCodes?: string[];
constructor( constructor(
private readonly libraries: Libraries, private readonly libraries: Libraries,
private readonly queries: Queries, private readonly queries: Queries,
data: BackupCodeVerificationRecordData data: BackupCodeVerificationRecordData
) { ) {
const { id, userId, code } = data; const { id, userId, code, backupCodes } = data;
this.id = id; this.id = id;
this.userId = userId; this.userId = userId;
this.code = code; this.code = code;
this.backupCodes = backupCodes;
} }
get isVerified() { get isVerified() {
@ -64,6 +74,12 @@ export class BackupCodeVerification implements MfaVerificationRecord<Verificatio
return false; return false;
} }
generate() {
const codes = generateBackupCodes();
this.backupCodes = codes;
return codes;
}
async verify(code: string) { async verify(code: string) {
const { const {
users: { findUserById, updateUserById }, users: { findUserById, updateUserById },
@ -113,14 +129,24 @@ export class BackupCodeVerification implements MfaVerificationRecord<Verificatio
this.code = code; this.code = code;
} }
toBindMfa(): BindBackupCode {
assertThat(this.backupCodes, 'session.mfa.pending_info_not_found');
return {
type: MfaFactor.BackupCode,
codes: this.backupCodes,
};
}
toJson(): BackupCodeVerificationRecordData { toJson(): BackupCodeVerificationRecordData {
const { id, type, userId, code } = this; const { id, type, userId, code, backupCodes } = this;
return { return {
id, id,
type, type,
userId, userId,
code, code,
backupCodes,
}; };
} }
} }

View file

@ -1,4 +1,4 @@
import { type User, type VerificationType } from '@logto/schemas'; import { type BindMfa, type User, type VerificationType } from '@logto/schemas';
type Data<T> = { type Data<T> = {
id: string; id: string;
@ -52,4 +52,8 @@ export abstract class MfaVerificationRecord<
* A new bind MFA verification record can not be used for existing user's interaction verification. * A new bind MFA verification record can not be used for existing user's interaction verification.
**/ **/
abstract get isNewBindMfaVerification(): boolean; abstract get isNewBindMfaVerification(): boolean;
/**
* Convert the verification record to a BindMfa data type.
*/
abstract toBindMfa(): BindMfa;
} }

View file

@ -142,7 +142,7 @@ export default function interactionProfileRoutes<T extends WithLogContext>(
`${experienceRoutes.mfa}`, `${experienceRoutes.mfa}`,
koaGuard({ koaGuard({
body: z.object({ body: z.object({
type: z.literal(MfaFactor.TOTP).or(z.literal(MfaFactor.WebAuthn)), type: z.nativeEnum(MfaFactor),
verificationId: z.string(), verificationId: z.string(),
}), }),
status: [204, 400, 403, 404, 422], status: [204, 400, 403, 404, 422],
@ -172,6 +172,10 @@ export default function interactionProfileRoutes<T extends WithLogContext>(
await experienceInteraction.mfa.addWebAuthnByVerificationId(verificationId); await experienceInteraction.mfa.addWebAuthnByVerificationId(verificationId);
break; break;
} }
case MfaFactor.BackupCode: {
await experienceInteraction.mfa.addBackupCodeByVerificationId(verificationId);
break;
}
} }
await experienceInteraction.save(); await experienceInteraction.save();
@ -181,65 +185,4 @@ export default function interactionProfileRoutes<T extends WithLogContext>(
return next(); return next();
} }
); );
router.post(
`${experienceRoutes.mfa}/backup-codes/generate`,
koaGuard({
status: [200, 400, 403, 404, 422],
response: z.object({
codes: z.array(z.string()),
}),
}),
async (ctx, next) => {
const { experienceInteraction } = ctx;
// Guard current interaction event is not ForgotPassword
assertThat(
experienceInteraction.interactionEvent !== InteractionEvent.ForgotPassword,
new RequestError({
code: 'session.not_supported_for_forgot_password',
statue: 400,
})
);
// Guard current interaction event is identified and MFA verified
await experienceInteraction.guardMfaVerificationStatus();
const backupCodes = await experienceInteraction.mfa.generateBackupCodes();
await experienceInteraction.save();
ctx.body = { codes: backupCodes };
return next();
}
);
router.post(
`${experienceRoutes.mfa}/backup-codes`,
koaGuard({
status: [204, 400, 403, 404, 422],
}),
async (ctx, next) => {
const { experienceInteraction } = ctx;
// Guard current interaction event is not ForgotPassword
assertThat(
experienceInteraction.interactionEvent !== InteractionEvent.ForgotPassword,
new RequestError({
code: 'session.not_supported_for_forgot_password',
statue: 400,
})
);
// Guard current interaction event is identified and MFA verified
await experienceInteraction.guardMfaVerificationStatus();
await experienceInteraction.mfa.addBackupCodes();
await experienceInteraction.save();
ctx.status = 204;
return next();
}
);
} }

View file

@ -17,6 +17,41 @@ export default function backupCodeVerificationRoutes<T extends WithLogContext>(
) { ) {
const { libraries, queries } = tenantContext; const { libraries, queries } = tenantContext;
router.post(
`${experienceRoutes.verification}/backup-code/generate`,
koaGuard({
status: [200, 400],
response: z.object({
verificationId: z.string(),
codes: z.array(z.string()),
}),
}),
async (ctx, next) => {
const { experienceInteraction } = ctx;
assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found');
const backupCodeVerificationRecord = BackupCodeVerification.create(
libraries,
queries,
experienceInteraction.identifiedUserId
);
const codes = backupCodeVerificationRecord.generate();
ctx.experienceInteraction.setVerificationRecord(backupCodeVerificationRecord);
await ctx.experienceInteraction.save();
ctx.body = {
verificationId: backupCodeVerificationRecord.id,
codes,
};
return next();
}
);
router.post( router.post(
`${experienceRoutes.verification}/backup-code/verify`, `${experienceRoutes.verification}/backup-code/verify`,
koaGuard({ koaGuard({