Skip to content

Commit d3f68e9

Browse files
committed
Account defender support for reCAPTCHA (#1616)
* Support use_account_defender add-on feature for reCAPTCHA config. * Added integration test for account defender feature
1 parent 140fe18 commit d3f68e9

File tree

6 files changed

+108
-7
lines changed

6 files changed

+108
-7
lines changed

etc/firebase-admin.auth.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ export interface RecaptchaConfig {
371371
emailPasswordEnforcementState?: RecaptchaProviderEnforcementState;
372372
managedRules?: RecaptchaManagedRule[];
373373
recaptchaKeys?: RecaptchaKey[];
374+
useAccountDefender?: boolean;
374375
}
375376

376377
// @public

src/auth/auth-config.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1791,17 +1791,25 @@ export interface RecaptchaConfig {
17911791
* The reCAPTCHA keys.
17921792
*/
17931793
recaptchaKeys?: RecaptchaKey[];
1794+
1795+
/**
1796+
* Whether to use account defender for reCAPTCHA assessment.
1797+
* The default value is false.
1798+
*/
1799+
useAccountDefender?: boolean;
17941800
}
17951801

17961802
export class RecaptchaAuthConfig implements RecaptchaConfig {
17971803
public readonly emailPasswordEnforcementState?: RecaptchaProviderEnforcementState;
17981804
public readonly managedRules?: RecaptchaManagedRule[];
17991805
public readonly recaptchaKeys?: RecaptchaKey[];
1806+
public readonly useAccountDefender?: boolean;
18001807

18011808
constructor(recaptchaConfig: RecaptchaConfig) {
18021809
this.emailPasswordEnforcementState = recaptchaConfig.emailPasswordEnforcementState;
18031810
this.managedRules = recaptchaConfig.managedRules;
18041811
this.recaptchaKeys = recaptchaConfig.recaptchaKeys;
1812+
this.useAccountDefender = recaptchaConfig.useAccountDefender;
18051813
}
18061814

18071815
/**
@@ -1813,6 +1821,7 @@ export class RecaptchaAuthConfig implements RecaptchaConfig {
18131821
emailPasswordEnforcementState: true,
18141822
managedRules: true,
18151823
recaptchaKeys: true,
1824+
useAccountDefender: true,
18161825
};
18171826

18181827
if (!validator.isNonNullObject(options)) {
@@ -1863,6 +1872,15 @@ export class RecaptchaAuthConfig implements RecaptchaConfig {
18631872
RecaptchaAuthConfig.validateManagedRule(managedRule);
18641873
});
18651874
}
1875+
1876+
if (typeof options.useAccountDefender != 'undefined') {
1877+
if (!validator.isBoolean(options.useAccountDefender)) {
1878+
throw new FirebaseAuthError(
1879+
AuthClientErrorCode.INVALID_CONFIG,
1880+
'"RecaptchaConfig.useAccountDefender" must be a boolean value".',
1881+
);
1882+
}
1883+
}
18661884
}
18671885

18681886
/**
@@ -1908,7 +1926,8 @@ export class RecaptchaAuthConfig implements RecaptchaConfig {
19081926
const json: any = {
19091927
emailPasswordEnforcementState: this.emailPasswordEnforcementState,
19101928
managedRules: deepCopy(this.managedRules),
1911-
recaptchaKeys: deepCopy(this.recaptchaKeys)
1929+
recaptchaKeys: deepCopy(this.recaptchaKeys),
1930+
useAccountDefender: this.useAccountDefender,
19121931
}
19131932

19141933
if (typeof json.emailPasswordEnforcementState === 'undefined') {
@@ -1921,6 +1940,10 @@ export class RecaptchaAuthConfig implements RecaptchaConfig {
19211940
delete json.recaptchaKeys;
19221941
}
19231942

1943+
if (typeof json.useAccountDefender === 'undefined') {
1944+
delete json.useAccountDefender;
1945+
}
1946+
19241947
return json;
19251948
}
19261949
}

src/utils/error.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,10 @@ export class AuthClientErrorCode {
745745
code: 'invalid-recaptcha-enforcement-state',
746746
message: 'reCAPTCHA enforcement state must be either "OFF", "AUDIT" or "ENFORCE".'
747747
}
748+
public static RECAPTCHA_NOT_ENABLED = {
749+
code: 'racaptcha-not-enabled',
750+
message: 'reCAPTCHA enterprise is not enabled.'
751+
}
748752
}
749753

750754
/**
@@ -1008,6 +1012,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = {
10081012
INVALID_RECAPTCHA_ACTION: 'INVALID_RECAPTCHA_ACTION',
10091013
// Unrecognized reCAPTCHA enforcement state.
10101014
INVALID_RECAPTCHA_ENFORCEMENT_STATE: 'INVALID_RECAPTCHA_ENFORCEMENT_STATE',
1015+
// reCAPTCHA is not enabled for account defender.
1016+
RECAPTCHA_NOT_ENABLED: 'RECAPTCHA_NOT_ENABLED'
10111017
};
10121018

10131019
/** @const {ServerToClientCode} Messaging server to client enum error codes. */

test/integration/auth.spec.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,29 +1308,41 @@ describe('admin.auth', () => {
13081308
recaptchaConfig: {
13091309
emailPasswordEnforcementState: 'AUDIT',
13101310
managedRules: [{ endScore: 0.1, action: 'BLOCK' }],
1311+
useAccountDefender: true,
13111312
},
13121313
};
13131314
const projectConfigOption2: UpdateProjectConfigRequest = {
13141315
recaptchaConfig: {
13151316
emailPasswordEnforcementState: 'OFF',
1317+
useAccountDefender: false,
1318+
},
1319+
};
1320+
const projectConfigOption3: UpdateProjectConfigRequest = {
1321+
recaptchaConfig: {
1322+
emailPasswordEnforcementState: 'OFF',
1323+
useAccountDefender: true,
13161324
},
13171325
};
13181326
const expectedProjectConfig1: any = {
13191327
recaptchaConfig: {
13201328
emailPasswordEnforcementState: 'AUDIT',
13211329
managedRules: [{ endScore: 0.1, action: 'BLOCK' }],
1330+
useAccountDefender: true,
13221331
},
13231332
};
13241333
const expectedProjectConfig2: any = {
13251334
recaptchaConfig: {
13261335
emailPasswordEnforcementState: 'OFF',
13271336
managedRules: [{ endScore: 0.1, action: 'BLOCK' }],
1337+
useAccountDefender: false,
13281338
},
13291339
};
13301340

13311341
it('updateProjectConfig() should resolve with the updated project config', () => {
13321342
return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption1)
13331343
.then((actualProjectConfig) => {
1344+
// ReCAPTCHA keys are generated differently each time.
1345+
delete actualProjectConfig.recaptchaConfig?.recaptchaKeys;
13341346
expect(actualProjectConfig.toJSON()).to.deep.equal(expectedProjectConfig1);
13351347
return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption2);
13361348
})
@@ -1346,6 +1358,11 @@ describe('admin.auth', () => {
13461358
expect(actualConfigObj).to.deep.equal(expectedProjectConfig2);
13471359
});
13481360
});
1361+
1362+
it('updateProjectConfig() should reject when trying to enable Account Defender while reCAPTCHA is disabled', () => {
1363+
return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption3)
1364+
.should.eventually.be.rejected.and.have.property('code', 'auth/racaptcha-not-enabled');
1365+
});
13491366
});
13501367

13511368
describe('Tenant management operations', () => {
@@ -1436,6 +1453,7 @@ describe('admin.auth', () => {
14361453
action: 'BLOCK',
14371454
},
14381455
],
1456+
useAccountDefender: true,
14391457
},
14401458
};
14411459
const expectedUpdatedTenant2: any = {
@@ -1491,6 +1509,7 @@ describe('admin.auth', () => {
14911509
action: 'BLOCK',
14921510
},
14931511
],
1512+
useAccountDefender: false,
14941513
},
14951514
};
14961515

@@ -2007,8 +2026,6 @@ describe('admin.auth', () => {
20072026
expect(actualTenant.toJSON()).to.deep.equal(expectedUpdatedTenant2);
20082027
});
20092028
});
2010-
2011-
<<<<<<< HEAD
20122029
it('updateTenant() should not disable SMS MFA when TOTP is disabled', () => {
20132030
expectedUpdatedTenantSmsEnabledTotpDisabled.tenantId = createdTenantId;
20142031
const updateRequestSMSEnabledTOTPDisabled: UpdateTenantRequest = {
@@ -2040,8 +2057,25 @@ describe('admin.auth', () => {
20402057
});
20412058
});
20422059

2043-
=======
2044-
>>>>>>> 50ef232 (Recapcha integ test (#1599))
2060+
it('updateTenant() enable Account Defender should be rejected when tenant reCAPTCHA is disabled',
2061+
function () {
2062+
// Skipping for now as Emulator resolves this operation, which is not expected.
2063+
// TODO: investigate with Rest API and Access team for this behavior.
2064+
if (authEmulatorHost) {
2065+
return this.skip();
2066+
}
2067+
expectedUpdatedTenant.tenantId = createdTenantId;
2068+
const updatedOptions: UpdateTenantRequest = {
2069+
displayName: expectedUpdatedTenant2.displayName,
2070+
recaptchaConfig: {
2071+
emailPasswordEnforcementState: 'OFF',
2072+
useAccountDefender: true,
2073+
},
2074+
};
2075+
return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions)
2076+
.should.eventually.be.rejected.and.have.property('code', 'auth/racaptcha-not-enabled');
2077+
});
2078+
20452079
it('updateTenant() should be able to enable/disable anon provider', async () => {
20462080
const tenantManager = getAuth().tenantManager();
20472081
let tenant = await tenantManager.createTenant({

test/unit/auth/project-config.spec.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ describe('ProjectConfig', () => {
8888
type: 'WEB',
8989
key: 'test-key-1' }
9090
],
91+
useAccountDefender: true,
9192
}
9293
};
9394

@@ -97,7 +98,8 @@ describe('ProjectConfig', () => {
9798
managedRules: [ {
9899
endScore: 0.2,
99100
action: 'BLOCK'
100-
} ]
101+
} ],
102+
useAccountDefender: true,
101103
}
102104
};
103105

@@ -202,6 +204,17 @@ describe('ProjectConfig', () => {
202204
}).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be either "OFF", "AUDIT" or "ENFORCE".');
203205
});
204206

207+
const invalidUseAccountDefender = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop];
208+
invalidUseAccountDefender.forEach((useAccountDefender) => {
209+
it(`should throw given invalid useAccountDefender parameter: ${JSON.stringify(useAccountDefender)}`, () => {
210+
const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any;
211+
configOptionsClientRequest.recaptchaConfig.useAccountDefender = useAccountDefender;
212+
expect(() => {
213+
ProjectConfig.buildServerRequest(configOptionsClientRequest);
214+
}).to.throw('"RecaptchaConfig.useAccountDefender" must be a boolean value".');
215+
});
216+
});
217+
205218
it('should throw on non-array managedRules attribute', () => {
206219
const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any;
207220
configOptionsClientRequest.recaptchaConfig.managedRules = 'non-array';
@@ -291,6 +304,7 @@ describe('ProjectConfig', () => {
291304
type: 'WEB',
292305
key: 'test-key-1' }
293306
],
307+
useAccountDefender: true,
294308
}
295309
);
296310
expect(projectConfig.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig);
@@ -313,6 +327,7 @@ describe('ProjectConfig', () => {
313327
delete serverResponseOptionalCopy.mfa;
314328
delete serverResponseOptionalCopy.recaptchaConfig?.emailPasswordEnforcementState;
315329
delete serverResponseOptionalCopy.recaptchaConfig?.managedRules;
330+
delete serverResponseOptionalCopy.recaptchaConfig?.useAccountDefender;
316331

317332
expect(new ProjectConfig(serverResponseOptionalCopy).toJSON()).to.deep.equal({
318333
recaptchaConfig: {

test/unit/auth/tenant.spec.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ describe('Tenant', () => {
132132
type: 'WEB',
133133
key: 'test-key-1' }
134134
],
135+
useAccountDefender: true,
135136
},
136137
smsRegionConfig: smsAllowByDefault,
137138
};
@@ -155,7 +156,8 @@ describe('Tenant', () => {
155156
endScore: 0.2,
156157
action: 'BLOCK'
157158
}],
158-
emailPasswordEnforcementState: 'AUDIT'
159+
emailPasswordEnforcementState: 'AUDIT',
160+
useAccountDefender: true,
159161
},
160162
};
161163

@@ -243,6 +245,14 @@ describe('Tenant', () => {
243245
}).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".');
244246
});
245247

248+
it('should throw on non-boolean useAccountDefender attribute', () => {
249+
const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any;
250+
tenantOptionsClientRequest.recaptchaConfig.useAccountDefender = 'yes';
251+
expect(() => {
252+
Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest);
253+
}).to.throw('"RecaptchaConfig.useAccountDefender" must be a boolean value".');
254+
});
255+
246256
it('should throw on invalid managedRules attribute', () => {
247257
const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any;
248258
tenantOptionsClientRequest.recaptchaConfig.managedRules =
@@ -450,6 +460,17 @@ describe('Tenant', () => {
450460
}).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".');
451461
});
452462

463+
const invalidUseAccountDefender = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop];
464+
invalidUseAccountDefender.forEach((useAccountDefender) => {
465+
it('should throw on non-boolean useAccountDefender attribute', () => {
466+
const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any;
467+
tenantOptionsClientRequest.recaptchaConfig.useAccountDefender = useAccountDefender;
468+
expect(() => {
469+
Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest);
470+
}).to.throw('"RecaptchaConfig.useAccountDefender" must be a boolean value".');
471+
});
472+
});
473+
453474
it('should throw on invalid managedRules attribute', () => {
454475
const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any;
455476
tenantOptionsClientRequest.recaptchaConfig.managedRules =
@@ -645,6 +666,7 @@ describe('Tenant', () => {
645666
type: 'WEB',
646667
key: 'test-key-1' }
647668
],
669+
useAccountDefender: true,
648670
});
649671
expect(tenantWithRecaptcha.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig);
650672
});

0 commit comments

Comments
 (0)