UNPKG

voluptasmollitia

Version:
510 lines (480 loc) 18.6 kB
/** * @license * Copyright 2017 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @fileoverview Defines the reCAPTCHA app verifier and its base class. The * former is currently used for web phone authentication whereas the latter is * used for the mobile app verification web fallback. */ goog.provide('fireauth.BaseRecaptchaVerifier'); goog.provide('fireauth.RecaptchaVerifier'); goog.require('fireauth.AuthError'); goog.require('fireauth.RecaptchaMockLoader'); goog.require('fireauth.RecaptchaRealLoader'); goog.require('fireauth.RpcHandler'); goog.require('fireauth.authenum.Error'); goog.require('fireauth.constants'); goog.require('fireauth.object'); goog.require('fireauth.util'); goog.require('goog.Promise'); goog.require('goog.array'); goog.require('goog.dom'); /** * Creates the firebase base reCAPTCHA app verifier independent of Firebase * App or Auth instances. * * @param {string} apiKey The API key used to initialize the RPC handler for * querying the Auth backend. * @param {!Element|string} container The reCAPTCHA container parameter. This * has different meaning depending on whether the reCAPTCHA is hidden or * visible. * @param {?Object=} opt_parameters The optional reCAPTCHA parameters. * @param {?(function():?string)=} opt_getLanguageCode The language code getter * function. * @param {?string=} opt_clientVersion The optional client version to append to * RPC header. * @param {?Object=} opt_rpcHandlerConfig The optional RPC handler * configuration, typically passed when different Auth endpoints are to be * used. * @param {boolean=} opt_isTestingMode Whether the reCAPTCHA is to be rendered * in testing mode. * @constructor */ fireauth.BaseRecaptchaVerifier = function(apiKey, container, opt_parameters, opt_getLanguageCode, opt_clientVersion, opt_rpcHandlerConfig, opt_isTestingMode) { // Set the type readonly property needed for full implementation of the // firebase.auth.ApplicationVerifier interface. fireauth.object.setReadonlyProperty(this, 'type', 'recaptcha'); /** * @private {?goog.Promise<void>} The cached reCAPTCHA ready response. This is * null until the first time it is triggered or when an error occurs in * getting ready. */ this.cachedReadyPromise_ = null; /** @private {?number} The reCAPTCHA widget ID. Null when not rendered. */ this.widgetId_ = null; /** @private {boolean} Whether the instance is already destroyed. */ this.destroyed_ = false; /** @private {!Element|string} The reCAPTCHA container. */ this.container_ = container; /** * @private {?fireauth.grecaptcha} The reCAPTCHA client library namespace. */ this.grecaptcha_ = null; /** * @const @private {!fireauth.RecaptchaLoader} The grecaptcha loader. */ this.recaptchaLoader_ = !!opt_isTestingMode ? fireauth.RecaptchaMockLoader.getInstance() : fireauth.RecaptchaRealLoader.getInstance(); // If no parameters passed, use default settings. // Currently, visible recaptcha is the default setting as invisible reCAPTCHA // is not yet supported by the backend. /** @private {!Object} The reCAPTCHA parameters. */ this.parameters_ = opt_parameters || { 'theme': 'light', 'type': 'image' }; /** @private {!Array<!goog.Promise<*>|!goog.Promise<void>>} List of * pending promises. */ this.pendingPromises_ = []; if (this.parameters_[fireauth.BaseRecaptchaVerifier.ParamName.SITEKEY]) { // sitekey should not be provided. throw new fireauth.AuthError( fireauth.authenum.Error.ARGUMENT_ERROR, 'sitekey should not be provided for reCAPTCHA as one is ' + 'automatically provisioned for the current project.'); } /** @private {boolean} Whether the reCAPTCHA is invisible or not. */ this.isInvisible_ = this.parameters_[fireauth.BaseRecaptchaVerifier.ParamName.SIZE] === 'invisible'; // Check if DOM is supported. if (!fireauth.util.isDOMSupported()) { throw new fireauth.AuthError( fireauth.authenum.Error.OPERATION_NOT_SUPPORTED, 'RecaptchaVerifier is only supported in a browser HTTP/HTTPS ' + 'environment with DOM support.'); } // reCAPTCHA container must be valid and if visible, not empty. // An invisible reCAPTCHA will not render in its container. That container // will execute the reCAPTCHA when it is clicked. if (!goog.dom.getElement(container) || (!this.isInvisible_ && goog.dom.getElement(container).hasChildNodes())) { throw new fireauth.AuthError( fireauth.authenum.Error.ARGUMENT_ERROR, 'reCAPTCHA container is either not found or already contains inner ' + 'elements!'); } /** * @private {!fireauth.RpcHandler} The RPC handler for querying the auth * backend. */ this.rpcHandler_ = new fireauth.RpcHandler( apiKey, opt_rpcHandlerConfig || null, opt_clientVersion || null); /** * @private {function():?string} Current language code getter. */ this.getLanguageCode_ = opt_getLanguageCode || function() {return null;}; var self = this; /** * @private {!Array<function(?string)>} The token change listeners. */ this.tokenListeners_ = []; // Wrap token callback. var existingCallback = this.parameters_[fireauth.BaseRecaptchaVerifier.ParamName.CALLBACK]; this.parameters_[fireauth.BaseRecaptchaVerifier.ParamName.CALLBACK] = function(response) { // Dispatch internal event for the token response. self.dispatchEvent_(response); if (typeof existingCallback === 'function') { existingCallback(response); } else if (typeof existingCallback === 'string') { // Check if the provided callback is a global function name. var cb = fireauth.util.getObjectRef(existingCallback, goog.global); if (typeof cb === 'function') { // If so, trigger it. cb(response); } } }; // Wrap expired token callback. var existingExpiredCallback = this.parameters_[ fireauth.BaseRecaptchaVerifier.ParamName.EXPIRED_CALLBACK]; this.parameters_[fireauth.BaseRecaptchaVerifier.ParamName.EXPIRED_CALLBACK] = function() { // Dispatch internal event for the token expiration. self.dispatchEvent_(null); if (typeof existingExpiredCallback === 'function') { existingExpiredCallback(); } else if (typeof existingExpiredCallback === 'string') { // Check if the provided expired callback is a global function name. var cb = fireauth.util.getObjectRef(existingExpiredCallback, goog.global); if (typeof cb === 'function') { // If so, trigger it. cb(); } } }; }; /** * grecaptcha parameter names. * @enum {string} */ fireauth.BaseRecaptchaVerifier.ParamName = { CALLBACK: 'callback', EXPIRED_CALLBACK: 'expired-callback', SITEKEY: 'sitekey', SIZE: 'size' }; /** * Dispatches the token change event to all subscribed listeners. * @param {?string} token The current detected token, null for none. * @private */ fireauth.BaseRecaptchaVerifier.prototype.dispatchEvent_ = function(token) { for (var i = 0; i < this.tokenListeners_.length; i++) { try { this.tokenListeners_[i](token); } catch (e) { // If any handler fails, ignore and run next handler. } } }; /** * Add a reCAPTCHA token change listener. * @param {function(?string)} listener The token listener to add. * @private */ fireauth.BaseRecaptchaVerifier.prototype.addTokenChangeListener_ = function(listener) { this.tokenListeners_.push(listener); }; /** * Remove a reCAPTCHA token change listener. * @param {function(?string)} listener The token listener to remove. * @private */ fireauth.BaseRecaptchaVerifier.prototype.removeTokenChangeListener_ = function(listener) { goog.array.removeAllIf(this.tokenListeners_, function(ele) { return ele == listener; }); }; /** * Takes in a pending promise, saves it and adds a clean up callback which * forgets the pending promise after it is fulfilled and echoes the promise * back. * @param {!goog.Promise<*, *>|!goog.Promise<void>} p The pending promise. * @return {!goog.Promise<*, *>|!goog.Promise<void>} * @private */ fireauth.BaseRecaptchaVerifier.prototype.registerPendingPromise_ = function(p) { var self = this; // Save created promise in pending list. this.pendingPromises_.push(p); p.thenAlways(function() { // When fulfilled, remove from pending list. goog.array.remove(self.pendingPromises_, p); }); // Return the promise. return p; }; /** @return {boolean} Whether verifier instance has pending promises. */ fireauth.BaseRecaptchaVerifier.prototype.hasPendingPromises = function() { return this.pendingPromises_.length != 0; }; /** * Gets the current RecaptchaVerifier in a ready state for rendering by first * checking that the environment supports a reCAPTCHA, loading reCAPTCHA * dependencies if not already available and then getting the Firebase project's * provisioned reCAPTCHA configuration. * @return {!goog.Promise<void>} The promise that resolves when recaptcha * is ready for rendering. * @private */ fireauth.BaseRecaptchaVerifier.prototype.isReady_ = function() { var self = this; // If previously called, return the cached response. if (this.cachedReadyPromise_) { return this.cachedReadyPromise_; } this.cachedReadyPromise_ = this.registerPendingPromise_(goog.Promise.resolve() .then(function() { // Verify environment first. // Fail quickly from a worker environment or non-HTTP/HTTPS environment. if (fireauth.util.isHttpOrHttps() && !fireauth.util.isWorker()) { // Wait for DOM to be ready as this feature depends on that. return fireauth.util.onDomReady(); } else { throw new fireauth.AuthError( fireauth.authenum.Error.OPERATION_NOT_SUPPORTED, 'RecaptchaVerifier is only supported in a browser HTTP/HTTPS ' + 'environment.'); } }) .then(function() { // Load external reCAPTCHA dependencies if not already there, taking // into account the current language code. return self.recaptchaLoader_.loadRecaptchaDeps(self.getLanguageCode_()); }) .then(function(grecaptcha) { self.grecaptcha_ = grecaptcha; // Load Firebase project's reCAPTCHA configuration. return self.rpcHandler_.getRecaptchaParam(); }) .then(function(result) { // Update the reCAPTCHA parameters. self.parameters_[fireauth.BaseRecaptchaVerifier.ParamName.SITEKEY] = result[fireauth.RpcHandler.AuthServerField.RECAPTCHA_SITE_KEY]; }).thenCatch(function(error) { // Anytime an error occurs, reset the cached ready promise to rerun on // retrial. self.cachedReadyPromise_ = null; // Rethrow the error. throw error; })); // Return the cached/pending ready promise. return this.cachedReadyPromise_; }; /** * Renders the reCAPTCHA and returns the allocated widget ID. * @return {!goog.Promise<number>} The promise that resolves with the reCAPTCHA * widget ID when it is rendered. */ fireauth.BaseRecaptchaVerifier.prototype.render = function() { this.checkIfDestroyed_(); var self = this; // Get reCAPTCHA ready. return this.registerPendingPromise_(this.isReady_().then(function() { if (self.widgetId_ === null) { // For a visible reCAPTCHA, embed in a wrapper DIV container to allow // re-rendering in the same developer provided container. var container = self.container_; if (!self.isInvisible_) { // Get outer container (the developer provided container). var outerContainer = goog.dom.getElement(container); // Create wrapper temp DIV container. container = goog.dom.createDom(goog.dom.TagName.DIV); // Add temp DIV to outer container. outerContainer.appendChild(container); } // If not initialized, initialize reCAPTCHA and return its widget ID. self.widgetId_ = self.grecaptcha_.render(container, self.parameters_); } return self.widgetId_; })); }; /** * Gets the reCAPTCHA ready and waits for the reCAPTCHA token to be available * before resolving the promise returned. * @return {!goog.Promise<string>} The promise that resolves with the reCAPTCHA * token when reCAPTCHA challenge is solved. */ fireauth.BaseRecaptchaVerifier.prototype.verify = function() { // Fail if reCAPTCHA is already destroyed. this.checkIfDestroyed_(); var self = this; // Render reCAPTCHA. return this.registerPendingPromise_(this.render().then(function(widgetId) { return new goog.Promise(function(resolve, reject) { // Get current reCAPTCHA token. var recaptchaToken = self.grecaptcha_.getResponse(widgetId); if (recaptchaToken) { // Unexpired token already available. Resolve pending promise with that // token. resolve(recaptchaToken); } else { // No token available. Listen to token change. var cb = function(token) { if (!token) { // Ignore token expirations. return; } // Remove temporary token change listener. self.removeTokenChangeListener_(cb); // Resolve with new token. resolve(token); }; // Add temporary token change listener. self.addTokenChangeListener_(cb); if (self.isInvisible_) { // Execute invisible reCAPTCHA to force a challenge. // This should do nothing if already triggered either by developer or // by a button click. self.grecaptcha_.execute(/** @type {number} */ (self.widgetId_)); } } }); })); }; /** * Resets the reCAPTCHA widget. */ fireauth.BaseRecaptchaVerifier.prototype.reset = function() { this.checkIfDestroyed_(); if (this.widgetId_ !== null) { this.grecaptcha_.reset(this.widgetId_); } }; /** * Throws an error if the reCAPTCHA verifier is already cleared. * @private */ fireauth.BaseRecaptchaVerifier.prototype.checkIfDestroyed_ = function() { if (this.destroyed_) { throw new fireauth.AuthError( fireauth.authenum.Error.INTERNAL_ERROR, 'RecaptchaVerifier instance has been destroyed.'); } }; /** * Removes the reCAPTCHA from the DOM. */ fireauth.BaseRecaptchaVerifier.prototype.clear = function() { this.checkIfDestroyed_(); this.destroyed_ = true; // Decrement reCAPTCHA instance counter. this.recaptchaLoader_.clearSingleRecaptcha(); // Cancel all pending promises. for (var i = 0; i < this.pendingPromises_.length; i++) { this.pendingPromises_[i].cancel( 'RecaptchaVerifier instance has been destroyed.'); } if (!this.isInvisible_) { goog.dom.removeChildren(goog.dom.getElement(this.container_)); } }; /** * Creates the Firebase reCAPTCHA app verifier, publicly available, for the * Firebase app provided, used for web phone authentication. * This is a subclass of fireauth.BaseRecaptchaVerifier. * * @param {!Element|string} container The reCAPTCHA container parameter. This * has different meaning depending on whether the reCAPTCHA is hidden or * visible. * @param {?Object=} opt_parameters The optional reCAPTCHA parameters. * @param {?firebase.app.App=} opt_app The corresponding Firebase app. * @constructor * @extends {fireauth.BaseRecaptchaVerifier} */ fireauth.RecaptchaVerifier = function(container, opt_parameters, opt_app) { var isTestingMode = false; var apiKey; try { /** @private {!firebase.app.App} The corresponding Firebase app instance. */ this.app_ = opt_app || firebase.app(); } catch (error) { throw new fireauth.AuthError( fireauth.authenum.Error.ARGUMENT_ERROR, 'No firebase.app.App instance is currently initialized.'); } // API key is required for web client RPC calls. if (this.app_.options && this.app_.options['apiKey']) { apiKey = this.app_.options['apiKey']; } else { throw new fireauth.AuthError(fireauth.authenum.Error.INVALID_API_KEY); } var self = this; // Construct the language code getter based on the underlying Auth instance. var getLanguageCode = function() { var languageCode; // Get the latest language setting. // reCAPTCHA does not support updating the language of an already // rendered reCAPTCHA. Reloading the SDK with the new hl will break // the existing rendered localized reCAPTCHA. We will need to // document that a new fireauth.BaseRecaptchaVerifier instance needs // to be instantiated after the language is updated. Otherwise, the // old language code will remain active on the existing instance. try { languageCode = self.app_['auth']().getLanguageCode(); } catch (e) { languageCode = null; } return languageCode; }; // Get the framework version from Auth instance. var frameworkVersion = null; try { frameworkVersion = this.app_['auth']().getFramework(); } catch (e) { // Do nothing. } try { isTestingMode = this.app_['auth']()['settings']['appVerificationDisabledForTesting']; } catch (e) { // Do nothing. } // Get the client version based on the Firebase JS version. var clientFullVersion = firebase.SDK_VERSION ? fireauth.util.getClientVersion( fireauth.util.ClientImplementation.JSCORE, firebase.SDK_VERSION, frameworkVersion) : null; // Call the superclass constructor with the computed API key, reCAPTCHA // container, optional parameters, language code getter, Firebase JS client // version and the current client configuration endpoints. fireauth.RecaptchaVerifier.base(this, 'constructor', apiKey, container, opt_parameters, getLanguageCode, clientFullVersion, fireauth.constants.getEndpointConfig(fireauth.constants.clientEndpoint), isTestingMode); }; goog.inherits(fireauth.RecaptchaVerifier, fireauth.BaseRecaptchaVerifier);