From b10d9d69ee0c5e37c8511effbda20bc05109f3a1 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 3f8f387f84..604a694459 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1722,3 +1722,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 ad904c4ff9dd14abd26b61af64a8269979b58b9c 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 c6c3f01c1b..a50400191a 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -362,6 +362,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; @@ -398,6 +426,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; @@ -472,6 +501,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 604a694459..1030429e39 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1768,22 +1768,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 d91c46f083..8af9c7e246 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -84,6 +84,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 74c008172a..79facad7b0 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, @@ -109,6 +109,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; @@ -152,6 +201,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'; @@ -230,7 +338,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; @@ -300,6 +408,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' }; @@ -463,6 +630,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)); @@ -499,7 +684,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', @@ -512,14 +697,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 21c7deb7a3c87acf6522a24ead7158b6bfa7f67c 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 | 47 +++++++- 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 | 113 +++++++++++++++++- test/unit/auth/tenant.spec.ts | 3 +- 9 files changed, 200 insertions(+), 13 deletions(-) diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index a50400191a..82336b85e5 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -345,6 +345,7 @@ export class PhoneMultiFactorInfo extends MultiFactorInfo { export class ProjectConfig { get multiFactorConfig(): MultiFactorConfig | undefined; readonly smsRegionConfig?: SmsRegionConfig; + get recaptchaConfig(): RecaptchaConfig | undefined; toJSON(): object; } @@ -478,6 +479,7 @@ export interface UpdatePhoneMultiFactorInfoRequest extends BaseUpdateMultiFactor export interface UpdateProjectConfigRequest { multiFactorConfig?: MultiFactorConfig; smsRegionConfig?: SmsRegionConfig; + recaptchaConfig?: RecaptchaConfig; } // @public diff --git a/src/auth/auth-config.ts b/src/auth/auth-config.ts index 1030429e39..2d20e5dc48 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1773,6 +1773,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 4abcce9b3e..ddab2b7117 100644 --- a/src/auth/project-config.ts +++ b/src/auth/project-config.ts @@ -21,6 +21,8 @@ import { MultiFactorConfig, MultiFactorAuthConfig, MultiFactorAuthServerConfig, + RecaptchaConfig, + RecaptchaAuthConfig, } from './auth-config'; import { deepCopy } from '../utils/deep-copy'; @@ -36,6 +38,14 @@ export interface UpdateProjectConfigRequest { * The multi-factor auth configuration to update on the project. */ multiFactorConfig?: MultiFactorConfig; + + /** + * 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; } /** @@ -45,6 +55,7 @@ export interface UpdateProjectConfigRequest { export interface ProjectConfigServerResponse { smsRegionConfig?: SmsRegionConfig; mfa?: MultiFactorAuthServerConfig; + recaptchaConfig?: RecaptchaConfig; } /** @@ -54,6 +65,7 @@ export interface ProjectConfigServerResponse { export interface ProjectConfigClientRequest { smsRegionConfig?: SmsRegionConfig; mfa?: MultiFactorAuthServerConfig; + recaptchaConfig?: RecaptchaConfig; } /** @@ -66,10 +78,21 @@ export class ProjectConfig { * This is based on the calling code of the destination phone number. */ public readonly smsRegionConfig?: SmsRegionConfig; + /** * The project's multi-factor auth configuration. * Supports only phone and TOTP. - */ private readonly multiFactorConfig_?: MultiFactorConfig; + */ + private readonly multiFactorConfig_?: MultiFactorConfig; + + /** + * 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; + /** * The multi-factor auth configuration. */ @@ -92,6 +115,7 @@ export class ProjectConfig { const validKeys = { smsRegionConfig: true, multiFactorConfig: true, + recaptchaConfig: true, } // Check for unsupported top level attributes. for (const key in request) { @@ -111,13 +135,17 @@ export class ProjectConfig { if (typeof request.multiFactorConfig !== 'undefined') { MultiFactorAuthConfig.validate(request.multiFactorConfig); } + // 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 { @@ -133,7 +161,13 @@ export class ProjectConfig { delete request.multiFactorConfig; return request as ProjectConfigClientRequest; } - + + /** + * The recaptcha configuration. + */ + get recaptchaConfig(): RecaptchaConfig | undefined { + return this.recaptchaConfig_; + } /** * The Project Config object constructor. * @@ -150,6 +184,9 @@ export class ProjectConfig { if (typeof response.mfa !== 'undefined') { this.multiFactorConfig_ = new MultiFactorAuthConfig(response.mfa); } + if (typeof response.recaptchaConfig !== 'undefined') { + this.recaptchaConfig_ = new RecaptchaAuthConfig(response.recaptchaConfig); + } } /** * Returns a JSON-serializable representation of this object. @@ -161,6 +198,7 @@ export class ProjectConfig { const json = { smsRegionConfig: deepCopy(this.smsRegionConfig), multiFactorConfig: deepCopy(this.multiFactorConfig), + recaptchaConfig: this.recaptchaConfig_?.toJSON(), }; if (typeof json.smsRegionConfig === 'undefined') { delete json.smsRegionConfig; @@ -168,6 +206,9 @@ export class ProjectConfig { if (typeof json.multiFactorConfig === 'undefined') { delete json.multiFactorConfig; } + 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 c4ff9d63c8..63e101751c 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, @@ -77,6 +78,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()', () => { @@ -147,6 +169,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) => { @@ -158,7 +238,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); @@ -198,6 +278,23 @@ describe('ProjectConfig', () => { }; expect(projectConfig.multiFactorConfig).to.deep.equal(expectedMultiFactorConfig); }); + + 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()', () => { @@ -205,7 +302,8 @@ describe('ProjectConfig', () => { it('should return the expected object representation of project config', () => { expect(new ProjectConfig(serverResponseCopy).toJSON()).to.deep.equal({ smsRegionConfig: deepCopy(serverResponse.smsRegionConfig), - multiFactorConfig: deepCopy(serverResponse.mfa) + multiFactorConfig: deepCopy(serverResponse.mfa), + recaptchaConfig: deepCopy(serverResponse.recaptchaConfig) }); }); @@ -213,7 +311,14 @@ describe('ProjectConfig', () => { const serverResponseOptionalCopy: ProjectConfigServerResponse = deepCopy(serverResponse); delete serverResponseOptionalCopy.smsRegionConfig; delete serverResponseOptionalCopy.mfa; - expect(new ProjectConfig(serverResponseOptionalCopy).toJSON()).to.deep.equal({}); + delete serverResponseOptionalCopy.recaptchaConfig?.emailPasswordEnforcementState; + delete serverResponseOptionalCopy.recaptchaConfig?.managedRules; + + 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 79facad7b0..72418b334c 100644 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -132,7 +132,8 @@ describe('Tenant', () => { type: 'WEB', key: 'test-key-1' } ], - } + }, + smsRegionConfig: smsAllowByDefault, }; const clientRequestWithRecaptcha: UpdateTenantRequest = { From 73bfb6be6434a1170907ff223359f404b51ea477 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 e1a29ee48a..f25131a255 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -1298,6 +1298,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[] = []; @@ -1378,6 +1428,15 @@ describe('admin.auth', () => { testPhoneNumbers: { '+16505551234': '123456', }, + recaptchaConfig: { + emailPasswordEnforcementState: 'AUDIT', + managedRules: [ + { + endScore: 0.3, + action: 'BLOCK', + }, + ], + }, }; const expectedUpdatedTenant2: any = { displayName: 'testTenantUpdated', @@ -1424,6 +1483,15 @@ describe('admin.auth', () => { disallowedRegions: ['AC', 'AD'], } }, + recaptchaConfig: { + emailPasswordEnforcementState: 'OFF', + managedRules: [ + { + endScore: 0.3, + action: 'BLOCK', + }, + ], + }, }; // https://mochajs.org/ @@ -1836,6 +1904,7 @@ describe('admin.auth', () => { }, multiFactorConfig: deepCopy(expectedUpdatedTenant.multiFactorConfig), testPhoneNumbers: deepCopy(expectedUpdatedTenant.testPhoneNumbers), + recaptchaConfig: deepCopy(expectedUpdatedTenant.recaptchaConfig), }; const updatedOptions2: UpdateTenantRequest = { emailSignInConfig: { @@ -1846,6 +1915,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) @@ -1914,11 +1984,31 @@ describe('admin.auth', () => { }); } return getAuth().tenantManager().updateTenant(createdTenantId, updateRequestNoMfaConfig) + }); + + 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); }); }); +<<<<<<< HEAD it('updateTenant() should not disable SMS MFA when TOTP is disabled', () => { expectedUpdatedTenantSmsEnabledTotpDisabled.tenantId = createdTenantId; const updateRequestSMSEnabledTOTPDisabled: UpdateTenantRequest = { @@ -1950,6 +2040,8 @@ describe('admin.auth', () => { }); }); +======= +>>>>>>> 50ef232 (Recapcha integ test (#1599)) it('updateTenant() should be able to enable/disable anon provider', async () => { const tenantManager = getAuth().tenantManager(); let tenant = await tenantManager.createTenant({ From fbc18f70c1168bec631863db4cfc295511f87231 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 | 42 ++++++++++++++++++++++++--- test/unit/auth/project-config.spec.ts | 17 ++++++++++- test/unit/auth/tenant.spec.ts | 24 ++++++++++++++- 6 files changed, 108 insertions(+), 7 deletions(-) diff --git a/etc/firebase-admin.auth.api.md b/etc/firebase-admin.auth.api.md index 82336b85e5..2182a8442b 100644 --- a/etc/firebase-admin.auth.api.md +++ b/etc/firebase-admin.auth.api.md @@ -371,6 +371,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 2d20e5dc48..bb05f7fd61 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1791,17 +1791,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; } /** @@ -1813,6 +1821,7 @@ export class RecaptchaAuthConfig implements RecaptchaConfig { emailPasswordEnforcementState: true, managedRules: true, recaptchaKeys: true, + useAccountDefender: true, }; if (!validator.isNonNullObject(options)) { @@ -1863,6 +1872,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".', + ); + } + } } /** @@ -1908,7 +1926,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') { @@ -1921,6 +1940,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 f25131a255..780e0254f6 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -1308,29 +1308,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); }) @@ -1346,6 +1358,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', () => { @@ -1436,6 +1453,7 @@ describe('admin.auth', () => { action: 'BLOCK', }, ], + useAccountDefender: true, }, }; const expectedUpdatedTenant2: any = { @@ -1491,6 +1509,7 @@ describe('admin.auth', () => { action: 'BLOCK', }, ], + useAccountDefender: false, }, }; @@ -2007,8 +2026,6 @@ describe('admin.auth', () => { expect(actualTenant.toJSON()).to.deep.equal(expectedUpdatedTenant2); }); }); - -<<<<<<< HEAD it('updateTenant() should not disable SMS MFA when TOTP is disabled', () => { expectedUpdatedTenantSmsEnabledTotpDisabled.tenantId = createdTenantId; const updateRequestSMSEnabledTOTPDisabled: UpdateTenantRequest = { @@ -2040,8 +2057,25 @@ describe('admin.auth', () => { }); }); -======= ->>>>>>> 50ef232 (Recapcha integ test (#1599)) + 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 63e101751c..2a02e72cdc 100644 --- a/test/unit/auth/project-config.spec.ts +++ b/test/unit/auth/project-config.spec.ts @@ -88,6 +88,7 @@ describe('ProjectConfig', () => { type: 'WEB', key: 'test-key-1' } ], + useAccountDefender: true, } }; @@ -97,7 +98,8 @@ describe('ProjectConfig', () => { managedRules: [ { endScore: 0.2, action: 'BLOCK' - } ] + } ], + useAccountDefender: true, } }; @@ -202,6 +204,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'; @@ -291,6 +304,7 @@ describe('ProjectConfig', () => { type: 'WEB', key: 'test-key-1' } ], + useAccountDefender: true, } ); expect(projectConfig.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig); @@ -313,6 +327,7 @@ describe('ProjectConfig', () => { delete serverResponseOptionalCopy.mfa; 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 72418b334c..0e4f25a46b 100644 --- a/test/unit/auth/tenant.spec.ts +++ b/test/unit/auth/tenant.spec.ts @@ -132,6 +132,7 @@ describe('Tenant', () => { type: 'WEB', key: 'test-key-1' } ], + useAccountDefender: true, }, smsRegionConfig: smsAllowByDefault, }; @@ -155,7 +156,8 @@ describe('Tenant', () => { endScore: 0.2, action: 'BLOCK' }], - emailPasswordEnforcementState: 'AUDIT' + emailPasswordEnforcementState: 'AUDIT', + useAccountDefender: true, }, }; @@ -243,6 +245,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 = @@ -450,6 +460,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 = @@ -645,6 +666,7 @@ describe('Tenant', () => { type: 'WEB', key: 'test-key-1' } ], + useAccountDefender: true, }); expect(tenantWithRecaptcha.recaptchaConfig).to.deep.equal(expectedRecaptchaConfig); }); From 1a0458ded80ebac2a208355098c109d7029710c7 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 bb05f7fd61..0ab9f6308a 100644 --- a/src/auth/auth-config.ts +++ b/src/auth/auth-config.ts @@ -1722,19 +1722,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'; /** @@ -1752,7 +1751,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'; @@ -1779,7 +1778,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: - *