Browse Source

Add reCAPTCHA configuration from the AdminCP

The recaptcha widget, validator and field are taken from Flask-WTF and
modified for our own use. It is also possible to disable recaptcha
without ugly hacks now.
Peter Justin 9 years ago
parent
commit
a7de6ea780

+ 14 - 0
flaskbb/_compat.py

@@ -27,3 +27,17 @@ else:           # pragma: no cover
     itervalues = lambda d: d.itervalues()
     iteritems = lambda d: d.iteritems()
     max_integer = sys.maxint
+
+
+def to_bytes(text):
+    """Transform string to bytes."""
+    if isinstance(text, text_type):
+        text = text.encode('utf-8')
+    return text
+
+
+def to_unicode(input_bytes, encoding='utf-8'):
+    """Decodes input_bytes to text if needed."""
+    if not isinstance(input_bytes, string_types):
+        input_bytes = input_bytes.decode(encoding)
+    return input_bytes

+ 4 - 9
flaskbb/auth/forms.py

@@ -10,13 +10,14 @@
 """
 from datetime import datetime
 
-from flask_wtf import Form, RecaptchaField
+from flask_wtf import Form
 from wtforms import (StringField, PasswordField, BooleanField, HiddenField,
                      SubmitField, SelectField)
 from wtforms.validators import (DataRequired, InputRequired, Email, EqualTo,
                                 regexp, ValidationError)
 from flask_babelplus import lazy_gettext as _
 from flaskbb.user.models import User
+from flaskbb.utils.recaptcha import RecaptchaField
 
 USERNAME_RE = r'^[\w.+-]+$'
 is_username = regexp(USERNAME_RE,
@@ -36,10 +37,6 @@ class LoginForm(Form):
     submit = SubmitField(_("Login"))
 
 
-class LoginRecaptchaForm(LoginForm):
-    recaptcha = RecaptchaField(_("Captcha"))
-
-
 class RegisterForm(Form):
     username = StringField(_("Username"), validators=[
         DataRequired(message=_("A Username is required.")),
@@ -55,6 +52,8 @@ class RegisterForm(Form):
 
     confirm_password = PasswordField(_('Confirm Password'))
 
+    recaptcha = RecaptchaField(_("Captcha"))
+
     language = SelectField(_('Language'))
 
     accept_tos = BooleanField(_("I accept the Terms of Service"), default=True)
@@ -81,10 +80,6 @@ class RegisterForm(Form):
         return user.save()
 
 
-class RegisterRecaptchaForm(RegisterForm):
-    recaptcha = RecaptchaField(_("Captcha"))
-
-
 class ReauthForm(Form):
     password = PasswordField(_('Password'), validators=[
         DataRequired(message=_("A Password is required."))])

+ 4 - 8
flaskbb/auth/views.py

@@ -11,7 +11,7 @@
 """
 from datetime import datetime, timedelta
 
-from flask import Blueprint, flash, redirect, url_for, request, current_app
+from flask import Blueprint, flash, redirect, url_for, request
 from flask_login import (current_user, login_user, login_required,
                          logout_user, confirm_login, login_fresh)
 from flask_babelplus import gettext as _
@@ -19,9 +19,8 @@ from flask_babelplus import gettext as _
 from flaskbb.utils.helpers import render_template, redirect_or_next
 from flaskbb.email import send_reset_token
 from flaskbb.exceptions import AuthenticationError, LoginAttemptsExceeded
-from flaskbb.auth.forms import (LoginForm, LoginRecaptchaForm, ReauthForm,
-                                ForgotPasswordForm, ResetPasswordForm,
-                                RegisterRecaptchaForm, RegisterForm)
+from flaskbb.auth.forms import (LoginForm, ReauthForm, ForgotPasswordForm,
+                                ResetPasswordForm, RegisterForm)
 from flaskbb.user.models import User
 from flaskbb.fixtures.settings import available_languages
 from flaskbb.utils.settings import flaskbb_config
@@ -94,10 +93,7 @@ def register():
         flash(_("The registration has been disabled."), "info")
         return redirect(url_for("forum.index"))
 
-    if current_app.config["RECAPTCHA_ENABLED"]:
-        form = RegisterRecaptchaForm(request.form)
-    else:
-        form = RegisterForm(request.form)
+    form = RegisterForm(request.form)
 
     form.language.choices = available_languages()
     form.language.default = flaskbb_config['DEFAULT_LANGUAGE']

+ 1 - 1
flaskbb/templates/auth/register.html

@@ -17,7 +17,7 @@
             {{ horizontal_field(form.password)}}
             {{ horizontal_field(form.confirm_password)}}
 
-            {% if config["RECAPTCHA_ENABLED"] %}
+            {% if flaskbb_config["RECAPTCHA_ENABLED"] %}
                 {{ horizontal_field(form.recaptcha) }}
             {% endif %}
 

+ 137 - 0
flaskbb/utils/recaptcha.py

@@ -0,0 +1,137 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.utils.recaptcha
+    ~~~~~~~~~~~~~~~~~~~~~~~
+
+    The reCAPTCHA Field. Taken from Flask-WTF and modified
+    to use our own settings system.
+
+    :copyright: (c) 2014 by the FlaskBB Team.
+    :license: BSD, see LICENSE for more details.
+"""
+from wtforms.fields import Field
+
+try:
+    import urllib2 as http
+except ImportError:
+    # Python 3
+    from urllib import request as http
+
+from flask import request, current_app, Markup, json
+from werkzeug import url_encode
+from wtforms import ValidationError
+
+from flaskbb._compat import to_bytes, to_unicode
+from flaskbb.utils.settings import flaskbb_config
+
+JSONEncoder = json.JSONEncoder
+
+RECAPTCHA_SCRIPT = u'https://www.google.com/recaptcha/api.js'
+RECAPTCHA_TEMPLATE = u'''
+<script src='%s' async defer></script>
+<div class="g-recaptcha" %s></div>
+'''
+
+RECAPTCHA_VERIFY_SERVER = 'https://www.google.com/recaptcha/api/siteverify'
+RECAPTCHA_ERROR_CODES = {
+    'missing-input-secret': 'The secret parameter is missing.',
+    'invalid-input-secret': 'The secret parameter is invalid or malformed.',
+    'missing-input-response': 'The response parameter is missing.',
+    'invalid-input-response': 'The response parameter is invalid or malformed.'
+}
+
+
+class RecaptchaValidator(object):
+    """Validates a ReCaptcha."""
+
+    def __init__(self, message=None):
+        if message is None:
+            message = RECAPTCHA_ERROR_CODES['missing-input-response']
+        self.message = message
+
+    def __call__(self, form, field):
+        if current_app.testing or not flaskbb_config["RECAPTCHA_ENABLED"]:
+            return True
+
+        if request.json:
+            response = request.json.get('g-recaptcha-response', '')
+        else:
+            response = request.form.get('g-recaptcha-response', '')
+        remote_ip = request.remote_addr
+
+        if not response:
+            raise ValidationError(field.gettext(self.message))
+
+        if not self._validate_recaptcha(response, remote_ip):
+            field.recaptcha_error = 'incorrect-captcha-sol'
+            raise ValidationError(field.gettext(self.message))
+
+    def _validate_recaptcha(self, response, remote_addr):
+        """Performs the actual validation."""
+        try:
+            private_key = flaskbb_config['RECAPTCHA_PRIVATE_KEY']
+        except KeyError:
+            raise RuntimeError("No RECAPTCHA_PRIVATE_KEY config set")
+
+        data = url_encode({
+            'secret':     private_key,
+            'remoteip':   remote_addr,
+            'response':   response
+        })
+
+        http_response = http.urlopen(RECAPTCHA_VERIFY_SERVER, to_bytes(data))
+
+        if http_response.code != 200:
+            return False
+
+        json_resp = json.loads(to_unicode(http_response.read()))
+
+        if json_resp["success"]:
+            return True
+
+        for error in json_resp.get("error-codes", []):
+            if error in RECAPTCHA_ERROR_CODES:
+                raise ValidationError(RECAPTCHA_ERROR_CODES[error])
+
+        return False
+
+
+class RecaptchaWidget(object):
+
+    def recaptcha_html(self, public_key):
+        html = current_app.config.get('RECAPTCHA_HTML')
+        if html:
+            return Markup(html)
+        params = current_app.config.get('RECAPTCHA_PARAMETERS')
+        script = RECAPTCHA_SCRIPT
+        if params:
+            script += u'?' + url_encode(params)
+
+        attrs = current_app.config.get('RECAPTCHA_DATA_ATTRS', {})
+        attrs['sitekey'] = public_key
+        snippet = u' '.join([u'data-%s="%s"' % (k, attrs[k]) for k in attrs])
+        return Markup(RECAPTCHA_TEMPLATE % (script, snippet))
+
+    def __call__(self, field, error=None, **kwargs):
+        """Returns the recaptcha input HTML."""
+
+        if not flaskbb_config["RECAPTCHA_ENABLED"]:
+            return
+
+        try:
+            public_key = flaskbb_config['RECAPTCHA_PUBLIC_KEY']
+        except KeyError:
+            raise RuntimeError("RECAPTCHA_PUBLIC_KEY config not set")
+
+        return self.recaptcha_html(public_key)
+
+
+class RecaptchaField(Field):
+    widget = RecaptchaWidget()
+
+    # error message if recaptcha validation fails
+    recaptcha_error = None
+
+    def __init__(self, label='', validators=None, **kwargs):
+        validators = validators or [RecaptchaValidator()]
+        super(RecaptchaField, self).__init__(label, validators, **kwargs)

+ 0 - 31
flaskbb/utils/widgets.py

@@ -8,7 +8,6 @@
     :copyright: (c) 2014 by the FlaskBB Team.
     :license: BSD, see LICENSE for more details.
 """
-import simplejson as json
 from datetime import datetime
 from wtforms.widgets.core import Select, HTMLString, html_params
 
@@ -93,33 +92,3 @@ class SelectBirthdayWidget(object):
             html.append(' ')
 
         return HTMLString(''.join(html))
-
-
-class MultiSelect(object):
-    """
-    Renders a megalist-multiselect widget.
-
-
-    The field must provide an `iter_choices()` method which the widget will
-    call on rendering; this method must yield tuples of
-    `(value, label, selected)`.
-    """
-
-    def __call__(self, field, **kwargs):
-        kwargs.setdefault('id', field.id)
-        src_list, dst_list = [], []
-
-        for val, label, selected in field.iter_choices():
-            if selected:
-                dst_list.append({'label':label, 'listValue':val})
-            else:
-                src_list.append({'label':label, 'listValue':val})
-        kwargs.update(
-            {
-                'data-provider-src':json.dumps(src_list),
-                'data-provider-dst':json.dumps(dst_list)
-            }
-        )
-        html = ['<div %s>' % html_params(name=field.name, **kwargs)]
-        html.append('</div>')
-        return HTMLString(''.join(html))