From 7715aea1351b3aa93a0a2cff68b5cbaeeee65922 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Patrick=20M=C3=BCller?= <p.mueller@pcsg.de>
Date: Wed, 29 Jul 2020 12:55:55 +0200
Subject: [PATCH] feat: Google reCAPTCHA v3 quiqqer/captcha#10

---
 bin/controls/modules/Google.js               | 73 +++++++++++----
 locale.xml                                   | 20 +++--
 src/QUI/Captcha/Modules/Google.php           |  6 +-
 src/QUI/Captcha/Modules/GoogleV3.php         | 93 ++++++++++++++++++++
 src/QUI/Captcha/Modules/GoogleV3/Control.php | 24 +++++
 5 files changed, 189 insertions(+), 27 deletions(-)
 create mode 100644 src/QUI/Captcha/Modules/GoogleV3.php
 create mode 100644 src/QUI/Captcha/Modules/GoogleV3/Control.php

diff --git a/bin/controls/modules/Google.js b/bin/controls/modules/Google.js
index 6aec190..7b042d0 100644
--- a/bin/controls/modules/Google.js
+++ b/bin/controls/modules/Google.js
@@ -20,18 +20,24 @@ define('package/quiqqer/captcha/bin/controls/modules/Google', [
         Binds: [
             '$onImport',
             '$onGoogleCaptchaLoaded',
-            '$onGoogleCatpchaSuccess'
+            '$onGoogleCatpchaSuccess',
+            '$renderV2',
+            '$renderV3'
         ],
 
         options: {
-            sitekey  : false,   // Google reCAPTCHA v2 Site Key
-            invisible: false    // use invisible captcha
+            sitekey  : false,   // Google reCAPTCHA v2/v3 Site Key
+            invisible: false,   // use invisible captcha
+            v3       : false,   // use reCAPTCHA v3
         },
 
         initialize: function (options) {
             this.parent(options);
 
-            this.Loader = new QUILoader();
+            this.Loader      = new QUILoader();
+            this.$DisplayElm = null;
+
+            this.$reloadChallengeOnPrematureClose = true;
 
             this.addEvents({
                 onImport: this.$onImport
@@ -66,11 +72,19 @@ define('package/quiqqer/captcha/bin/controls/modules/Google', [
             window.$onGoogleCaptchaLoaded = this.$onGoogleCaptchaLoaded;
             window.loadingGoogleReCaptcha = true;
 
-            new Element('script', {
-                src  : 'https://www.google.com/recaptcha/api.js?onload=$onGoogleCaptchaLoaded&render=explicit',
-                async: true,
-                defer: true
-            }).inject(document.head);
+            if (this.getAttribute('v3')) {
+                new Element('script', {
+                    src  : 'https://www.google.com/recaptcha/api.js?onload=$onGoogleCaptchaLoaded&render=' + this.getAttribute('sitekey'),
+                    async: true,
+                    defer: true
+                }).inject(document.head);
+            } else {
+                new Element('script', {
+                    src  : 'https://www.google.com/recaptcha/api.js?onload=$onGoogleCaptchaLoaded&render=explicit',
+                    async: true,
+                    defer: true
+                }).inject(document.head);
+            }
         },
 
         /**
@@ -79,17 +93,27 @@ define('package/quiqqer/captcha/bin/controls/modules/Google', [
         $onGoogleCaptchaLoaded: function () {
             window.loadingGoogleReCaptcha = false;
 
-            var self                            = this;
-            var reloadChallengeOnPrematureClose = true;
-
             this.Loader.hide();
 
-            var DisplayElm = this.$Elm.getElement('.quiqqer-captcha-google-display');
+            this.$DisplayElm = this.$Elm.getElement('.quiqqer-captcha-google-display');
+
+            if (this.getAttribute('v3')) {
+                this.$renderV3();
+            } else {
+                this.$renderV2();
+            }
+        },
+
+        /**
+         * Render Google reCAPTCHA v2
+         */
+        $renderV2: function () {
+            var self = this;
 
             var Options = {
                 sitekey           : this.getAttribute('sitekey'),
                 callback          : function (response) {
-                    reloadChallengeOnPrematureClose = false;
+                    self.$reloadChallengeOnPrematureClose = false;
                     self.$onCaptchaSuccess(response);
                 },
                 'expired-callback': function () {
@@ -97,7 +121,7 @@ define('package/quiqqer/captcha/bin/controls/modules/Google', [
                     //self.$onCaptchaExpired();
                 },
                 'error-callback'  : function () {
-                    reloadChallengeOnPrematureClose = false;
+                    self.$reloadChallengeOnPrematureClose = false;
                 }
             };
 
@@ -105,9 +129,9 @@ define('package/quiqqer/captcha/bin/controls/modules/Google', [
                 Options.size = 'invisible';
             }
 
-            grecaptcha.render(DisplayElm, Options);
+            grecaptcha.render(this.$DisplayElm, Options);
 
-            if (this.getAttribute('invisible')) {
+            if (self.getAttribute('invisible')) {
                 grecaptcha.execute();
 
                 // Wait for challenge window
@@ -120,7 +144,7 @@ define('package/quiqqer/captcha/bin/controls/modules/Google', [
                         clearInterval(wait);
 
                         var Observer = new MutationObserver(function (mutations) {
-                            if (!reloadChallengeOnPrematureClose) {
+                            if (!self.$reloadChallengeOnPrematureClose) {
                                 return;
                             }
 
@@ -141,6 +165,19 @@ define('package/quiqqer/captcha/bin/controls/modules/Google', [
                     }
                 }, 1000);
             }
+        },
+
+        /**
+         * Render Google reCAPTCHA v3
+         */
+        $renderV3: function () {
+            var self = this;
+
+            grecaptcha.ready(function () {
+                grecaptcha.execute(self.getAttribute('sitekey'), {action: 'submit'}).then(function (token) {
+                    self.$onCaptchaSuccess(token);
+                });
+            });
         }
     });
 });
\ No newline at end of file
diff --git a/locale.xml b/locale.xml
index 441f273..26616af 100644
--- a/locale.xml
+++ b/locale.xml
@@ -49,8 +49,8 @@
             <en><![CDATA[Site key]]></en>
         </locale>
         <locale name="settings.google.siteKey.description" html="true">
-            <de><![CDATA[Googe reCAPTCHA v2 Websiteschlüssel. Siehe <a href="https://www.google.com/recaptcha/admin" target="_blank">https://www.google.com/recaptcha/admin</a>.]]></de>
-            <en><![CDATA[Google reCAPTCHA v2 Site key. See <a href="https://www.google.com/recaptcha/admin" target="_blank">https://www.google.com/recaptcha/admin</a>.]]></en>
+            <de><![CDATA[Googe reCAPTCHA v2 or v3 Websiteschlüssel. Siehe <a href="https://www.google.com/recaptcha/admin" target="_blank">https://www.google.com/recaptcha/admin</a>.]]></de>
+            <en><![CDATA[Google reCAPTCHA v2 or v3 Site key. See <a href="https://www.google.com/recaptcha/admin" target="_blank">https://www.google.com/recaptcha/admin</a>.]]></en>
         </locale>
         <locale name="settings.google.secretKey.title">
             <de><![CDATA[Geheimer Schlüssel]]></de>
@@ -59,21 +59,29 @@
 
         <!-- Module titles and descriptions -->
         <locale name="captcha.title.Google">
-            <de><![CDATA[Google (reCAPTCHA)]]></de>
-            <en><![CDATA[Google (reCAPTCHA)]]></en>
+            <de><![CDATA[Google (reCAPTCHA v2)]]></de>
+            <en><![CDATA[Google (reCAPTCHA v2)]]></en>
         </locale>
         <locale name="captcha.descrption.Google">
             <de><![CDATA[Benötigt JavaScript.]]></de>
             <en><![CDATA[Requires JavaScript.]]></en>
         </locale>
         <locale name="captcha.title.GoogleInvisible">
-            <de><![CDATA[Google (reCAPTCHA - unsichtbar)]]></de>
-            <en><![CDATA[Google (reCAPTCHA - invisible)]]></en>
+            <de><![CDATA[Google (reCAPTCHA v2 - unsichtbar)]]></de>
+            <en><![CDATA[Google (reCAPTCHA v2 - invisible)]]></en>
         </locale>
         <locale name="captcha.descrption.GoogleInvisible">
             <de><![CDATA[Benötigt JavaScript.]]></de>
             <en><![CDATA[Requires JavaScript.]]></en>
         </locale>
+        <locale name="captcha.title.GoogleV3">
+            <de><![CDATA[Google (reCAPTCHA v3)]]></de>
+            <en><![CDATA[Google (reCAPTCHA v3)]]></en>
+        </locale>
+        <locale name="captcha.descrption.GoogleV3">
+            <de><![CDATA[Benötigt JavaScript.]]></de>
+            <en><![CDATA[Requires JavaScript.]]></en>
+        </locale>
 
     </groups>
 </locales>
diff --git a/src/QUI/Captcha/Modules/Google.php b/src/QUI/Captcha/Modules/Google.php
index 57d37ce..d7a182d 100644
--- a/src/QUI/Captcha/Modules/Google.php
+++ b/src/QUI/Captcha/Modules/Google.php
@@ -27,10 +27,10 @@ class Google extends QUI\Captcha\AbstractCaptcha
     public static function isValid($data)
     {
         $url    = 'https://www.google.com/recaptcha/api/siteverify?';
-        $params = array(
+        $params = [
             'secret'   => self::getSecretKey(),
             'response' => $data
-        );
+        ];
 
         $url .= http_build_query($params);
 
@@ -56,7 +56,7 @@ class Google extends QUI\Captcha\AbstractCaptcha
 
         if (!empty($validationResponse['error-codes'])) {
             QUI\System\Log::addError(
-                'Google reCAPTCHA response could not be validated: ' . implode(', ', $validationResponse['error-codes'])
+                'Google reCAPTCHA response could not be validated: '.implode(', ', $validationResponse['error-codes'])
             );
 
             return false;
diff --git a/src/QUI/Captcha/Modules/GoogleV3.php b/src/QUI/Captcha/Modules/GoogleV3.php
new file mode 100644
index 0000000..316ede8
--- /dev/null
+++ b/src/QUI/Captcha/Modules/GoogleV3.php
@@ -0,0 +1,93 @@
+<?php
+
+namespace QUI\Captcha\Modules;
+
+use QUI;
+use QUI\Captcha\Modules\GoogleV3\Control;
+
+/**
+ * Class GoogleV3
+ *
+ * Captcha provider vor Google reCAPTCHA v3
+ */
+class GoogleV3 extends Google
+{
+    /**
+     * Get control to show captcha
+     *
+     * @return Control
+     */
+    public static function getControl()
+    {
+        return new Control();
+    }
+
+    /**
+     * Check if this CAPTCHA has a visible representation or not
+     *
+     * @return bool
+     */
+    public static function isInvisible()
+    {
+        return true;
+    }
+
+    /**
+     * Validate captcha data
+     *
+     * @param string $data
+     * @return bool
+     * @throws QUI\Exception
+     */
+    public static function isValid($data)
+    {
+        $url    = 'https://www.google.com/recaptcha/api/siteverify?';
+        $params = [
+            'secret'   => self::getSecretKey(),
+            'response' => $data
+        ];
+
+        $url .= http_build_query($params);
+
+        $validationResponse = file_get_contents($url);
+
+        if (empty($validationResponse)) {
+            QUI\System\Log::addError(
+                'Google reCAPTCHA response could not be validated: Empty response.'
+            );
+
+            return false;
+        }
+
+        $validationResponse = json_decode($validationResponse, true);
+
+        if (json_last_error() !== JSON_ERROR_NONE) {
+            QUI\System\Log::addError(
+                'Google reCAPTCHA response could not be validated: Response was no valid JSON.'
+            );
+
+            return false;
+        }
+
+        if (!empty($validationResponse['error-codes'])) {
+            QUI\System\Log::addError(
+                'Google reCAPTCHA response could not be validated: '.implode(', ', $validationResponse['error-codes'])
+            );
+
+            return false;
+        }
+
+        if (empty($validationResponse['success'])) {
+            return false;
+        }
+
+        // Evaluate reCAPTCHA v3 score
+        $score = (float)$validationResponse['score'];
+
+        if ($score <= 0) {
+            return false;
+        }
+
+        return true;
+    }
+}
diff --git a/src/QUI/Captcha/Modules/GoogleV3/Control.php b/src/QUI/Captcha/Modules/GoogleV3/Control.php
new file mode 100644
index 0000000..d1f3fbe
--- /dev/null
+++ b/src/QUI/Captcha/Modules/GoogleV3/Control.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace QUI\Captcha\Modules\GoogleV3;
+
+use QUI;
+use QUI\Captcha\Modules\Google\Control as GoogleControl;
+
+/**
+ * Class Controls
+ *
+ * Google reCAPTCHA v3 Control
+ */
+class Control extends GoogleControl
+{
+    /**
+     * ControlWrapper constructor.
+     * @param array $attributes
+     */
+    public function __construct(array $attributes = [])
+    {
+        parent::__construct($attributes);
+        $this->setJavaScriptControlOption('v3', true);
+    }
+}
-- 
GitLab