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

+ 4 - 8
flaskbb/auth/views.py

@@ -11,7 +11,7 @@
 """
 """
 from datetime import datetime, timedelta
 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,
 from flask_login import (current_user, login_user, login_required,
                          logout_user, confirm_login, login_fresh)
                          logout_user, confirm_login, login_fresh)
 from flask_babelplus import gettext as _
 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.utils.helpers import render_template, redirect_or_next
 from flaskbb.email import send_reset_token
 from flaskbb.email import send_reset_token
 from flaskbb.exceptions import AuthenticationError, LoginAttemptsExceeded
 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.user.models import User
 from flaskbb.fixtures.settings import available_languages
 from flaskbb.fixtures.settings import available_languages
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.settings import flaskbb_config
@@ -94,10 +93,7 @@ def register():
         flash(_("The registration has been disabled."), "info")
         flash(_("The registration has been disabled."), "info")
         return redirect(url_for("forum.index"))
         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.choices = available_languages()
     form.language.default = flaskbb_config['DEFAULT_LANGUAGE']
     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.password)}}
             {{ horizontal_field(form.confirm_password)}}
             {{ horizontal_field(form.confirm_password)}}
 
 
-            {% if config["RECAPTCHA_ENABLED"] %}
+            {% if flaskbb_config["RECAPTCHA_ENABLED"] %}
                 {{ horizontal_field(form.recaptcha) }}
                 {{ horizontal_field(form.recaptcha) }}
             {% endif %}
             {% 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.
     :copyright: (c) 2014 by the FlaskBB Team.
     :license: BSD, see LICENSE for more details.
     :license: BSD, see LICENSE for more details.
 """
 """
-import simplejson as json
 from datetime import datetime
 from datetime import datetime
 from wtforms.widgets.core import Select, HTMLString, html_params
 from wtforms.widgets.core import Select, HTMLString, html_params
 
 
@@ -93,33 +92,3 @@ class SelectBirthdayWidget(object):
             html.append(' ')
             html.append(' ')
 
 
         return HTMLString(''.join(html))
         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))