CloudCaptchas: Add hCaptcha support (#186)

* Add hCaptcha support
* Rename plugin to cloud_captchas
This commit is contained in:
Adrian 2022-08-11 12:08:14 +02:00 committed by GitHub
parent 8a9edc61d0
commit 535bb975fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 312 additions and 127 deletions

View File

@ -0,0 +1,4 @@
graft indico_cloud_captchas/static
graft indico_cloud_captchas/translations
global-exclude *.pyc __pycache__ .keep

27
cloud_captchas/README.md Normal file
View File

@ -0,0 +1,27 @@
# Cloud CAPTCHAs Plugin
This plugin replaces the built-in CAPTCHA with Google's reCAPTCHA v2 or hCaptcha.
This plugin also serves as an example for developers who want to use a different
CAPTCHA for their Indico instance.
![screenshot](recaptcha.png)
## Setup
The plugin requires you to set the site key and secret key on the plugin settings page.
When using reCaptcha these keys can be created on the [reCAPTCHA admin dashboard][recaptcha-create].
Choose **reCAPTCHA v2** and **"I'm not a robot" Checkbox**.
When using hCaptcha the keys can be created on the [hCaptcha dashboard][hcaptcha-dashboard].
## Changelog
### 3.2
- Initial release for Indico 3.2
[recaptcha-create]: https://www.google.com/recaptcha/admin/create
[hcaptcha-dashboard]: https://dashboard.hcaptcha.com/overview

View File

@ -8,4 +8,4 @@
from indico.util.i18n import make_bound_gettext
_ = make_bound_gettext('recaptcha')
_ = make_bound_gettext('cloud_captchas')

View File

@ -5,6 +5,7 @@
// them and/or modify them under the terms of the MIT License;
// see the LICENSE file for more details.
import HCaptcha from '@hcaptcha/react-hcaptcha';
import PropTypes from 'prop-types';
import React, {useState, useEffect, useRef, useCallback} from 'react';
import {useFormState, useForm} from 'react-final-form';
@ -14,44 +15,55 @@ import {Message, Form} from 'semantic-ui-react';
import {FinalField} from 'indico/react/forms';
import {Translate} from 'indico/react/i18n';
import './Captcha.module.scss';
import './CloudCaptcha.module.scss';
export default function Captcha({name, settings: {siteKey}, wtf}) {
export default function CloudCaptcha({name, settings: {siteKey, hCaptcha}, wtf}) {
return wtf ? (
<WTFCaptcha name={name} siteKey={siteKey} />
<WTFCloudCaptcha name={name} siteKey={siteKey} hCaptcha={hCaptcha} />
) : (
<FinalCaptcha name={name} siteKey={siteKey} />
<FinalCloudCaptcha name={name} siteKey={siteKey} hCaptcha={hCaptcha} />
);
}
Captcha.propTypes = {
CloudCaptcha.propTypes = {
name: PropTypes.string,
wtf: PropTypes.bool,
settings: PropTypes.shape({
siteKey: PropTypes.string.isRequired,
hCaptcha: PropTypes.bool.isRequired,
}).isRequired,
};
Captcha.defaultProps = {
CloudCaptcha.defaultProps = {
name: 'captcha',
wtf: false,
};
function CaptchaField({onChange, siteKey, reCaptchaRef}) {
return <ReCAPTCHA sitekey={siteKey} onChange={onChange} ref={reCaptchaRef} />;
function CloudCaptchaField({onChange, siteKey, hCaptcha, reCaptchaRef}) {
return hCaptcha ? (
<HCaptcha
sitekey={siteKey}
onVerify={onChange}
onExpire={() => onChange(null)}
ref={reCaptchaRef}
/>
) : (
<ReCAPTCHA sitekey={siteKey} onChange={onChange} ref={reCaptchaRef} />
);
}
CaptchaField.propTypes = {
CloudCaptchaField.propTypes = {
onChange: PropTypes.func.isRequired,
siteKey: PropTypes.string.isRequired,
hCaptcha: PropTypes.bool.isRequired,
reCaptchaRef: PropTypes.object,
};
CaptchaField.defaultProps = {
CloudCaptchaField.defaultProps = {
reCaptchaRef: undefined,
};
function WTFCaptcha({name, siteKey}) {
function WTFCloudCaptcha({name, siteKey, hCaptcha}) {
const fieldRef = useRef(null);
const [response, setResponse] = useState('');
const [hasError, setError] = useState(false);
@ -88,7 +100,7 @@ function WTFCaptcha({name, siteKey}) {
<Form as="div" styleName="captcha">
<input type="hidden" name={name} value={response} ref={fieldRef} />
<Form.Field error={hasError}>
<CaptchaField siteKey={siteKey} onChange={handleChange} />
<CloudCaptchaField siteKey={siteKey} onChange={handleChange} hCaptcha={hCaptcha} />
</Form.Field>
</Form>
</div>
@ -96,12 +108,13 @@ function WTFCaptcha({name, siteKey}) {
);
}
WTFCaptcha.propTypes = {
WTFCloudCaptcha.propTypes = {
name: PropTypes.string.isRequired,
siteKey: PropTypes.string.isRequired,
hCaptcha: PropTypes.bool.isRequired,
};
function FinalCaptcha({name, siteKey}) {
function FinalCloudCaptcha({name, siteKey, hCaptcha}) {
const reCaptchaRef = useRef(null);
const form = useForm();
const {submitErrors} = useFormState({
@ -132,8 +145,9 @@ function FinalCaptcha({name, siteKey}) {
<FinalField
name={name}
required
component={CaptchaField}
component={CloudCaptchaField}
siteKey={siteKey}
hCaptcha={hCaptcha}
reCaptchaRef={reCaptchaRef}
/>
</Form>
@ -142,7 +156,8 @@ function FinalCaptcha({name, siteKey}) {
);
}
FinalCaptcha.propTypes = {
FinalCloudCaptcha.propTypes = {
name: PropTypes.string.isRequired,
siteKey: PropTypes.string.isRequired,
hCaptcha: PropTypes.bool.isRequired,
};

View File

@ -7,7 +7,7 @@
import {bindTranslateComponents} from 'indico/react/i18n';
const {Translate, PluralTranslate} = bindTranslateComponents('cern_access');
const {Translate, PluralTranslate} = bindTranslateComponents('cloud_captchas');
export {Translate, PluralTranslate};
export {Singular, Plural, Param} from 'react-jsx-i18n';

View File

@ -7,6 +7,6 @@
import {registerPluginComponent} from 'indico/utils/plugins';
import Captcha from './Captcha';
import CloudCaptcha from './CloudCaptcha';
registerPluginComponent('recaptcha', 'captcha', Captcha);
registerPluginComponent('cloud_captchas', 'captcha', CloudCaptcha);

View File

@ -0,0 +1,143 @@
# This file is part of the Indico plugins.
# Copyright (C) 2002 - 2022 CERN
#
# The Indico plugins are free software; you can redistribute
# them and/or modify them under the terms of the MIT License;
# see the LICENSE file for more details.
import requests
from requests.exceptions import HTTPError, RequestException
from wtforms.fields import StringField
from wtforms.validators import DataRequired
from indico.core.plugins import IndicoPlugin
from indico.modules.api.forms import IndicoEnumSelectField
from indico.modules.core.plugins import CaptchaPluginMixin
from indico.modules.users import EnumConverter
from indico.util.enum import RichIntEnum
from indico.web.forms.base import IndicoForm
from indico.web.forms.validators import HiddenUnless
from indico.web.views import WPBase
from indico_cloud_captchas import _
class CaptchaProvider(RichIntEnum):
__titles__ = [_("None (use Indico's built-in CAPTCHA)"), 'reCAPTCHA', 'hCaptcha']
none = 0
recaptcha = 1
hcaptcha = 2
class CloudCaptchasSettingsForm(IndicoForm):
provider = IndicoEnumSelectField(
_('Type'), enum=CaptchaProvider,
description=_('Select which CAPTCHA provider you want to use')
)
# recaptcha
recaptcha_site_key = StringField(
_('reCAPTCHA site key'),
[HiddenUnless('provider', CaptchaProvider.recaptcha, preserve_data=True), DataRequired()],
description=_('The site key available in the reCAPTCHA admin dashboard')
)
recaptcha_secret_key = StringField(
_('reCAPTCHA secret key'),
[HiddenUnless('provider', CaptchaProvider.recaptcha, preserve_data=True), DataRequired()],
description=_('The secret key available in the reCAPTCHA admin dashboard')
)
# hcaptcha
hcaptcha_site_key = StringField(
_('hCaptcha site key'),
[HiddenUnless('provider', CaptchaProvider.hcaptcha, preserve_data=True), DataRequired()],
description=_('The site key available in the hCaptcha admin dashboard')
)
hcaptcha_secret_key = StringField(
_('reCAPTCHA secret key'),
[HiddenUnless('provider', CaptchaProvider.hcaptcha, preserve_data=True), DataRequired()],
description=_('The secret key available in the hCaptcha admin dashboard')
)
class CloudCaptchasPlugin(CaptchaPluginMixin, IndicoPlugin):
"""Cloud CAPTCHAs
Replaces Indico's default CAPTCHA with reCAPTCHA or hCaptcha.
"""
configurable = True
settings_form = CloudCaptchasSettingsForm
default_settings = {
'provider': CaptchaProvider.none,
'recaptcha_site_key': '',
'recaptcha_secret_key': '',
'hcaptcha_site_key': '',
'hcaptcha_secret_key': '',
}
settings_converters = {
'provider': EnumConverter(CaptchaProvider),
}
def init(self):
super().init()
# TODO split hcaptcha/recaptcha (hcaptcha is pretty big since it includes react)
self.inject_bundle('main.js', WPBase, condition=lambda: self.settings.get('provider') != CaptchaProvider.none)
self.inject_bundle('main.css', WPBase, condition=lambda: self.settings.get('provider') != CaptchaProvider.none)
def is_captcha_available(self):
provider = self.settings.get('provider')
if provider == CaptchaProvider.recaptcha:
return bool(self.settings.get('recaptcha_site_key') and self.settings.get('recaptcha_secret_key'))
elif provider == CaptchaProvider.hcaptcha:
return bool(self.settings.get('hcaptcha_site_key') and self.settings.get('hcaptcha_secret_key'))
return False
def _validate_recaptcha(self, answer):
data = {
'secret': self.settings.get('recaptcha_secret_key'),
'response': answer
}
if resp := self._validate_http_post('https://www.google.com/recaptcha/api/siteverify', data):
return resp.json()['success']
return False
def _validate_hcaptcha(self, answer):
data = {
'sitekey': self.settings.get('hcaptcha_site_key'),
'secret': self.settings.get('hcaptcha_secret_key'),
'response': answer
}
if resp := self._validate_http_post('https://hcaptcha.com/siteverify', data):
return resp.json()['success']
return False
def _validate_http_post(self, url, data):
try:
resp = requests.post(url, data=data)
resp.raise_for_status()
except HTTPError as exc:
self.logger.error('Failed to validate CAPTCHA: %s', exc.response.text)
return None
except RequestException as exc:
self.logger.error('Failed to validate CAPTCHA: %s', exc)
return None
return resp
def validate_captcha(self, answer):
if self.settings.get('provider') == CaptchaProvider.recaptcha:
return self._validate_recaptcha(answer)
elif self.settings.get('provider') == CaptchaProvider.hcaptcha:
return self._validate_hcaptcha(answer)
# should never happen
return False
def get_captcha_settings(self):
if self.settings.get('provider') == CaptchaProvider.recaptcha:
return {
'siteKey': self.settings.get('recaptcha_site_key'),
'hCaptcha': False,
}
elif self.settings.get('provider') == CaptchaProvider.hcaptcha:
return {
'siteKey': self.settings.get('hcaptcha_site_key'),
'hCaptcha': True,
}

View File

@ -1,16 +1,38 @@
{
"name": "recaptcha",
"version": "1.0.0",
"name": "indico-plugin-cloud-captchas",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "recaptcha",
"version": "1.0.0",
"name": "indico-plugin-cloud-captchas",
"dependencies": {
"@hcaptcha/react-hcaptcha": "^1.4.4",
"react-google-recaptcha": "^2.1.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.18.9",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.18.9.tgz",
"integrity": "sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw==",
"dependencies": {
"regenerator-runtime": "^0.13.4"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@hcaptcha/react-hcaptcha": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/@hcaptcha/react-hcaptcha/-/react-hcaptcha-1.4.4.tgz",
"integrity": "sha512-Aen217LDnf5ywbPSwBG5CsoqBLIHIAS9lhj3zQjXJuO13doQ6/ubkCWNuY8jmwYLefoFt3V3MrZmCdKDaFoTuQ==",
"dependencies": {
"@babel/runtime": "^7.17.9"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@ -77,6 +99,19 @@
"react": ">=16.4.1"
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
},
"peerDependencies": {
"react": "^18.2.0"
}
},
"node_modules/react-google-recaptcha": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-2.1.0.tgz",
@ -93,9 +128,39 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/regenerator-runtime": {
"version": "0.13.9",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
"integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
},
"node_modules/scheduler": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
"integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
}
},
"dependencies": {
"@babel/runtime": {
"version": "7.18.9",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.18.9.tgz",
"integrity": "sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"@hcaptcha/react-hcaptcha": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/@hcaptcha/react-hcaptcha/-/react-hcaptcha-1.4.4.tgz",
"integrity": "sha512-Aen217LDnf5ywbPSwBG5CsoqBLIHIAS9lhj3zQjXJuO13doQ6/ubkCWNuY8jmwYLefoFt3V3MrZmCdKDaFoTuQ==",
"requires": {
"@babel/runtime": "^7.17.9"
}
},
"hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@ -150,6 +215,16 @@
"prop-types": "^15.5.0"
}
},
"react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"peer": true,
"requires": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
}
},
"react-google-recaptcha": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-2.1.0.tgz",
@ -163,6 +238,20 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"regenerator-runtime": {
"version": "0.13.9",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
"integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
},
"scheduler": {
"version": "0.23.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz",
"integrity": "sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==",
"peer": true,
"requires": {
"loose-envify": "^1.1.0"
}
}
}
}

View File

@ -0,0 +1,8 @@
{
"private": true,
"name": "indico-plugin-cloud-captchas",
"dependencies": {
"@hcaptcha/react-hcaptcha": "^1.4.4",
"react-google-recaptcha": "^2.1.0"
}
}

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,5 +1,5 @@
[metadata]
name = indico-plugin-recaptcha
name = indico-plugin-cloud-captchas
version = 3.2-dev
description = Google reCAPTCHA plugin for Indico
long_description = file: README.md
@ -24,7 +24,7 @@ install_requires =
[options.entry_points]
indico.plugins =
recaptcha = indico_recaptcha.plugin:ReCaptchaPlugin
cloud_captchas = indico_cloud_captchas.plugin:CloudCaptchasPlugin

View File

@ -1,4 +0,0 @@
graft indico_recaptcha/static
graft indico_recaptcha/translations
global-exclude *.pyc __pycache__ .keep

View File

@ -1,23 +0,0 @@
# Google reCAPTCHA Plugin
This plugin replaces the built-in CAPTCHA with Google's reCAPTCHA v2.
This plugin also serves as an example for developers who want to use a
different CAPTCHA for their Indico instance.
![screenshot](recaptcha.png)
## Setup
The plugin requires you to set the reCAPTCHA site key and secret key on the plugin
settings page. These keys can be created on the [reCAPTCHA admin dashboard][recaptcha-create].
Choose **reCAPTCHA v2** and **"I'm not a robot" Checkbox**.
## Changelog
### 3.2
- Initial release for Indico 3.2
[recaptcha-create]: https://www.google.com/recaptcha/admin/create

View File

@ -1,64 +0,0 @@
# This file is part of the Indico plugins.
# Copyright (C) 2002 - 2022 CERN
#
# The Indico plugins are free software; you can redistribute
# them and/or modify them under the terms of the MIT License;
# see the LICENSE file for more details.
import requests
from requests.exceptions import RequestException
from wtforms.fields import BooleanField, StringField
from wtforms.validators import DataRequired
from indico.core.plugins import IndicoPlugin
from indico.modules.core.plugins import CaptchaPluginMixin
from indico.web.forms.base import IndicoForm
from indico.web.forms.widgets import SwitchWidget
from indico.web.views import WPBase
from indico_recaptcha import _
class ReCaptchaSettingsForm(IndicoForm):
enabled = BooleanField(_('Enabled'), widget=SwitchWidget(), description=_('Whether to enable the access overrides'))
site_key = StringField(_('Site key'), [DataRequired()],
description=_('The site key available in the reCAPTCHA admin dashboard'))
secret_key = StringField(_('Secret key'), [DataRequired()],
description=_('The secret key available in the reCAPTCHA admin dashboard'))
class ReCaptchaPlugin(CaptchaPluginMixin, IndicoPlugin):
"""Google reCAPTCHA
Replaces Indico's default CAPTCHA with Google reCAPTCHA.
"""
configurable = True
settings_form = ReCaptchaSettingsForm
default_settings = {
'enabled': False,
'site_key': '',
'secret_key': '',
}
def init(self):
super().init()
self.inject_bundle('main.js', WPBase, condition=lambda: self.settings.get('enabled'))
self.inject_bundle('main.css', WPBase, condition=lambda: self.settings.get('enabled'))
def is_captcha_available(self):
return self.settings.get('enabled') and bool(self.settings.get('site_key'))
def validate_captcha(self, answer):
secret = self.settings.get('secret_key')
resp = requests.post('https://www.google.com/recaptcha/api/siteverify',
data={'secret': secret, 'response': answer})
try:
resp.raise_for_status()
except RequestException as exc:
self.logger.error('Failed to validate CAPTCHA: %s', exc.response.text)
return False
return resp.json()['success']
def get_captcha_settings(self):
return {'siteKey': self.settings.get('site_key')}

View File

@ -1,10 +0,0 @@
{
"name": "recaptcha",
"private": true,
"version": "1.0.0",
"repository": "https://github.com/indico/indico-plugins",
"author": "Indico Team <indico-team@cern.ch>",
"dependencies": {
"react-google-recaptcha": "^2.1.0"
}
}