From fecc9f96eb1186f4fbf3816affb41d987e36944b Mon Sep 17 00:00:00 2001 From: the-djmaze <> Date: Fri, 11 Feb 2022 14:21:41 +0100 Subject: [PATCH] Added support for reCaptcha #222 --- dev/View/User/Login.js | 8 +- plugins/recaptcha/index.php | 143 ++++++++++++++++++++++++++++++ plugins/recaptcha/js/recaptcha.js | 65 ++++++++++++++ 3 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 plugins/recaptcha/index.php create mode 100644 plugins/recaptcha/js/recaptcha.js diff --git a/dev/View/User/Login.js b/dev/View/User/Login.js index 830775947..c490c838b 100644 --- a/dev/View/User/Login.js +++ b/dev/View/User/Login.js @@ -1,6 +1,6 @@ import { Notification } from 'Common/Enums'; import { ClientSideKeyName } from 'Common/EnumsUser'; -import { SettingsGet } from 'Common/Globals'; +import { SettingsGet, fireEvent } from 'Common/Globals'; import { getNotification, translatorReload, convertLangName } from 'Common/Translator'; import { LanguageStore } from 'Stores/Language'; @@ -89,7 +89,7 @@ class LoginUserView extends AbstractViewLogin { submitCommand(self, event) { let form = event.target.form, data = new FormData(form), - valid = form.reportValidity(); + valid = form.reportValidity() && fireEvent('sm-user-login', data); this.emailError(!this.email()); this.passwordError(!this.password()); @@ -101,6 +101,10 @@ class LoginUserView extends AbstractViewLogin { data.set('SignMe', this.signMe() ? 1 : 0); Remote.request('Login', (iError, oData) => { + fireEvent('sm-user-login-response', { + error: iError, + data: oData + }); if (iError) { this.submitRequest(false); if (Notification.InvalidInputArgument == iError) { diff --git a/plugins/recaptcha/index.php b/plugins/recaptcha/index.php new file mode 100644 index 000000000..0535c2c93 --- /dev/null +++ b/plugins/recaptcha/index.php @@ -0,0 +1,143 @@ +UseLangs(true); + + $this->addJs('js/recaptcha.js'); + + $this->addHook('json.action-pre-call', 'AjaxActionPreCall'); + $this->addHook('filter.json-response', 'FilterAjaxResponse'); + } + + protected function configMapping() : array + { + return array( + \RainLoop\Plugins\Property::NewInstance('public_key')->SetLabel('Site key') + ->SetAllowedInJs(true) + ->SetDefaultValue(''), + \RainLoop\Plugins\Property::NewInstance('private_key')->SetLabel('Secret key') + ->SetDefaultValue(''), + \RainLoop\Plugins\Property::NewInstance('theme')->SetLabel('Theme') + ->SetAllowedInJs(true) + ->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECTION) + ->SetDefaultValue(array('light', 'dark')), + \RainLoop\Plugins\Property::NewInstance('error_limit')->SetLabel('Limit') + ->SetType(\RainLoop\Enumerations\PluginPropertyType::SELECTION) + ->SetDefaultValue(array('0', 1, 2, 3, 4, 5)) + ->SetDescription('') + ); + } + + /** + * @return string + */ + private function getCaptchaCacherKey() + { + return 'CaptchaNew/Login/'.\RainLoop\Utils::GetConnectionToken(); + } + + /** + * @return int + */ + private function getLimit() + { + $iConfigLimit = $this->Config()->Get('plugin', 'error_limit', 0); + if (0 < $iConfigLimit) { + $oCacher = $this->Manager()->Actions()->Cacher(); + $sLimit = $oCacher && $oCacher->IsInited() ? $oCacher->Get($this->getCaptchaCacherKey()) : '0'; + + if (\strlen($sLimit) && \is_numeric($sLimit)) { + $iConfigLimit -= (int) $sLimit; + } + } + + return $iConfigLimit; + } + + /** + * @return void + */ + public function FilterAppDataPluginSection(bool $bAdmin, bool $bAuth, array &$aConfig) : void + { + if (!$bAdmin && !$bAuth) { + $aConfig['show_captcha_on_login'] = 1; + } + } + + /** + * @param string $sAction + */ + public function AjaxActionPreCall(string $sAction) + { + if ('Login' === $sAction && 0 >= $this->getLimit()) { + $bResult = false; + + $HTTP = \SnappyMail\HTTP\Request::factory(); + $oResponse = $HTTP->doRequest('POST', 'https://www.google.com/recaptcha/api/siteverify', array( + 'secret' => $this->Config()->Get('plugin', 'private_key', ''), + 'response' => $this->Manager()->Actions()->GetActionParam('RecaptchaResponse', '') + )); + + if ($oResponse) { + $aResp = \json_decode($oResponse->body, true); + if (\is_array($aResp) && isset($aResp['success']) && $aResp['success']) { + $bResult = true; + } + } + + if (!$bResult) { + $this->Manager()->Actions()->Logger()->Write('RecaptchaResponse:'.$sResult); + throw new \RainLoop\Exceptions\ClientException(105); + } + } + } + + /** + * @param string $sAction + * @param array $aResponseItem + */ + public function FilterAjaxResponse(string $sAction, array &$aResponseItem) + { + if ('Login' === $sAction && $aResponseItem && isset($aResponseItem['Result'])) { + $oCacher = $this->Manager()->Actions()->Cacher(); + $iConfigLimit = (int) $this->Config()->Get('plugin', 'error_limit', 0); + + $sKey = $this->getCaptchaCacherKey(); + + if (0 < $iConfigLimit && $oCacher && $oCacher->IsInited()) { + if (false === $aResponseItem['Result']) { + $iLimit = 0; + $sLimut = $oCacher->Get($sKey); + if (\strlen($sLimut) && \is_numeric($sLimut)) { + $iLimit = (int) $sLimut; + } + + $oCacher->Set($sKey, ++$iLimit); + + if ($iConfigLimit <= $iLimit) { + $aResponseItem['Captcha'] = true; + } + } else { + $oCacher->Delete($sKey); + } + } + } + } +} diff --git a/plugins/recaptcha/js/recaptcha.js b/plugins/recaptcha/js/recaptcha.js new file mode 100644 index 000000000..1afa5de9a --- /dev/null +++ b/plugins/recaptcha/js/recaptcha.js @@ -0,0 +1,65 @@ + +(rl => { + + rl && addEventListener('rl-view-model', e => { + if (e.detail && 'Login' === e.detail.viewModelTemplateID + && rl.pluginSettingsGet('recaptcha', 'show_captcha_on_login')) { + let + nId = null, + script; + + const + doc = document, + + container = e.detail.viewModelDom.querySelector('#plugin-Login-BottomControlGroup'), + + ShowRecaptcha = () => { + if (window.grecaptcha && null === nId && container) { + const oEl = doc.createElement('div'); + oEl.className = 'controls'; + + container.after(oEl); + + nId = window.grecaptcha.render(oEl, { + 'sitekey': rl.pluginSettingsGet('recaptcha', 'public_key'), + 'theme': rl.pluginSettingsGet('recaptcha', 'theme') + }); + } + }, + + StartRecaptcha = () => { + if (window.grecaptcha) { + ShowRecaptcha(); + } else if (!script) { + script = doc.createElement('script'); +// script.onload = ShowRecaptcha; + script.src = 'https://www.google.com/recaptcha/api.js?onload=ShowRecaptcha&render=explicit&hl=' + doc.documentElement.lang; + doc.head.append(script); + } + }; + + window.ShowRecaptcha = ShowRecaptcha; + + StartRecaptcha(); + + addEventListener('sm-user-login', e => { + if (null !== nId && window.grecaptcha) { + e.detail.set('RecaptchaResponse', window.grecaptcha.getResponse(nId)); + } else { + e.preventDefault() + } + }); + + addEventListener('sm-user-login-response', e => { + if (e.detail.error) { + if (null !== nId && window.grecaptcha) { + window.grecaptcha.reset(nId); + } else if (e.detail.data && e.detail.data.Captcha) { + StartRecaptcha(); + } + } + }); + } + }); + +})(window.rl);