Skip to content

Commit 1be5541

Browse files
committed
correction of the MFA detection #618
1 parent cb6d032 commit 1be5541

File tree

6 files changed

+101
-26
lines changed

6 files changed

+101
-26
lines changed

build/src/api/core/orgcheck-api-secretsauce.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,7 @@ const ALL_SCORE_RULES = [
693693
}, {
694694
id: 72,
695695
description: 'User is logging directly without MFA',
696-
formula: (/** @type {SFDC_User} */ d) => d.nbDirectLoginWithoutMFA > 0 && d.hasMfaByPass !== true,
696+
formula: (/** @type {SFDC_User} */ d) => d.nbDirectLoginsWithoutMFA > 0 && d.hasMfaByPass !== true,
697697
errorMessage: `This user is logging in directly to Salesforce without using MFA (Multi-Factor Authentication). And this user has not the MFA bypass enabled. Please work with your user to make them use MFA for better security.`,
698698
badField: 'nbDirectLoginWithoutMFA',
699699
applicable: [ SFDC_User ],

build/src/api/data/orgcheck-api-data-user.js

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -125,23 +125,30 @@ export class SFDC_User extends Data {
125125
permissionSetRefs;
126126

127127
/**
128-
* @description Number of direct login without using MFA
128+
* @description Number of direct logins to salesforce
129129
* @type {number}
130130
* @public
131131
*/
132-
nbDirectLoginWithoutMFA;
132+
nbDirectLogins;
133133

134134
/**
135-
* @description Number of direct login with using MFA
135+
* @description Number of direct logins without using MFA
136136
* @type {number}
137137
* @public
138138
*/
139-
nbDirectLoginWithMFA;
139+
nbDirectLoginsWithoutMFA;
140140

141141
/**
142-
* @description Number of indirect login via SSO
142+
* @description Number of direct logins with using MFA
143143
* @type {number}
144144
* @public
145145
*/
146-
nbSSOLogin;
146+
nbDirectLoginsWithMFA;
147+
148+
/**
149+
* @description Number of indirect logins via SSO
150+
* @type {number}
151+
* @public
152+
*/
153+
nbSSOLogins;
147154
}

build/src/api/dataset/orgcheck-api-dataset-internalactiveusers.js

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,20 @@ export class DatasetInternalActiveUsers extends Dataset {
3838
'AND Assignee.ContactId = NULL ' +
3939
'AND Assignee.Profile.Id != NULL'
4040
}, {
41-
string: 'SELECT UserId, LoginType, AuthMethodReference, Status, COUNT(Id) CntLogin ' +
41+
string: 'SELECT UserId, Status, LoginType, COUNT(Id) CntLogins ' +
4242
'FROM LoginHistory ' +
43-
`WHERE (LoginType = 'Application' ` + // Adding parenthesis because we have a OR here
44-
`OR LoginType LIKE '%SSO%') ` + // and the option 'queryMoreField' will add a final AND
45-
`GROUP BY UserId, LoginType, AuthMethodReference, Status `,
43+
`WHERE (LoginType = 'Application' OR LoginType LIKE '%SSO%') ` + // the option 'queryMoreField' will add a final AND so that's why we have parenthesis here around the OR statement
44+
'GROUP BY UserId, Status, LoginType ' +
45+
'ORDER BY UserId, Status, LoginType ',
4646
queryMoreField: 'LoginTime' // aggregate does not support calling QueryMore, use the custom instead
47+
}, {
48+
string: 'SELECT UserId, Policy, COUNT(Id) CntVerifications ' +
49+
'FROM VerificationHistory ' +
50+
`WHERE Activity = 'Login' ` +
51+
`AND Status IN ('AutomatedSuccess', 'Succeeded') ` +
52+
`AND (LoginHistory.LoginType = 'Application')` +
53+
'GROUP BY UserId, Policy ',
54+
queryMoreField: 'VerificationTime' // aggregate does not support calling QueryMore, use the custom instead
4755
}], logger);
4856

4957
// Init the factory and records
@@ -53,6 +61,7 @@ export class DatasetInternalActiveUsers extends Dataset {
5361
const userRecords = results[0];
5462
const assignmentRecords = results[1];
5563
const loginRecords = results[2];
64+
const verifRecords = results[3];
5665
logger?.log(`Parsing ${userRecords.length} users...`);
5766
const users = new Map(await Processor.map(userRecords, async (/** @type {any} */ record) => {
5867

@@ -74,9 +83,10 @@ export class DatasetInternalActiveUsers extends Dataset {
7483
isAdminLike: false,
7584
hasMfaByPass: false,
7685
hasDebugMode: record.UserPreferencesUserDebugModePref === true,
77-
nbDirectLoginWithMFA: 0,
78-
nbDirectLoginWithoutMFA: 0,
79-
nbSSOLogin: 0,
86+
nbDirectLogins: 0,
87+
nbDirectLoginsWithMFA: 0,
88+
nbDirectLoginsWithoutMFA: 0,
89+
nbSSOLogins: 0,
8090
url: sfdcManager.setupUrl(id, SalesforceMetadataTypes.USER)
8191
}
8292
});
@@ -123,21 +133,31 @@ export class DatasetInternalActiveUsers extends Dataset {
123133
// depending on the login access (direct or sso)
124134
if (record.LoginType === 'Application') {
125135
// Direct login access via browser
126-
if (record.AuthMethodReference) {
127-
// MFA used from Salesforce
128-
user.nbDirectLoginWithMFA += record.CntLogin;
129-
} else {
130-
// No MFA used from Salesforce
131-
user.nbDirectLoginWithoutMFA += record.CntLogin;
132-
}
136+
user.nbDirectLogins += record.CntLogins;
133137
} else {
134138
// SSO login access via IDP
135-
user.nbSSOLogin += record.CntLogin;
139+
user.nbSSOLogins += record.CntLogins;
136140
}
137141
}
138142
}
139143
});
140144

145+
// Now process the user verifications aggregates
146+
logger?.log(`Parsing ${verifRecords.length} user verifications aggregates...`);
147+
await Processor.forEach(verifRecords, (/** @type {any} */ record) => {
148+
149+
const userId = sfdcManager.caseSafeId(record.UserId);
150+
const user = users.get(userId);
151+
if (user) {
152+
// If MFA is used for internal logins
153+
if (record.Policy === 'TwoFactorAuthentication') {
154+
user.nbDirectLoginsWithMFA += record.CntVerifications;
155+
} else {
156+
user.nbDirectLoginsWithoutMFA += record.CntVerifications;
157+
}
158+
}
159+
});
160+
141161
// FINALLY!!!! Compute the score of all items
142162
logger?.log(`Computing scores for ${users.size} users...`);
143163
await Processor.forEach(users, (/** @type {SFDC_User} */ user) => {

build/test/api/unit/orgcheck-api-datasets.unit.test.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,4 +609,52 @@ describe('tests.api.unit.Datasets', () => {
609609
});
610610
});
611611

612+
describe('Specific test for DatasetInternalActiveUsers', () => {
613+
const dataset = new DatasetInternalActiveUsers();
614+
it('checks if this dataset class runs correctly', async () => {
615+
const sfdcManager = new SfdcManagerMock();
616+
sfdcManager.addSoqlQueryResponse('FROM User ', [
617+
{ Id: '001', Name: 'User is OK', ProfileId: '001', LastLoginDate: new Date(), LastPasswordChangeDate: new Date(), NumberOfFailedLogins: 0, UserPreferencesLightningExperiencePreferred: true, UserPreferencesUserDebugModePref: false },
618+
{ Id: '002', Name: 'User never logged', ProfileId: '001', LastLoginDate: null, LastPasswordChangeDate: new Date(), NumberOfFailedLogins: 0, UserPreferencesLightningExperiencePreferred: true, UserPreferencesUserDebugModePref: false },
619+
{ Id: '003', Name: 'User never changed pwd', ProfileId: '001', LastLoginDate: new Date(), LastPasswordChangeDate: null, NumberOfFailedLogins: 0, UserPreferencesLightningExperiencePreferred: true, UserPreferencesUserDebugModePref: false },
620+
{ Id: '004', Name: 'User has failed logins', ProfileId: '001', LastLoginDate: new Date(), LastPasswordChangeDate: new Date(), NumberOfFailedLogins: 5, UserPreferencesLightningExperiencePreferred: true, UserPreferencesUserDebugModePref: false },
621+
{ Id: '005', Name: 'User is still under classic', ProfileId: '001', LastLoginDate: new Date(), LastPasswordChangeDate: new Date(), NumberOfFailedLogins: 0, UserPreferencesLightningExperiencePreferred: false, UserPreferencesUserDebugModePref: false },
622+
{ Id: '006', Name: 'User has debug mode on', ProfileId: '001', LastLoginDate: new Date(), LastPasswordChangeDate: new Date(), NumberOfFailedLogins: 0, UserPreferencesLightningExperiencePreferred: true, UserPreferencesUserDebugModePref: true },
623+
]);
624+
sfdcManager.addSoqlQueryResponse('FROM LoginHistory ', [
625+
{ UserId: '001', LoginType: 'Application', Status: 'Success', CntLogins: 92 }, // nbDirectLoginWithoutMFA += 92 for user 001
626+
{ UserId: '001', LoginType: 'Application', Status: 'Failure', CntLogins: 12 },
627+
{ UserId: '001', LoginType: 'zz SSO 1', Status: 'Success', CntLogins: 4 }, // nbSSOLogins += 4 for user 001
628+
{ UserId: '001', LoginType: 'zz SSO 2', Status: 'Success', CntLogins: 9 }, // nbSSOLogins += 9 for user 001
629+
{ UserId: '001', LoginType: 'zz SSO 2', Status: 'Failure', CntLogins: 1 },
630+
{ UserId: '002', LoginType: 'Application', Status: 'Success', CntLogins: 1 }, // nbDirectLoginsWithoutMFA += 1 for user 002
631+
{ UserId: '002', LoginType: 'zz SSO 1', Status: 'Failure', CntLogins: 1 },
632+
{ UserId: '002', LoginType: 'zz SSO 2', Status: 'Success', CntLogins: 2 } // nbSSOLogins += 2 for user 002
633+
]);
634+
sfdcManager.addSoqlQueryResponse('FROM VerificationHistory ', [
635+
{ UserId: '001', Policy: 'TwoFactorAuthentication', CntVerifications: 91 }, // nbDirectLoginsWithMFA += 91 for user 001
636+
{ UserId: '001', Policy: 'other', CntVerifications: 21 },
637+
{ UserId: '002', Policy: 'TwoFactorAuthentication', CntVerifications: 0 }, // nbDirectLoginsWithMFA += 0 for user 002
638+
{ UserId: '002', Policy: 'other', CntVerifications: 0 },
639+
]);
640+
const dataFactory = new DataFactoryMock();
641+
const logger = new SimpleLoggerMock();
642+
const results = await dataset.run(sfdcManager, dataFactory, logger);
643+
expect(results).toBeDefined();
644+
expect(results instanceof Map).toBeTruthy();
645+
expect(results.size).toBe(6);
646+
expect(results.get('002').lastLogin).toBe(null);
647+
expect(results.get('005').onLightningExperience).toBe(false);
648+
expect(results.get('006').hasDebugMode).toBe(true);
649+
expect(results.get('001').nbDirectLogins).toBe(92);
650+
expect(results.get('001').nbDirectLoginsWithMFA).toBe(91);
651+
expect(results.get('001').nbDirectLoginsWithoutMFA).toBe(21);
652+
expect(results.get('001').nbSSOLogins).toBe(13);
653+
expect(results.get('002').nbDirectLogins).toBe(1);
654+
expect(results.get('002').nbDirectLoginsWithMFA).toBe(0);
655+
expect(results.get('002').nbDirectLoginsWithoutMFA).toBe(0);
656+
expect(results.get('002').nbSSOLogins).toBe(2);
657+
});
658+
});
659+
612660
});

force-app/main/default/lwc/orgcheckApp/__tests__/orgcheckApp.tableDefinitions.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ describe('c-orgcheck-app', () => {
262262
// Properties used in the app in the corresponding TableDefintion
263263
const expectedProperties = [ 'url', 'score', 'id', 'name', 'onLightningExperience', 'lastLogin', 'numberFailedLogins', 'isAdminLike',
264264
'lastPasswordChange', 'importantPermissions', 'importantPermissionsGrantedBy', 'profileRef', 'permissionSetRefs', 'hasMfaByPass',
265-
'nbDirectLoginWithMFA', 'nbDirectLoginWithoutMFA', 'nbSSOLogin', 'hasDebugMode' ];
265+
'nbDirectLoginsWithMFA', 'nbDirectLoginsWithoutMFA', 'nbSSOLogins', 'nbDirectLogins', 'hasDebugMode' ];
266266
// Calculate the expected fields that are not in the object
267267
expect(expectedProperties.filter((p) => objectProperties.includes(p) === false)).toStrictEqual([]);
268268
// Calculate the fields that are in the object but not in the expected properties

force-app/main/default/lwc/orgcheckApp/subs/tableDefinitions.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -806,9 +806,9 @@ export class TableDefinitions {
806806
{ label: 'Failed logins', type: ColumnType.NUM, data: { value: 'numberFailedLogins' }},
807807
{ label: 'Has MFA by-pass?', type: ColumnType.CHK, data: { value: 'hasMfaByPass' }},
808808
{ label: 'Has Debug mode?', type: ColumnType.CHK, data: { value: 'hasDebugMode' }},
809-
{ label: '#SF Logins w/o MFA', type: ColumnType.NUM, data: { value: 'nbDirectLoginWithoutMFA' }},
810-
{ label: '#SF Logins w/ MFA', type: ColumnType.NUM, data: { value: 'nbDirectLoginWithMFA' }},
811-
{ label: '#SSO Logins', type: ColumnType.NUM, data: { value: 'nbSSOLogin' }},
809+
{ label: '#SF Logins w/o MFA', type: ColumnType.NUM, data: { value: 'nbDirectLoginsWithoutMFA' }},
810+
{ label: '#SF Logins w/ MFA', type: ColumnType.NUM, data: { value: 'nbDirectLoginsWithMFA' }},
811+
{ label: '#SSO Logins', type: ColumnType.NUM, data: { value: 'nbSSOLogins' }},
812812
{ label: 'Password change', type: ColumnType.DTM, data: { value: 'lastPasswordChange' }},
813813
{ label: 'Is Admin-like?', type: ColumnType.CHK, data: { value: 'isAdminLike' }},
814814
{ label: 'Api Enabled', type: ColumnType.CHK, data: { value: 'importantPermissions.apiEnabled' }},

0 commit comments

Comments
 (0)