Skip to content

Commit

Permalink
feat(core): add mfa verification guard
Browse files Browse the repository at this point in the history
add mfa verification guard
  • Loading branch information
simeng-li committed Jul 17, 2024
1 parent f95a7da commit 3f0047c
Show file tree
Hide file tree
Showing 10 changed files with 299 additions and 18 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type ToZodObject } from '@logto/connector-kit';
import { InteractionEvent, type VerificationType } from '@logto/schemas';
import { InteractionEvent, type User, type VerificationType } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { conditional } from '@silverhand/essentials';
import { z } from 'zod';
Expand All @@ -17,6 +17,7 @@ import {
identifyUserByVerificationRecord,
toUserSocialIdentityData,
} from './utils.js';
import { MfaValidator } from './validators/mfa-validator.js';
import { ProfileValidator } from './validators/profile-validator.js';
import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js';
import {
Expand Down Expand Up @@ -55,6 +56,7 @@ export default class ExperienceInteraction {
private readonly verificationRecords = new Map<VerificationType, VerificationRecord>();
/** The userId of the user for the current interaction. Only available once the user is identified. */
private userId?: string;
private userCache?: User;
/** The user provided profile data in the current interaction that needs to be stored to database. */
#profile?: InteractionProfile;
/** The interaction event for the current interaction. */
Expand Down Expand Up @@ -190,6 +192,27 @@ export default class ExperienceInteraction {
return this.verificationRecordsArray.find((record) => record.id === verificationId);
}

/**
* Validate the interaction verification records against the sign-in experience and user MFA settings.
*
* The interaction is verified if at least one user enabled MFA verification record is present and verified.
*/
public async guardMfaVerificationStatus() {
const user = await this.getIdentifiedUser();
const mfaSettings = await this.signInExperienceValidator.getMfaSettings();
const mfaValidator = new MfaValidator(mfaSettings, user);

const isVerified = mfaValidator.isMfaVerified(this.verificationRecordsArray);

assertThat(
isVerified,
new RequestError(
{ code: 'session.mfa.require_mfa_verification', status: 403 },
{ availableFactors: mfaValidator.availableMfaVerificationTypes }
)
);
}

Check warning on line 214 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L201-L214

Added lines #L201 - L214 were not covered by tests

/** Save the current interaction result. */
public async save() {
const { provider } = this.tenant;
Expand All @@ -212,22 +235,30 @@ export default class ExperienceInteraction {

/** Submit the current interaction result to the OIDC provider and clear the interaction data */
public async submit() {
assertThat(this.userId, 'session.verification_session_not_found');

const {
queries: { users: userQueries, userSsoIdentities: userSsoIdentitiesQueries },
} = this.tenant;

const user = await userQueries.findUserById(this.userId);
// Initiated
assertThat(
this.interactionEvent,
new RequestError({ code: 'session.interaction_not_found', status: 404 })
);

Check warning on line 246 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L242-L246

Added lines #L242 - L246 were not covered by tests

// TODO: mfa validation
// TODO: profile updates validation
// TODO: missing profile fields validation
// Identified
const user = await this.getIdentifiedUser();

// Verified
if (this.#interactionEvent !== InteractionEvent.ForgotPassword) {
await this.guardMfaVerificationStatus();
}

Check warning on line 254 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L248-L254

Added lines #L248 - L254 were not covered by tests

const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.#profile ?? {};

// TODO: profile updates validation

Check warning on line 259 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L258-L259

Added lines #L258 - L259 were not covered by tests
// Update user profile
await userQueries.updateUserById(this.userId, {
await userQueries.updateUserById(user.id, {

Check warning on line 261 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L261

Added line #L261 was not covered by tests
...rest,
...conditional(
socialIdentity && {
Expand All @@ -240,6 +271,8 @@ export default class ExperienceInteraction {
lastSignInAt: Date.now(),
});

// TODO: missing profile fields validation

Check warning on line 275 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L274-L275

Added lines #L274 - L275 were not covered by tests
if (enterpriseSsoIdentity) {
await userSsoIdentitiesQueries.insert({
id: generateStandardId(),
Expand All @@ -254,7 +287,7 @@ export default class ExperienceInteraction {
const { provider } = this.tenant;

const redirectTo = await provider.interactionResult(this.ctx.req, this.ctx.res, {
login: { accountId: this.userId },
login: { accountId: user.id },

Check warning on line 290 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L290

Added line #L290 was not covered by tests
});

this.ctx.body = { redirectTo };
Expand Down Expand Up @@ -377,4 +410,28 @@ export default class ExperienceInteraction {
...profile,
};
}

/**
* Assert the interaction is identified and return the identified user.
*
* @throws RequestError with 400 if the user is not identified
* @throws RequestError with 404 if the user is not found
*/
private async getIdentifiedUser(): Promise<User> {
if (this.userCache) {
return this.userCache;
}

// Identified
assertThat(this.userId, 'session.identifier_not_found');

const {
queries: { users: userQueries },
} = this.tenant;

const user = await userQueries.findUserById(this.userId);

this.userCache = user;
return this.userCache;
}

Check warning on line 436 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L421-L436

Added lines #L421 - L436 were not covered by tests
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import {
MfaFactor,
VerificationType,
type Mfa,
type MfaVerification,
type User,
} from '@logto/schemas';

import { type VerificationRecord } from '../verifications/index.js';

const mfaVerificationTypes = Object.freeze([
VerificationType.TOTP,
VerificationType.BackupCode,
VerificationType.WebAuthn,
]);

type MfaVerificationType =
| VerificationType.TOTP
| VerificationType.BackupCode
| VerificationType.WebAuthn;

const mfaVerificationTypeToMfaFactorMap = Object.freeze({
[VerificationType.TOTP]: MfaFactor.TOTP,
[VerificationType.BackupCode]: MfaFactor.BackupCode,
[VerificationType.WebAuthn]: MfaFactor.WebAuthn,
}) satisfies Record<MfaVerificationType, MfaFactor>;

const isMfaVerificationRecordType = (type: VerificationType): type is MfaVerificationType => {
return mfaVerificationTypes.includes(type);
};

Check warning on line 30 in packages/core/src/routes/experience/classes/validators/mfa-validator.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/validators/mfa-validator.ts#L29-L30

Added lines #L29 - L30 were not covered by tests

export class MfaValidator {
constructor(
private readonly mfaSettings: Mfa,
private readonly user: User
) {}

Check warning on line 36 in packages/core/src/routes/experience/classes/validators/mfa-validator.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/validators/mfa-validator.ts#L34-L36

Added lines #L34 - L36 were not covered by tests

/**
* Get the enabled MFA factors for the user
* - Filter out MFA factors that are not configured in the sign-in experience
*/
get userEnabledMfaVerifications() {
const { mfaVerifications } = this.user;

return mfaVerifications.filter((verification) =>
this.mfaSettings.factors.includes(verification.type)
);
}

Check warning on line 48 in packages/core/src/routes/experience/classes/validators/mfa-validator.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/validators/mfa-validator.ts#L43-L48

Added lines #L43 - L48 were not covered by tests

/**
* For front-end display usage only
* Return the available MFA verifications for the user.
*/
get availableMfaVerificationTypes() {
return (
this.userEnabledMfaVerifications
// Filter out backup codes if all the codes are used
.filter((verification) => {
if (verification.type !== MfaFactor.BackupCode) {
return true;
}
return verification.codes.some((code) => !code.usedAt);
})
// Filter out duplicated verifications with the same type
.reduce<MfaVerification[]>((verifications, verification) => {
if (verifications.some(({ type }) => type === verification.type)) {
return verifications;
}

return [...verifications, verification];
}, [])
.slice()
// Sort by last used time, the latest used factor is the first one, backup code is always the last one
.sort((verificationA, verificationB) => {
if (verificationA.type === MfaFactor.BackupCode) {
return 1;
}

if (verificationB.type === MfaFactor.BackupCode) {
return -1;
}

return (
new Date(verificationB.lastUsedAt ?? 0).getTime() -
new Date(verificationA.lastUsedAt ?? 0).getTime()
);
})
.map(({ type }) => type)
);
}

Check warning on line 90 in packages/core/src/routes/experience/classes/validators/mfa-validator.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/validators/mfa-validator.ts#L55-L90

Added lines #L55 - L90 were not covered by tests

get isMfaEnabled() {
return this.userEnabledMfaVerifications.length > 0;
}

Check warning on line 94 in packages/core/src/routes/experience/classes/validators/mfa-validator.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/validators/mfa-validator.ts#L93-L94

Added lines #L93 - L94 were not covered by tests

isMfaVerified(verificationRecords: VerificationRecord[]) {
// MFA validation is not enabled
if (!this.isMfaEnabled) {
return true;
}

const mfaVerificationRecords = this.filterVerifiedMfaVerificationRecords(verificationRecords);

return mfaVerificationRecords.length > 0;
}

Check warning on line 105 in packages/core/src/routes/experience/classes/validators/mfa-validator.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/validators/mfa-validator.ts#L97-L105

Added lines #L97 - L105 were not covered by tests

filterVerifiedMfaVerificationRecords(verificationRecords: VerificationRecord[]) {
const enabledMfaFactors = this.userEnabledMfaVerifications;

// Filter out the verified MFA verification records
const mfaVerificationRecords = verificationRecords.filter(({ type, isVerified }) => {
return (
isVerified &&
isMfaVerificationRecordType(type) &&
// Check if the verification type is enabled in the user's MFA settings
enabledMfaFactors.some((factor) => factor.type === mfaVerificationTypeToMfaFactorMap[type])
);
});

return mfaVerificationRecords;
}

Check warning on line 121 in packages/core/src/routes/experience/classes/validators/mfa-validator.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/validators/mfa-validator.ts#L108-L121

Added lines #L108 - L121 were not covered by tests
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ export class SignInExperienceValidator {
return availableSsoConnectors.filter(({ domains }) => domains.includes(domain));
}

public async getMfaSettings() {
const { mfa } = await this.getSignInExperienceData();

return mfa;
}

Check warning on line 118 in packages/core/src/routes/experience/classes/validators/sign-in-experience-validator.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/validators/sign-in-experience-validator.ts#L115-L118

Added lines #L115 - L118 were not covered by tests

public async getSignInExperienceData() {
this.signInExperienceDataCache ||=
await this.queries.signInExperiences.findDefaultSignInExperience();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export class TotpVerification implements VerificationRecord<VerificationType.TOT

const { mfaVerifications } = await findUserById(this.userId);

// User can only have one TOTP MFA record in the profile

Check warning on line 127 in packages/core/src/routes/experience/classes/verifications/totp-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/totp-verification.ts#L127

Added line #L127 was not covered by tests
const totpVerification = findUserTotp(mfaVerifications);

// Can not found totp verification, this is an invalid request, throw invalid code error anyway for security reason
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/routes/experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
router.post(
`${experienceRoutes.prefix}/submit`,
koaGuard({
status: [200, 400],
status: [200, 400, 403, 422],
response: z.object({
redirectTo: z.string(),
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ export default function backupCodeVerificationRoutes<T extends WithLogContext>(

assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found');

// TODO: Check if the MFA is enabled

const backupCodeVerificationRecord = BackupCodeVerification.create(
libraries,
queries,
Expand All @@ -49,6 +47,8 @@ export default function backupCodeVerificationRoutes<T extends WithLogContext>(
ctx.body = {
verificationId: backupCodeVerificationRecord.id,
};

return next();

Check warning on line 51 in packages/core/src/routes/experience/verification-routes/backup-code-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/verification-routes/backup-code-verification.ts#L50-L51

Added lines #L50 - L51 were not covered by tests
}
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ import assertThat from '#src/utils/assert-that.js';
import { type WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js';
import { type WithInteractionSieContext } from '../middleware/koa-interaction-sie.js';
import {
type VerifiedSignInInteractionResult,
type AccountVerifiedInteractionResult,
type VerifiedInteractionResult,
type VerifiedRegisterInteractionResult,
type AccountVerifiedInteractionResult,
type VerifiedSignInInteractionResult,
} from '../types/index.js';
import { generateBackupCodes } from '../utils/backup-code-validation.js';
import { storeInteractionResult } from '../utils/interaction.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const successFullyCreateNewTotpSecret = async (client: ExperienceClient)
return { secret, secretQrCode, verificationId };
};

export const successFullyVerifyTotp = async (
export const successfullyVerifyTotp = async (
client: ExperienceClient,
payload: {
code: string;
Expand Down
Loading

0 comments on commit 3f0047c

Please sign in to comment.