From be100b7bbb6ae9780574ba0b2f7e4be039d49ac0 Mon Sep 17 00:00:00 2001 From: Liubin Jiang <56564857+Xiaoshouzi-gh@users.noreply.github.com> Date: Mon, 14 Feb 2022 13:45:55 -0800 Subject: [PATCH 01/10] Defined reCAPTCHA config. (#1574) * Defined reCAPTCHA config. - Added reCAPTCHA protection states. - Added reCAPTCHA action rule. - Added reCAPTCHA key config. --- src/auth/auth-config.ts | 65 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 45ca3ef2d0..2aaf9a9d48 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1594,3 +1594,68 @@ export class SmsRegionsAuthConfig { } } } + +/** + * Enforcement state of reCAPTCHA protection. + * - 'OFF': Unenforced. + * - 'AUDIT': Assessment is created but result is not used to enforce. + * - 'ENFORCE': Assessment is created and result is used to enforce. + */ +export type RecaptchaProviderEnforcementState = 'OFF' | 'AUDIT' | 'ENFORCE'; + +/** + * The actions for reCAPTCHA-protected requests. + * - 'BLOCK': The reCAPTCHA-protected request will be blocked. + */ +export type RecaptchaAction = 'BLOCK'; + +/** + * The config for a reCAPTCHA action rule. + */ +export interface RecaptchaManagedRule { + /** + * The action will be enforced if the reCAPTCHA score of a request is larger than endScore. + */ + endScore: number; + /** + * The action for reCAPTCHA-protected requests. + */ + action?: RecaptchaAction; +} + +/** + * The key's platform type: only web supported now. + */ +export type RecaptchaKeyClientType = 'WEB'; + +/** + * The reCAPTCHA key config. + */ +export interface RecaptchaKey { + /** + * The key's client platform type. + */ + type?: RecaptchaKeyClientType; + + /** + * The reCAPTCHA site key. + */ + key: string; +} + +export interface RecaptchaConfig { + /** + * The enforcement state of email password provider. + */ + emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; + + /** + * The reCAPTCHA managed rules. + */ + managedRules: RecaptchaManagedRule[]; + + /** + * The reCAPTCHA keys. + */ + recaptchaKeys?: RecaptchaKey[]; +} From 7e90c9ab71502b55b96349b42f0328471c3a120c Mon Sep 17 00:00:00 2001 From: Liubin Jiang <56564857+Xiaoshouzi-gh@users.noreply.github.com> Date: Mon, 7 Mar 2022 13:38:34 -0800 Subject: [PATCH 02/10] Create/Update tenant with ReCAPTCHA Config (#1586) * Support reCaptcha config /create update on tenants. - Support create and update tenants with reCaptcha config. - Added reCaptcha unit tests on tenants operations. --- etc/firebase-admin.auth.api.md | 30 +++++ src/auth/auth-config.ts | 156 ++++++++++++++++++++++++-- src/auth/index.ts | 6 + src/auth/tenant.ts | 35 +++++- test/unit/auth/tenant.spec.ts | 195 ++++++++++++++++++++++++++++++++- 5 files changed, 406 insertions(+), 16 deletions(-) diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index c7090af304..4b2f74ad3e 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -354,6 +354,34 @@ export interface ProviderIdentifier { providerUid: string; } +// @public +export type RecaptchaAction = 'BLOCK'; + +// @public +export interface RecaptchaConfig { + emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; + managedRules?: RecaptchaManagedRule[]; + recaptchaKeys?: RecaptchaKey[]; +} + +// @public +export interface RecaptchaKey { + key: string; + type?: RecaptchaKeyClientType; +} + +// @public +export type RecaptchaKeyClientType = 'WEB'; + +// @public +export interface RecaptchaManagedRule { + action?: RecaptchaAction; + endScore: number; +} + +// @public +export type RecaptchaProviderEnforcementState = 'OFF' | 'AUDIT' | 'ENFORCE'; + // @public export interface SAMLAuthProviderConfig extends BaseAuthProviderConfig { callbackURL?: string; @@ -390,6 +418,7 @@ export class Tenant { get emailSignInConfig(): EmailSignInProviderConfig | undefined; get multiFactorConfig(): MultiFactorConfig | undefined; readonly smsRegionConfig?: SmsRegionConfig; + get recaptchaConfig(): RecaptchaConfig | undefined; readonly tenantId: string; readonly testPhoneNumbers?: { [phoneNumber: string]: string; @@ -458,6 +487,7 @@ export interface UpdateTenantRequest { emailSignInConfig?: EmailSignInProviderConfig; multiFactorConfig?: MultiFactorConfig; smsRegionConfig?: SmsRegionConfig; + recaptchaConfig?: RecaptchaConfig; testPhoneNumbers?: { [phoneNumber: string]: string; } | null; diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 2aaf9a9d48..b382de53c6 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1640,22 +1640,156 @@ export interface RecaptchaKey { /** * The reCAPTCHA site key. */ - key: string; + key: string; } +/** + * The request interface for updating a reCAPTCHA Config. + */ export interface RecaptchaConfig { - /** + /** * The enforcement state of email password provider. */ - emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; + emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; + /** + * The reCAPTCHA managed rules. + */ + managedRules?: RecaptchaManagedRule[]; - /** - * The reCAPTCHA managed rules. - */ - managedRules: RecaptchaManagedRule[]; + /** + * The reCAPTCHA keys. + */ + recaptchaKeys?: RecaptchaKey[]; +} - /** - * The reCAPTCHA keys. - */ - recaptchaKeys?: RecaptchaKey[]; +export class RecaptchaAuthConfig implements RecaptchaConfig { + public readonly emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; + public readonly managedRules?: RecaptchaManagedRule[]; + public readonly recaptchaKeys?: RecaptchaKey[]; + + constructor(recaptchaConfig: RecaptchaConfig) { + this.emailPasswordEnforcementState = recaptchaConfig.emailPasswordEnforcementState; + this.managedRules = recaptchaConfig.managedRules; + this.recaptchaKeys = recaptchaConfig.recaptchaKeys; + } + + /** + * Validates the RecaptchaConfig options object. Throws an error on failure. + * @param options - The options object to validate. + */ + public static validate(options: RecaptchaConfig): void { + const validKeys = { + emailPasswordEnforcementState: true, + managedRules: true, + recaptchaKeys: true, + }; + + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaConfig" must be a non-null object.', + ); + } + + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid RecaptchaConfig parameter.`, + ); + } + } + + // Validation + if (typeof options.emailPasswordEnforcementState !== undefined) { + if (!validator.isNonEmptyString(options.emailPasswordEnforcementState)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"RecaptchaConfig.emailPasswordEnforcementState" must be a valid non-empty string.', + ); + } + + if (options.emailPasswordEnforcementState !== 'OFF' && + options.emailPasswordEnforcementState !== 'AUDIT' && + options.emailPasswordEnforcementState !== 'ENFORCE') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaConfig.emailPasswordEnforcementState" must be either "OFF", "AUDIT" or "ENFORCE".', + ); + } + } + + if (typeof options.managedRules !== 'undefined') { + // Validate array + if (!validator.isArray(options.managedRules)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".', + ); + } + // Validate each rule of the array + options.managedRules.forEach((managedRule) => { + RecaptchaAuthConfig.validateManagedRule(managedRule); + }); + } + } + + /** + * Validate each element in ManagedRule array + * @param options - The options object to validate. + */ + private static validateManagedRule(options: RecaptchaManagedRule): void { + const validKeys = { + endScore: true, + action: true, + } + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaManagedRule" must be a non-null object.', + ); + } + // Check for unsupported top level attributes. + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid RecaptchaManagedRule parameter.`, + ); + } + } + + // Validate content. + if (typeof options.action !== 'undefined' && + options.action !== 'BLOCK') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaManagedRule.action" must be "BLOCK".', + ); + } + } + + /** + * Returns a JSON-serializable representation of this object. + * @returns The JSON-serializable object representation of the ReCaptcha config instance + */ + public toJSON(): object { + const json: any = { + emailPasswordEnforcementState: this.emailPasswordEnforcementState, + managedRules: deepCopy(this.managedRules), + recaptchaKeys: deepCopy(this.recaptchaKeys) + } + + if (typeof json.emailPasswordEnforcementState === 'undefined') { + delete json.emailPasswordEnforcementState; + } + if (typeof json.managedRules === 'undefined') { + delete json.managedRules; + } + if (typeof json.recaptchaKeys === 'undefined') { + delete json.recaptchaKeys; + } + + return json; + } } diff --git a/src/auth/index.ts b/src/auth/index.ts index 7dec658473..7be7fe8c65 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -83,6 +83,12 @@ export { OAuthResponseType, OIDCAuthProviderConfig, OIDCUpdateAuthProviderRequest, + RecaptchaAction, + RecaptchaConfig, + RecaptchaKey, + RecaptchaKeyClientType, + RecaptchaManagedRule, + RecaptchaProviderEnforcementState, SAMLAuthProviderConfig, SAMLUpdateAuthProviderRequest, SmsRegionConfig, diff --git a/src/auth/tenant.ts b/src/auth/tenant.ts index 56cf2abd8d..d8fdd80e27 100644 --- a/src/auth/tenant.ts +++ b/src/auth/tenant.ts @@ -21,7 +21,7 @@ import { AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; import { EmailSignInConfig, EmailSignInConfigServerRequest, MultiFactorAuthServerConfig, MultiFactorConfig, validateTestPhoneNumbers, EmailSignInProviderConfig, - MultiFactorAuthConfig, SmsRegionConfig, SmsRegionsAuthConfig + MultiFactorAuthConfig, SmsRegionConfig, SmsRegionsAuthConfig, RecaptchaAuthConfig, RecaptchaConfig } from './auth-config'; /** @@ -59,6 +59,11 @@ export interface UpdateTenantRequest { * The SMS configuration to update on the project. */ smsRegionConfig?: SmsRegionConfig; + + /** + * The recaptcha configuration to update on the tenant. + */ + recaptchaConfig?: RecaptchaConfig; } /** @@ -74,6 +79,7 @@ export interface TenantOptionsServerRequest extends EmailSignInConfigServerReque mfaConfig?: MultiFactorAuthServerConfig; testPhoneNumbers?: {[key: string]: string}; smsRegionConfig?: SmsRegionConfig; + recaptchaConfig?: RecaptchaConfig; } /** The tenant server response interface. */ @@ -86,6 +92,7 @@ export interface TenantServerResponse { mfaConfig?: MultiFactorAuthServerConfig; testPhoneNumbers?: {[key: string]: string}; smsRegionConfig?: SmsRegionConfig; + recaptchaConfig? : RecaptchaConfig; } /** @@ -130,6 +137,10 @@ export class Tenant { private readonly emailSignInConfig_?: EmailSignInConfig; private readonly multiFactorConfig_?: MultiFactorAuthConfig; + /* + * The map conatining the reCAPTCHA config. + */ + private readonly recaptchaConfig_?: RecaptchaAuthConfig; /** * The SMS Regions Config to update a tenant. * Configures the regions where users are allowed to send verification SMS. @@ -169,6 +180,9 @@ export class Tenant { if (typeof tenantOptions.smsRegionConfig !== 'undefined') { request.smsRegionConfig = tenantOptions.smsRegionConfig; } + if (typeof tenantOptions.recaptchaConfig !== 'undefined') { + request.recaptchaConfig = tenantOptions.recaptchaConfig; + } return request; } @@ -203,6 +217,7 @@ export class Tenant { multiFactorConfig: true, testPhoneNumbers: true, smsRegionConfig: true, + recaptchaConfig: true, }; const label = createRequest ? 'CreateTenantRequest' : 'UpdateTenantRequest'; if (!validator.isNonNullObject(request)) { @@ -253,6 +268,10 @@ export class Tenant { if (typeof request.smsRegionConfig != 'undefined') { SmsRegionsAuthConfig.validate(request.smsRegionConfig); } + // Validate reCAPTCHAConfig type if provided. + if (typeof request.recaptchaConfig !== 'undefined') { + RecaptchaAuthConfig.validate(request.recaptchaConfig); + } } /** @@ -290,6 +309,9 @@ export class Tenant { if (typeof response.smsRegionConfig !== 'undefined') { this.smsRegionConfig = deepCopy(response.smsRegionConfig); } + if (typeof response.recaptchaConfig !== 'undefined') { + this.recaptchaConfig_ = new RecaptchaAuthConfig(response.recaptchaConfig); + } } /** @@ -306,6 +328,13 @@ export class Tenant { return this.multiFactorConfig_; } + /** + * The recaptcha config auth configuration of the current tenant. + */ + get recaptchaConfig(): RecaptchaConfig | undefined { + return this.recaptchaConfig_; + } + /** * Returns a JSON-serializable representation of this object. * @@ -320,6 +349,7 @@ export class Tenant { anonymousSignInEnabled: this.anonymousSignInEnabled, testPhoneNumbers: this.testPhoneNumbers, smsRegionConfig: deepCopy(this.smsRegionConfig), + recaptchaConfig: this.recaptchaConfig_?.toJSON(), }; if (typeof json.multiFactorConfig === 'undefined') { delete json.multiFactorConfig; @@ -330,6 +360,9 @@ export class Tenant { if (typeof json.smsRegionConfig === 'undefined') { delete json.smsRegionConfig; } + if (typeof json.recaptchaConfig === 'undefined') { + delete json.recaptchaConfig; + } return json; } } diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index 44885ecafa..94ac86099e 100644 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -20,7 +20,7 @@ import * as sinonChai from 'sinon-chai'; import * as chaiAsPromised from 'chai-as-promised'; import { deepCopy } from '../../../src/utils/deep-copy'; -import { EmailSignInConfig, MultiFactorAuthConfig } from '../../../src/auth/auth-config'; +import { EmailSignInConfig, MultiFactorAuthConfig, RecaptchaAuthConfig } from '../../../src/auth/auth-config'; import { TenantServerResponse } from '../../../src/auth/tenant'; import { CreateTenantRequest, UpdateTenantRequest, EmailSignInProviderConfig, Tenant, @@ -93,6 +93,55 @@ describe('Tenant', () => { }, }; + const serverResponseWithRecaptcha: TenantServerResponse = { + name: 'projects/project1/tenants/TENANT-ID', + displayName: 'TENANT-DISPLAY-NAME', + allowPasswordSignup: true, + enableEmailLinkSignin: true, + mfaConfig: { + state: 'ENABLED', + enabledProviders: ['PHONE_SMS'], + }, + testPhoneNumbers: { + '+16505551234': '019287', + '+16505550676': '985235', + }, + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [ { + endScore: 0.2, + action: 'BLOCK' + } ], + recaptchaKeys: [ { + type: 'WEB', + key: 'test-key-1' } + ], + } + }; + + const clientRequestWithRecaptcha: UpdateTenantRequest = { + displayName: 'TENANT-DISPLAY-NAME', + emailSignInConfig: { + enabled: true, + passwordRequired: false, + }, + multiFactorConfig: { + state: 'ENABLED', + factorIds: ['phone'], + }, + testPhoneNumbers: { + '+16505551234': '019287', + '+16505550676': '985235', + }, + recaptchaConfig: { + managedRules: [{ + endScore: 0.2, + action: 'BLOCK' + }], + emailPasswordEnforcementState: 'AUDIT' + }, + }; + describe('buildServerRequest()', () => { const createRequest = true; @@ -136,6 +185,65 @@ describe('Tenant', () => { }).to.throw('"MultiFactorConfig.state" must be either "ENABLED" or "DISABLED".'); }); + it('should throw on null RecaptchaConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig = null; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"RecaptchaConfig" must be a non-null object.'); + }); + + it('should throw on invalid RecaptchaConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.invalidParameter = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"invalidParameter" is not a valid RecaptchaConfig parameter.'); + }); + + it('should throw on null emailPasswordEnforcementState attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.emailPasswordEnforcementState = null; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be a valid non-empty string.'); + }); + + it('should throw on invalid emailPasswordEnforcementState attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig + .emailPasswordEnforcementState = 'INVALID'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be either "OFF", "AUDIT" or "ENFORCE".'); + }); + + it('should throw on non-array managedRules attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.managedRules = 'non-array'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".'); + }); + + it('should throw on invalid managedRules attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.managedRules = + [{ 'score': 0.1, 'action': 'BLOCK' }]; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"score" is not a valid RecaptchaManagedRule parameter.'); + }); + + it('should throw on invalid RecaptchaManagedRule.action attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.managedRules = + [{ 'endScore': 0.1, 'action': 'ALLOW' }]; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"RecaptchaManagedRule.action" must be "BLOCK".'); + }); + it('should throw on invalid testPhoneNumbers attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; tenantOptionsClientRequest.testPhoneNumbers = 'invalid'; @@ -214,7 +322,7 @@ describe('Tenant', () => { }); it('should not throw on valid client request object', () => { - const tenantOptionsClientRequest = deepCopy(clientRequest); + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha); expect(() => { Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); }).not.to.throw; @@ -284,6 +392,65 @@ describe('Tenant', () => { }).to.throw('"invalid" is not a valid "AuthFactorType".',); }); + it('should throw on null RecaptchaConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig = null; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"RecaptchaConfig" must be a non-null object.'); + }); + + it('should throw on invalid RecaptchaConfig attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.invalidParameter = 'invalid'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"invalidParameter" is not a valid RecaptchaConfig parameter.'); + }); + + it('should throw on null emailPasswordEnforcementState attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.emailPasswordEnforcementState = null; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be a valid non-empty string.'); + }); + + it('should throw on invalid emailPasswordEnforcementState attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig + .emailPasswordEnforcementState = 'INVALID'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be either "OFF", "AUDIT" or "ENFORCE".'); + }); + + it('should throw on non-array managedRules attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.managedRules = 'non-array'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".'); + }); + + it('should throw on invalid managedRules attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.managedRules = + [{ 'score': 0.1, 'action': 'BLOCK' }]; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"score" is not a valid RecaptchaManagedRule parameter.'); + }); + + it('should throw on invalid RecaptchaManagedRule.action attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.managedRules = + [{ 'endScore': 0.1, 'action': 'ALLOW' }]; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"RecaptchaManagedRule.action" must be "BLOCK".'); + }); + it('should throw on invalid testPhoneNumbers attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequest) as any; tenantOptionsClientRequest.testPhoneNumbers = { 'invalid': '123456' }; @@ -439,6 +606,24 @@ describe('Tenant', () => { expect(tenant.multiFactorConfig).to.deep.equal(expectedMultiFactorConfig); }); + it('should set readonly property recaptchaConfig', () => { + const serverRequestWithRecaptchaCopy: TenantServerResponse = + deepCopy(serverResponseWithRecaptcha); + const tenantWithRecaptcha = new Tenant(serverRequestWithRecaptchaCopy); + const expectedRecaptchaConfig = new RecaptchaAuthConfig({ + emailPasswordEnforcementState: 'AUDIT', + managedRules: [{ + endScore: 0.2, + action: 'BLOCK' + }], + recaptchaKeys: [ { + type: 'WEB', + key: 'test-key-1' } + ], + }); + expect(tenantWithRecaptcha.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig); + }); + it('should set readonly property testPhoneNumbers', () => { expect(tenant.testPhoneNumbers).to.deep.equal( deepCopy(clientRequest.testPhoneNumbers)); @@ -475,7 +660,7 @@ describe('Tenant', () => { }); describe('toJSON()', () => { - const serverRequestCopy: TenantServerResponse = deepCopy(serverRequest); + const serverRequestCopy: TenantServerResponse = deepCopy(serverResponseWithRecaptcha); it('should return the expected object representation of a tenant', () => { expect(new Tenant(serverRequestCopy).toJSON()).to.deep.equal({ tenantId: 'TENANT-ID', @@ -488,14 +673,16 @@ describe('Tenant', () => { multiFactorConfig: deepCopy(clientRequest.multiFactorConfig), testPhoneNumbers: deepCopy(clientRequest.testPhoneNumbers), smsRegionConfig: deepCopy(clientRequest.smsRegionConfig), + recaptchaConfig: deepCopy(serverResponseWithRecaptcha.recaptchaConfig), }); }); it('should not populate optional fields if not available', () => { - const serverRequestCopyWithoutMfa: TenantServerResponse = deepCopy(serverRequest); + const serverRequestCopyWithoutMfa: TenantServerResponse = deepCopy(serverResponseWithRecaptcha); delete serverRequestCopyWithoutMfa.mfaConfig; delete serverRequestCopyWithoutMfa.testPhoneNumbers; delete serverRequestCopyWithoutMfa.smsRegionConfig; + delete serverRequestCopyWithoutMfa.recaptchaConfig; expect(new Tenant(serverRequestCopyWithoutMfa).toJSON()).to.deep.equal({ tenantId: 'TENANT-ID', From c39c18957063e183605e36ff739d90bb69f5b4c6 Mon Sep 17 00:00:00 2001 From: Liubin Jiang <56564857+Xiaoshouzi-gh@users.noreply.github.com> Date: Tue, 8 Mar 2022 15:38:31 -0800 Subject: [PATCH 03/10] Project config - Recaptcha config (#1595) * Recaptcha config changes in project config. - Implemented getProjectConfig. - Implemented updateProjectConfig. - Updated error code. - Add Term of Service consents. --- etc/firebase-admin.auth.api.md | 2 + src/auth/auth-config.ts | 3 + src/auth/project-config-manager.ts | 1 - src/auth/project-config.ts | 43 ++++++- src/auth/tenant.ts | 12 +- src/utils/error.ts | 12 ++ test/unit/auth/project-config-manager.spec.ts | 20 +++- test/unit/auth/project-config.spec.ts | 111 +++++++++++++++++- test/unit/auth/tenant.spec.ts | 3 +- 9 files changed, 194 insertions(+), 13 deletions(-) diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index 4b2f74ad3e..81cdf7a871 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -337,6 +337,7 @@ export class PhoneMultiFactorInfo extends MultiFactorInfo { // @public export class ProjectConfig { readonly smsRegionConfig?: SmsRegionConfig; + get recaptchaConfig(): RecaptchaConfig | undefined; toJSON(): object; } @@ -464,6 +465,7 @@ export interface UpdatePhoneMultiFactorInfoRequest extends BaseUpdateMultiFactor // @public export interface UpdateProjectConfigRequest { smsRegionConfig?: SmsRegionConfig; + recaptchaConfig?: RecaptchaConfig; } // @public diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index b382de53c6..57b25182a8 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1645,6 +1645,9 @@ export interface RecaptchaKey { /** * The request interface for updating a reCAPTCHA Config. + * By enabling reCAPTCHA Enterprise Integration you are + * agreeing to reCAPTCHA Enterprise + * {@link https://cloud.google.com/terms/service-terms | Term of Service}. */ export interface RecaptchaConfig { /** diff --git a/src/auth/project-config-manager.ts b/src/auth/project-config-manager.ts index 030b64a779..7c4dcbe7d7 100644 --- a/src/auth/project-config-manager.ts +++ b/src/auth/project-config-manager.ts @@ -27,7 +27,6 @@ import { */ export class ProjectConfigManager { private readonly authRequestHandler: AuthRequestHandler; - /** * Initializes a ProjectConfigManager instance for a specified FirebaseApp. * diff --git a/src/auth/project-config.ts b/src/auth/project-config.ts index 54dcfb3b9c..29ee15f033 100644 --- a/src/auth/project-config.ts +++ b/src/auth/project-config.ts @@ -18,6 +18,8 @@ import { AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; import { SmsRegionsAuthConfig, SmsRegionConfig, + RecaptchaConfig, + RecaptchaAuthConfig, } from './auth-config'; import { deepCopy } from '../utils/deep-copy'; @@ -29,6 +31,13 @@ export interface UpdateProjectConfigRequest { * The SMS configuration to update on the project. */ smsRegionConfig?: SmsRegionConfig; + /** + * The recaptcha configuration to update on the project. + * By enabling reCAPTCHA Enterprise Integration you are + * agreeing to reCAPTCHA Enterprise + * {@link https://cloud.google.com/terms/service-terms | Term of Service}. + */ + recaptchaConfig?: RecaptchaConfig; } /** @@ -37,6 +46,7 @@ export interface UpdateProjectConfigRequest { */ export interface ProjectConfigServerResponse { smsRegionConfig?: SmsRegionConfig; + recaptchaConfig?: RecaptchaConfig; } /** @@ -45,6 +55,7 @@ export interface ProjectConfigServerResponse { */ export interface ProjectConfigClientRequest { smsRegionConfig?: SmsRegionConfig; + recaptchaConfig?: RecaptchaConfig; } /** @@ -57,6 +68,13 @@ export class ProjectConfig { * This is based on the calling code of the destination phone number. */ public readonly smsRegionConfig?: SmsRegionConfig; + /** + * The recaptcha configuration to update on the project config. + * By enabling reCAPTCHA Enterprise Integration you are + * agreeing to reCAPTCHA Enterprise + * {@link https://cloud.google.com/terms/service-terms | Term of Service}. + */ + private readonly recaptchaConfig_?: RecaptchaAuthConfig; /** * Validates a project config options object. Throws an error on failure. @@ -72,6 +90,7 @@ export class ProjectConfig { } const validKeys = { smsRegionConfig: true, + recaptchaConfig: true, } // Check for unsupported top level attributes. for (const key in request) { @@ -86,20 +105,31 @@ export class ProjectConfig { if (typeof request.smsRegionConfig !== 'undefined') { SmsRegionsAuthConfig.validate(request.smsRegionConfig); } + + // Validate reCAPTCHA config attribute. + if (typeof request.recaptchaConfig !== 'undefined') { + RecaptchaAuthConfig.validate(request.recaptchaConfig); + } } /** * Build the corresponding server request for a UpdateProjectConfigRequest object. * @param configOptions - The properties to convert to a server request. * @returns The equivalent server request. - * + * * @internal */ public static buildServerRequest(configOptions: UpdateProjectConfigRequest): ProjectConfigClientRequest { - ProjectConfig.validate(configOptions); + ProjectConfig.validate(configOptions); return configOptions as ProjectConfigClientRequest; } - + + /** + * The recaptcha configuration. + */ + get recaptchaConfig(): RecaptchaConfig | undefined { + return this.recaptchaConfig_; + } /** * The Project Config object constructor. * @@ -111,6 +141,9 @@ export class ProjectConfig { if (typeof response.smsRegionConfig !== 'undefined') { this.smsRegionConfig = response.smsRegionConfig; } + if (typeof response.recaptchaConfig !== 'undefined') { + this.recaptchaConfig_ = new RecaptchaAuthConfig(response.recaptchaConfig); + } } /** * Returns a JSON-serializable representation of this object. @@ -121,10 +154,14 @@ export class ProjectConfig { // JSON serialization const json = { smsRegionConfig: deepCopy(this.smsRegionConfig), + recaptchaConfig: this.recaptchaConfig_?.toJSON(), }; if (typeof json.smsRegionConfig === 'undefined') { delete json.smsRegionConfig; } + if (typeof json.recaptchaConfig === 'undefined') { + delete json.recaptchaConfig; + } return json; } } diff --git a/src/auth/tenant.ts b/src/auth/tenant.ts index d8fdd80e27..5244793bfc 100644 --- a/src/auth/tenant.ts +++ b/src/auth/tenant.ts @@ -62,6 +62,9 @@ export interface UpdateTenantRequest { /** * The recaptcha configuration to update on the tenant. + * By enabling reCAPTCHA Enterprise Integration you are + * agreeing to reCAPTCHA Enterprise + * {@link https://cloud.google.com/terms/service-terms | Term of Service}. */ recaptchaConfig?: RecaptchaConfig; } @@ -137,9 +140,12 @@ export class Tenant { private readonly emailSignInConfig_?: EmailSignInConfig; private readonly multiFactorConfig_?: MultiFactorAuthConfig; - /* - * The map conatining the reCAPTCHA config. - */ + /** + * The map conatining the reCAPTCHA config. + * By enabling reCAPTCHA Enterprise Integration you are + * agreeing to reCAPTCHA Enterprise + * {@link https://cloud.google.com/terms/service-terms | Term of Service}. + */ private readonly recaptchaConfig_?: RecaptchaAuthConfig; /** * The SMS Regions Config to update a tenant. diff --git a/src/utils/error.ts b/src/utils/error.ts index 6c74748ed1..9ede38af54 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -737,6 +737,14 @@ export class AuthClientErrorCode { code: 'user-not-disabled', message: 'The user must be disabled in order to bulk delete it (or you must pass force=true).', }; + public static INVALID_RECAPTCHA_ACTION = { + code: 'invalid-recaptcha-action', + message: 'reCAPTCHA action must be "BLOCK".' + } + public static INVALID_RECAPTCHA_ENFORCEMENT_STATE = { + code: 'invalid-recaptcha-enforcement-state', + message: 'reCAPTCHA enforcement state must be either "OFF", "AUDIT" or "ENFORCE".' + } } /** @@ -996,6 +1004,10 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { USER_DISABLED: 'USER_DISABLED', // Password provided is too weak. WEAK_PASSWORD: 'INVALID_PASSWORD', + // Unrecognized reCAPTCHA action. + INVALID_RECAPTCHA_ACTION: 'INVALID_RECAPTCHA_ACTION', + // Unrecognized reCAPTCHA enforcement state. + INVALID_RECAPTCHA_ENFORCEMENT_STATE: 'INVALID_RECAPTCHA_ENFORCEMENT_STATE', }; /** @const {ServerToClientCode} Messaging server to client enum error codes. */ diff --git a/test/unit/auth/project-config-manager.spec.ts b/test/unit/auth/project-config-manager.spec.ts index d06b24fa80..3fc0770b36 100644 --- a/test/unit/auth/project-config-manager.spec.ts +++ b/test/unit/auth/project-config-manager.spec.ts @@ -51,6 +51,17 @@ describe('ProjectConfigManager', () => { allowedRegions: [ 'AC', 'AD' ], }, }, + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [ { + endScore: 0.2, + action: 'BLOCK' + } ], + recaptchaKeys: [ { + type: 'WEB', + key: 'test-key-1' } + ], + } }; before(() => { @@ -131,6 +142,13 @@ describe('ProjectConfigManager', () => { disallowedRegions: [ 'AC', 'AD' ], }, }, + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [ { + endScore: 0.2, + action: 'BLOCK' + } ], + } }; const expectedProjectConfig = new ProjectConfig(GET_CONFIG_RESPONSE); const expectedError = new FirebaseAuthError( @@ -193,4 +211,4 @@ describe('ProjectConfigManager', () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/test/unit/auth/project-config.spec.ts b/test/unit/auth/project-config.spec.ts index 19cc8f420d..3d2cdfb2db 100644 --- a/test/unit/auth/project-config.spec.ts +++ b/test/unit/auth/project-config.spec.ts @@ -20,6 +20,7 @@ import * as sinonChai from 'sinon-chai'; import * as chaiAsPromised from 'chai-as-promised'; import { deepCopy } from '../../../src/utils/deep-copy'; +import { RecaptchaAuthConfig } from '../../../src/auth/auth-config'; import { ProjectConfig, ProjectConfigServerResponse, @@ -66,6 +67,27 @@ describe('ProjectConfig', () => { disallowedRegions: ['AC', 'AD'], }, }, + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [ { + endScore: 0.2, + action: 'BLOCK' + } ], + recaptchaKeys: [ { + type: 'WEB', + key: 'test-key-1' } + ], + } + }; + + const updateProjectConfigRequest: UpdateProjectConfigRequest = { + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [ { + endScore: 0.2, + action: 'BLOCK' + } ] + } }; describe('buildServerRequest()', () => { @@ -136,6 +158,64 @@ describe('ProjectConfig', () => { ProjectConfig.buildServerRequest(configOptionsClientRequest2); }).not.to.throw; }); + it('should throw on null RecaptchaConfig attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig = null; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"RecaptchaConfig" must be a non-null object.'); + }); + + it('should throw on invalid RecaptchaConfig attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig.invalidParameter = 'invalid'; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"invalidParameter" is not a valid RecaptchaConfig parameter.'); + }); + + it('should throw on null emailPasswordEnforcementState attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig.emailPasswordEnforcementState = null; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be a valid non-empty string.'); + }); + + it('should throw on invalid emailPasswordEnforcementState attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig + .emailPasswordEnforcementState = 'INVALID'; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be either "OFF", "AUDIT" or "ENFORCE".'); + }); + + it('should throw on non-array managedRules attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig.managedRules = 'non-array'; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".'); + }); + + it('should throw on invalid managedRules attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig.managedRules = + [{ 'score': 0.1, 'action': 'BLOCK' }]; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"score" is not a valid RecaptchaManagedRule parameter.'); + }); + + it('should throw on invalid RecaptchaManagedRule.action attribute', () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig.managedRules = + [{ 'endScore': 0.1, 'action': 'ALLOW' }]; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"RecaptchaManagedRule.action" must be "BLOCK".'); + }); const nonObjects = [null, NaN, 0, 1, true, false, '', 'a', [], [1, 'a'], _.noop]; nonObjects.forEach((request) => { @@ -147,7 +227,7 @@ describe('ProjectConfig', () => { }); it('should throw on unsupported attribute for update request', () => { - const configOptionsClientRequest = deepCopy(updateProjectConfigRequest1) as any; + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; configOptionsClientRequest.unsupported = 'value'; expect(() => { ProjectConfig.buildServerRequest(configOptionsClientRequest); @@ -172,21 +252,44 @@ describe('ProjectConfig', () => { }; expect(projectConfig.smsRegionConfig).to.deep.equal(expectedSmsRegionConfig); }); + it('should set readonly property recaptchaConfig', () => { + const expectedRecaptchaConfig = new RecaptchaAuthConfig( + { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [ { + endScore: 0.2, + action: 'BLOCK' + } ], + recaptchaKeys: [ { + type: 'WEB', + key: 'test-key-1' } + ], + } + ); + expect(projectConfig.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig); + }); }); describe('toJSON()', () => { const serverResponseCopy: ProjectConfigServerResponse = deepCopy(serverResponse); it('should return the expected object representation of project config', () => { expect(new ProjectConfig(serverResponseCopy).toJSON()).to.deep.equal({ - smsRegionConfig: deepCopy(serverResponse.smsRegionConfig) + smsRegionConfig: deepCopy(serverResponse.smsRegionConfig), + recaptchaConfig: deepCopy(serverResponse.recaptchaConfig) }); }); it('should not populate optional fields if not available', () => { const serverResponseOptionalCopy: ProjectConfigServerResponse = deepCopy(serverResponse); delete serverResponseOptionalCopy.smsRegionConfig; + delete serverResponseOptionalCopy.recaptchaConfig?.emailPasswordEnforcementState; + delete serverResponseOptionalCopy.recaptchaConfig?.managedRules; - expect(new ProjectConfig(serverResponseOptionalCopy).toJSON()).to.deep.equal({}); + expect(new ProjectConfig(serverResponseOptionalCopy).toJSON()).to.deep.equal({ + recaptchaConfig: { + recaptchaKeys: deepCopy(serverResponse.recaptchaConfig?.recaptchaKeys), + } + }); }); }); -}); \ No newline at end of file +}); diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index 94ac86099e..1f2c45a650 100644 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -116,7 +116,8 @@ describe('Tenant', () => { type: 'WEB', key: 'test-key-1' } ], - } + }, + smsRegionConfig: smsAllowByDefault, }; const clientRequestWithRecaptcha: UpdateTenantRequest = { From 50ef23296313bed91c826789ad7401ec82f8ea74 Mon Sep 17 00:00:00 2001 From: Liubin Jiang <56564857+Xiaoshouzi-gh@users.noreply.github.com> Date: Wed, 9 Mar 2022 15:40:26 -0800 Subject: [PATCH 04/10] Recapcha integ test (#1599) * Added integ test for Project Config and Tenants update on reCAPTCHA config --- test/integration/auth.spec.ts | 92 +++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 68e66aaf06..a6951e437a 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -1255,6 +1255,56 @@ describe('admin.auth', () => { }); }); + describe('Project config management operations', () => { + before(function() { + if (authEmulatorHost) { + this.skip(); // getConfig is not supported in Auth Emulator + } + }); + const projectConfigOption1: UpdateProjectConfigRequest = { + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [{ endScore: 0.1, action: 'BLOCK' }], + }, + }; + const projectConfigOption2: UpdateProjectConfigRequest = { + recaptchaConfig: { + emailPasswordEnforcementState: 'OFF', + }, + }; + const expectedProjectConfig1: any = { + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [{ endScore: 0.1, action: 'BLOCK' }], + }, + }; + const expectedProjectConfig2: any = { + recaptchaConfig: { + emailPasswordEnforcementState: 'OFF', + managedRules: [{ endScore: 0.1, action: 'BLOCK' }], + }, + }; + + it('updateProjectConfig() should resolve with the updated project config', () => { + return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption1) + .then((actualProjectConfig) => { + expect(actualProjectConfig.toJSON()).to.deep.equal(expectedProjectConfig1); + return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption2); + }) + .then((actualProjectConfig) => { + expect(actualProjectConfig.toJSON()).to.deep.equal(expectedProjectConfig2); + }); + }); + + it('getProjectConfig() should resolve with expected project config', () => { + return getAuth().projectConfigManager().getProjectConfig() + .then((actualConfig) => { + const actualConfigObj = actualConfig.toJSON(); + expect(actualConfigObj).to.deep.equal(expectedProjectConfig2); + }); + }); + }); + describe('Tenant management operations', () => { let createdTenantId: string; const createdTenants: string[] = []; @@ -1311,6 +1361,15 @@ describe('admin.auth', () => { testPhoneNumbers: { '+16505551234': '123456', }, + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [ + { + endScore: 0.3, + action: 'BLOCK', + }, + ], + }, }; const expectedUpdatedTenant2: any = { displayName: 'testTenantUpdated', @@ -1328,6 +1387,15 @@ describe('admin.auth', () => { disallowedRegions: ['AC', 'AD'], } }, + recaptchaConfig: { + emailPasswordEnforcementState: 'OFF', + managedRules: [ + { + endScore: 0.3, + action: 'BLOCK', + }, + ], + }, }; // https://mochajs.org/ @@ -1740,6 +1808,7 @@ describe('admin.auth', () => { }, multiFactorConfig: deepCopy(expectedUpdatedTenant.multiFactorConfig), testPhoneNumbers: deepCopy(expectedUpdatedTenant.testPhoneNumbers), + recaptchaConfig: deepCopy(expectedUpdatedTenant.recaptchaConfig), }; const updatedOptions2: UpdateTenantRequest = { emailSignInConfig: { @@ -1750,6 +1819,7 @@ describe('admin.auth', () => { // Test clearing of phone numbers. testPhoneNumbers: null, smsRegionConfig: deepCopy(expectedUpdatedTenant2.smsRegionConfig), + recaptchaConfig: deepCopy(expectedUpdatedTenant2.recaptchaConfig), }; if (authEmulatorHost) { return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions) @@ -1801,6 +1871,28 @@ describe('admin.auth', () => { }); }); + it('updateTenant() should not update tenant reCAPTCHA config is undefined', () => { + expectedUpdatedTenant.tenantId = createdTenantId; + const updatedOptions2: UpdateTenantRequest = { + displayName: expectedUpdatedTenant2.displayName, + recaptchaConfig: undefined, + }; + if (authEmulatorHost) { + return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2) + .then((actualTenant) => { + const actualTenantObj = actualTenant.toJSON(); + // Not supported in Auth Emulator + delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; + delete expectedUpdatedTenant2.testPhoneNumbers; + expect(actualTenantObj).to.deep.equal(expectedUpdatedTenant2); + }); + } + return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2) + .then((actualTenant) => { + expect(actualTenant.toJSON()).to.deep.equal(expectedUpdatedTenant2); + }); + }); + it('updateTenant() should be able to enable/disable anon provider', async () => { const tenantManager = getAuth().tenantManager(); let tenant = await tenantManager.createTenant({ From f3094c81905ec7e791618b7c5045e3d6ad4a03d7 Mon Sep 17 00:00:00 2001 From: Liubin Jiang <56564857+Xiaoshouzi-gh@users.noreply.github.com> Date: Tue, 3 May 2022 12:14:58 -0700 Subject: [PATCH 05/10] Account defender support for reCAPTCHA (#1616) * Support use_account_defender add-on feature for reCAPTCHA config. * Added integration test for account defender feature --- etc/firebase-admin.auth.api.md | 1 + src/auth/auth-config.ts | 25 +++++++++++++++++- src/utils/error.ts | 6 +++++ test/integration/auth.spec.ts | 38 +++++++++++++++++++++++++++ test/unit/auth/project-config.spec.ts | 17 +++++++++++- test/unit/auth/tenant.spec.ts | 24 ++++++++++++++++- 6 files changed, 108 insertions(+), 3 deletions(-) diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index 81cdf7a871..3e6e01affd 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -363,6 +363,7 @@ export interface RecaptchaConfig { emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; managedRules?: RecaptchaManagedRule[]; recaptchaKeys?: RecaptchaKey[]; + useAccountDefender?: boolean; } // @public diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 57b25182a8..a69f1e3a82 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1663,17 +1663,25 @@ export interface RecaptchaConfig { * The reCAPTCHA keys. */ recaptchaKeys?: RecaptchaKey[]; + + /** + * Whether to use account defender for reCAPTCHA assessment. + * The default value is false. + */ + useAccountDefender?: boolean; } export class RecaptchaAuthConfig implements RecaptchaConfig { public readonly emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; public readonly managedRules?: RecaptchaManagedRule[]; public readonly recaptchaKeys?: RecaptchaKey[]; + public readonly useAccountDefender?: boolean; constructor(recaptchaConfig: RecaptchaConfig) { this.emailPasswordEnforcementState = recaptchaConfig.emailPasswordEnforcementState; this.managedRules = recaptchaConfig.managedRules; this.recaptchaKeys = recaptchaConfig.recaptchaKeys; + this.useAccountDefender = recaptchaConfig.useAccountDefender; } /** @@ -1685,6 +1693,7 @@ export class RecaptchaAuthConfig implements RecaptchaConfig { emailPasswordEnforcementState: true, managedRules: true, recaptchaKeys: true, + useAccountDefender: true, }; if (!validator.isNonNullObject(options)) { @@ -1735,6 +1744,15 @@ export class RecaptchaAuthConfig implements RecaptchaConfig { RecaptchaAuthConfig.validateManagedRule(managedRule); }); } + + if (typeof options.useAccountDefender != 'undefined') { + if (!validator.isBoolean(options.useAccountDefender)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaConfig.useAccountDefender" must be a boolean value".', + ); + } + } } /** @@ -1780,7 +1798,8 @@ export class RecaptchaAuthConfig implements RecaptchaConfig { const json: any = { emailPasswordEnforcementState: this.emailPasswordEnforcementState, managedRules: deepCopy(this.managedRules), - recaptchaKeys: deepCopy(this.recaptchaKeys) + recaptchaKeys: deepCopy(this.recaptchaKeys), + useAccountDefender: this.useAccountDefender, } if (typeof json.emailPasswordEnforcementState === 'undefined') { @@ -1793,6 +1812,10 @@ export class RecaptchaAuthConfig implements RecaptchaConfig { delete json.recaptchaKeys; } + if (typeof json.useAccountDefender === 'undefined') { + delete json.useAccountDefender; + } + return json; } } diff --git a/src/utils/error.ts b/src/utils/error.ts index 9ede38af54..cdb7faef05 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -745,6 +745,10 @@ export class AuthClientErrorCode { code: 'invalid-recaptcha-enforcement-state', message: 'reCAPTCHA enforcement state must be either "OFF", "AUDIT" or "ENFORCE".' } + public static RECAPTCHA_NOT_ENABLED = { + code: 'racaptcha-not-enabled', + message: 'reCAPTCHA enterprise is not enabled.' + } } /** @@ -1008,6 +1012,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { INVALID_RECAPTCHA_ACTION: 'INVALID_RECAPTCHA_ACTION', // Unrecognized reCAPTCHA enforcement state. INVALID_RECAPTCHA_ENFORCEMENT_STATE: 'INVALID_RECAPTCHA_ENFORCEMENT_STATE', + // reCAPTCHA is not enabled for account defender. + RECAPTCHA_NOT_ENABLED: 'RECAPTCHA_NOT_ENABLED' }; /** @const {ServerToClientCode} Messaging server to client enum error codes. */ diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index a6951e437a..3fc568f12f 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -1265,29 +1265,41 @@ describe('admin.auth', () => { recaptchaConfig: { emailPasswordEnforcementState: 'AUDIT', managedRules: [{ endScore: 0.1, action: 'BLOCK' }], + useAccountDefender: true, }, }; const projectConfigOption2: UpdateProjectConfigRequest = { recaptchaConfig: { emailPasswordEnforcementState: 'OFF', + useAccountDefender: false, + }, + }; + const projectConfigOption3: UpdateProjectConfigRequest = { + recaptchaConfig: { + emailPasswordEnforcementState: 'OFF', + useAccountDefender: true, }, }; const expectedProjectConfig1: any = { recaptchaConfig: { emailPasswordEnforcementState: 'AUDIT', managedRules: [{ endScore: 0.1, action: 'BLOCK' }], + useAccountDefender: true, }, }; const expectedProjectConfig2: any = { recaptchaConfig: { emailPasswordEnforcementState: 'OFF', managedRules: [{ endScore: 0.1, action: 'BLOCK' }], + useAccountDefender: false, }, }; it('updateProjectConfig() should resolve with the updated project config', () => { return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption1) .then((actualProjectConfig) => { + // ReCAPTCHA keys are generated differently each time. + delete actualProjectConfig.recaptchaConfig?.recaptchaKeys; expect(actualProjectConfig.toJSON()).to.deep.equal(expectedProjectConfig1); return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption2); }) @@ -1303,6 +1315,11 @@ describe('admin.auth', () => { expect(actualConfigObj).to.deep.equal(expectedProjectConfig2); }); }); + + it('updateProjectConfig() should reject when trying to enable Account Defender while reCAPTCHA is disabled', () => { + return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption3) + .should.eventually.be.rejected.and.have.property('code', 'auth/racaptcha-not-enabled'); + }); }); describe('Tenant management operations', () => { @@ -1369,6 +1386,7 @@ describe('admin.auth', () => { action: 'BLOCK', }, ], + useAccountDefender: true, }, }; const expectedUpdatedTenant2: any = { @@ -1395,6 +1413,7 @@ describe('admin.auth', () => { action: 'BLOCK', }, ], + useAccountDefender: false, }, }; @@ -1893,6 +1912,25 @@ describe('admin.auth', () => { }); }); + it('updateTenant() enable Account Defender should be rejected when tenant reCAPTCHA is disabled', + function () { + // Skipping for now as Emulator resolves this operation, which is not expected. + // TODO: investigate with Rest API and Access team for this behavior. + if (authEmulatorHost) { + return this.skip(); + } + expectedUpdatedTenant.tenantId = createdTenantId; + const updatedOptions: UpdateTenantRequest = { + displayName: expectedUpdatedTenant2.displayName, + recaptchaConfig: { + emailPasswordEnforcementState: 'OFF', + useAccountDefender: true, + }, + }; + return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions) + .should.eventually.be.rejected.and.have.property('code', 'auth/racaptcha-not-enabled'); + }); + it('updateTenant() should be able to enable/disable anon provider', async () => { const tenantManager = getAuth().tenantManager(); let tenant = await tenantManager.createTenant({ diff --git a/test/unit/auth/project-config.spec.ts b/test/unit/auth/project-config.spec.ts index 3d2cdfb2db..28a8a18aae 100644 --- a/test/unit/auth/project-config.spec.ts +++ b/test/unit/auth/project-config.spec.ts @@ -77,6 +77,7 @@ describe('ProjectConfig', () => { type: 'WEB', key: 'test-key-1' } ], + useAccountDefender: true, } }; @@ -86,7 +87,8 @@ describe('ProjectConfig', () => { managedRules: [ { endScore: 0.2, action: 'BLOCK' - } ] + } ], + useAccountDefender: true, } }; @@ -191,6 +193,17 @@ describe('ProjectConfig', () => { }).to.throw('"RecaptchaConfig.emailPasswordEnforcementState" must be either "OFF", "AUDIT" or "ENFORCE".'); }); + const invalidUseAccountDefender = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidUseAccountDefender.forEach((useAccountDefender) => { + it(`should throw given invalid useAccountDefender parameter: ${JSON.stringify(useAccountDefender)}`, () => { + const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; + configOptionsClientRequest.recaptchaConfig.useAccountDefender = useAccountDefender; + expect(() => { + ProjectConfig.buildServerRequest(configOptionsClientRequest); + }).to.throw('"RecaptchaConfig.useAccountDefender" must be a boolean value".'); + }); + }); + it('should throw on non-array managedRules attribute', () => { const configOptionsClientRequest = deepCopy(updateProjectConfigRequest) as any; configOptionsClientRequest.recaptchaConfig.managedRules = 'non-array'; @@ -264,6 +277,7 @@ describe('ProjectConfig', () => { type: 'WEB', key: 'test-key-1' } ], + useAccountDefender: true, } ); expect(projectConfig.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig); @@ -284,6 +298,7 @@ describe('ProjectConfig', () => { delete serverResponseOptionalCopy.smsRegionConfig; delete serverResponseOptionalCopy.recaptchaConfig?.emailPasswordEnforcementState; delete serverResponseOptionalCopy.recaptchaConfig?.managedRules; + delete serverResponseOptionalCopy.recaptchaConfig?.useAccountDefender; expect(new ProjectConfig(serverResponseOptionalCopy).toJSON()).to.deep.equal({ recaptchaConfig: { diff --git a/test/unit/auth/tenant.spec.ts b/test/unit/auth/tenant.spec.ts index 1f2c45a650..dc64983069 100644 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -116,6 +116,7 @@ describe('Tenant', () => { type: 'WEB', key: 'test-key-1' } ], + useAccountDefender: true, }, smsRegionConfig: smsAllowByDefault, }; @@ -139,7 +140,8 @@ describe('Tenant', () => { endScore: 0.2, action: 'BLOCK' }], - emailPasswordEnforcementState: 'AUDIT' + emailPasswordEnforcementState: 'AUDIT', + useAccountDefender: true, }, }; @@ -227,6 +229,14 @@ describe('Tenant', () => { }).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".'); }); + it('should throw on non-boolean useAccountDefender attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.useAccountDefender = 'yes'; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, !createRequest); + }).to.throw('"RecaptchaConfig.useAccountDefender" must be a boolean value".'); + }); + it('should throw on invalid managedRules attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; tenantOptionsClientRequest.recaptchaConfig.managedRules = @@ -434,6 +444,17 @@ describe('Tenant', () => { }).to.throw('"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".'); }); + const invalidUseAccountDefender = [null, NaN, 0, 1, '', 'a', [], [1, 'a'], {}, { a: 1 }, _.noop]; + invalidUseAccountDefender.forEach((useAccountDefender) => { + it('should throw on non-boolean useAccountDefender attribute', () => { + const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; + tenantOptionsClientRequest.recaptchaConfig.useAccountDefender = useAccountDefender; + expect(() => { + Tenant.buildServerRequest(tenantOptionsClientRequest, createRequest); + }).to.throw('"RecaptchaConfig.useAccountDefender" must be a boolean value".'); + }); + }); + it('should throw on invalid managedRules attribute', () => { const tenantOptionsClientRequest = deepCopy(clientRequestWithRecaptcha) as any; tenantOptionsClientRequest.recaptchaConfig.managedRules = @@ -621,6 +642,7 @@ describe('Tenant', () => { type: 'WEB', key: 'test-key-1' } ], + useAccountDefender: true, }); expect(tenantWithRecaptcha.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig); }); From 6cab05878a81f0642de67002dd9dbac12a4e0d99 Mon Sep 17 00:00:00 2001 From: Liubin Jiang <56564857+Xiaoshouzi-gh@users.noreply.github.com> Date: Wed, 4 May 2022 16:01:57 -0700 Subject: [PATCH 06/10] address tech review feedback (#1685) --- src/auth/auth-config.ts | 21 ++++++++++----------- src/auth/project-config-manager.ts | 5 +---- src/auth/project-config.ts | 24 ++++++++++++------------ src/auth/tenant.ts | 6 +++--- 4 files changed, 26 insertions(+), 30 deletions(-) diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index a69f1e3a82..cb10daa2f3 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1594,19 +1594,18 @@ export class SmsRegionsAuthConfig { } } } - /** - * Enforcement state of reCAPTCHA protection. - * - 'OFF': Unenforced. - * - 'AUDIT': Assessment is created but result is not used to enforce. - * - 'ENFORCE': Assessment is created and result is used to enforce. - */ +* Enforcement state of reCAPTCHA protection. +* - 'OFF': Unenforced. +* - 'AUDIT': Create assessment but don't enforce the result. +* - 'ENFORCE': Create assessment and enforce the result. +*/ export type RecaptchaProviderEnforcementState = 'OFF' | 'AUDIT' | 'ENFORCE'; /** - * The actions for reCAPTCHA-protected requests. - * - 'BLOCK': The reCAPTCHA-protected request will be blocked. - */ +* The actions to take for reCAPTCHA-protected requests. +* - 'BLOCK': The reCAPTCHA-protected request will be blocked. +*/ export type RecaptchaAction = 'BLOCK'; /** @@ -1624,7 +1623,7 @@ export interface RecaptchaManagedRule { } /** - * The key's platform type: only web supported now. + * The key's platform type: only web is currently supported. */ export type RecaptchaKeyClientType = 'WEB'; @@ -1651,7 +1650,7 @@ export interface RecaptchaKey { */ export interface RecaptchaConfig { /** - * The enforcement state of email password provider. + * The enforcement state of the email password provider. */ emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; /** diff --git a/src/auth/project-config-manager.ts b/src/auth/project-config-manager.ts index 7c4dcbe7d7..847aa7d982 100644 --- a/src/auth/project-config-manager.ts +++ b/src/auth/project-config-manager.ts @@ -20,10 +20,7 @@ import { } from './auth-api-request'; /** - * Defines the project config manager used to help manage project config related operations. - * This includes: - *