mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): add mfa verification guard (#6262)
add mfa verification guard
This commit is contained in:
parent
c93ffb4760
commit
7537c510f7
10 changed files with 299 additions and 18 deletions
|
@ -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';
|
||||
|
@ -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 {
|
||||
|
@ -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. */
|
||||
|
@ -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 }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Save the current interaction result. */
|
||||
public async save() {
|
||||
const { provider } = this.tenant;
|
||||
|
@ -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 })
|
||||
);
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.#profile ?? {};
|
||||
|
||||
// TODO: profile updates validation
|
||||
|
||||
// Update user profile
|
||||
await userQueries.updateUserById(this.userId, {
|
||||
await userQueries.updateUserById(user.id, {
|
||||
...rest,
|
||||
...conditional(
|
||||
socialIdentity && {
|
||||
|
@ -240,6 +271,8 @@ export default class ExperienceInteraction {
|
|||
lastSignInAt: Date.now(),
|
||||
});
|
||||
|
||||
// TODO: missing profile fields validation
|
||||
|
||||
if (enterpriseSsoIdentity) {
|
||||
await userSsoIdentitiesQueries.insert({
|
||||
id: generateStandardId(),
|
||||
|
@ -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 },
|
||||
});
|
||||
|
||||
this.ctx.body = { redirectTo };
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
export class MfaValidator {
|
||||
constructor(
|
||||
private readonly mfaSettings: Mfa,
|
||||
private readonly user: User
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
);
|
||||
}
|
||||
|
||||
get isMfaEnabled() {
|
||||
return this.userEnabledMfaVerifications.length > 0;
|
||||
}
|
||||
|
||||
isMfaVerified(verificationRecords: VerificationRecord[]) {
|
||||
// MFA validation is not enabled
|
||||
if (!this.isMfaEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const mfaVerificationRecords = this.filterVerifiedMfaVerificationRecords(verificationRecords);
|
||||
|
||||
return mfaVerificationRecords.length > 0;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -111,6 +111,12 @@ export class SignInExperienceValidator {
|
|||
return availableSsoConnectors.filter(({ domains }) => domains.includes(domain));
|
||||
}
|
||||
|
||||
public async getMfaSettings() {
|
||||
const { mfa } = await this.getSignInExperienceData();
|
||||
|
||||
return mfa;
|
||||
}
|
||||
|
||||
public async getSignInExperienceData() {
|
||||
this.signInExperienceDataCache ||=
|
||||
await this.queries.signInExperiences.findDefaultSignInExperience();
|
||||
|
|
|
@ -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
|
||||
const totpVerification = findUserTotp(mfaVerifications);
|
||||
|
||||
// Can not found totp verification, this is an invalid request, throw invalid code error anyway for security reason
|
||||
|
|
|
@ -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(),
|
||||
}),
|
||||
|
|
|
@ -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,
|
||||
|
@ -49,6 +47,8 @@ export default function backupCodeVerificationRoutes<T extends WithLogContext>(
|
|||
ctx.body = {
|
||||
verificationId: backupCodeVerificationRecord.id,
|
||||
};
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
import { MfaFactor } from '@logto/schemas';
|
||||
import { authenticator } from 'otplib';
|
||||
|
||||
import { createUserMfaVerification } from '#src/api/admin-user.js';
|
||||
import { initExperienceClient } from '#src/helpers/client.js';
|
||||
import { identifyUserWithUsernamePassword } from '#src/helpers/experience/index.js';
|
||||
import { successfullyVerifyTotp } from '#src/helpers/experience/totp-verification.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import {
|
||||
enableAllPasswordSignInMethods,
|
||||
enableMandatoryMfaWithTotpAndBackupCode,
|
||||
} from '#src/helpers/sign-in-experience.js';
|
||||
import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js';
|
||||
import { devFeatureTest } from '#src/utils.js';
|
||||
|
||||
devFeatureTest.describe('mfa sign-in verification', () => {
|
||||
const userApi = new UserApiTest();
|
||||
|
||||
beforeAll(async () => {
|
||||
await enableAllPasswordSignInMethods();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userApi.cleanUp();
|
||||
});
|
||||
|
||||
describe('TOTP verification', () => {
|
||||
beforeAll(async () => {
|
||||
await enableMandatoryMfaWithTotpAndBackupCode();
|
||||
});
|
||||
|
||||
it('should throw require_mfa_verification error when signing in without mfa verification', async () => {
|
||||
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||
|
||||
const user = await userApi.create({ username, password });
|
||||
await createUserMfaVerification(user.id, MfaFactor.TOTP);
|
||||
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await identifyUserWithUsernamePassword(client, username, password);
|
||||
|
||||
await expectRejects(client.submitInteraction(), {
|
||||
code: 'session.mfa.require_mfa_verification',
|
||||
status: 403,
|
||||
});
|
||||
});
|
||||
|
||||
it('should sign-in successfully with TOTP verification', async () => {
|
||||
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||
const user = await userApi.create({ username, password });
|
||||
|
||||
const response = await createUserMfaVerification(user.id, MfaFactor.TOTP);
|
||||
|
||||
if (response.type !== MfaFactor.TOTP) {
|
||||
throw new Error('unexpected mfa type');
|
||||
}
|
||||
|
||||
const { secret } = response;
|
||||
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await identifyUserWithUsernamePassword(client, username, password);
|
||||
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
await successfullyVerifyTotp(client, { code });
|
||||
|
||||
await expect(client.submitInteraction()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should sign-in successfully with backup code with both TOTP and backup code enabled', async () => {
|
||||
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||
const user = await userApi.create({ username, password });
|
||||
|
||||
await createUserMfaVerification(user.id, MfaFactor.TOTP);
|
||||
const response = await createUserMfaVerification(user.id, MfaFactor.BackupCode);
|
||||
|
||||
if (response.type !== MfaFactor.BackupCode) {
|
||||
throw new Error('unexpected mfa type');
|
||||
}
|
||||
|
||||
const { codes } = response;
|
||||
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await identifyUserWithUsernamePassword(client, username, password);
|
||||
|
||||
const code = codes[0]!;
|
||||
|
||||
await client.verifyBackupCode({ code });
|
||||
|
||||
await expect(client.submitInteraction()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -6,7 +6,7 @@ import { initExperienceClient } from '#src/helpers/client.js';
|
|||
import { identifyUserWithUsernamePassword } from '#src/helpers/experience/index.js';
|
||||
import {
|
||||
successFullyCreateNewTotpSecret,
|
||||
successFullyVerifyTotp,
|
||||
successfullyVerifyTotp,
|
||||
} from '#src/helpers/experience/totp-verification.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
|
||||
|
@ -98,7 +98,7 @@ devFeatureTest.describe('TOTP verification APIs', () => {
|
|||
const { verificationId, secret } = await successFullyCreateNewTotpSecret(client);
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
await successFullyVerifyTotp(client, { code, verificationId });
|
||||
await successfullyVerifyTotp(client, { code, verificationId });
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -157,7 +157,7 @@ devFeatureTest.describe('TOTP verification APIs', () => {
|
|||
|
||||
const code = authenticator.generate(secret);
|
||||
|
||||
await successFullyVerifyTotp(client, { code });
|
||||
await successfullyVerifyTotp(client, { code });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue