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:
parent
34c8bedef6
commit
05082b56a7
5 changed files with 87 additions and 116 deletions
|
@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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({
|
||||||
|
|
Loading…
Reference in a new issue