Просмотр исходного кода

Merge branch 'authentication-revamp'

sh4nks 9 лет назад
Родитель
Сommit
c5b0d40545
36 измененных файлов с 872 добавлено и 190 удалено
  1. 14 0
      flaskbb/_compat.py
  2. 4 1
      flaskbb/app.py
  3. 37 5
      flaskbb/auth/forms.py
  4. 153 54
      flaskbb/auth/views.py
  5. 22 0
      flaskbb/configs/default.py
  6. 27 13
      flaskbb/configs/production.py.example
  7. 22 2
      flaskbb/email.py
  8. 12 0
      flaskbb/exceptions.py
  9. 5 0
      flaskbb/extensions.py
  10. 64 0
      flaskbb/fixtures/settings.py
  11. 3 0
      flaskbb/management/forms.py
  12. 22 0
      flaskbb/templates/auth/account_activation.html
  13. 8 2
      flaskbb/templates/auth/forgot_password.html
  14. 4 0
      flaskbb/templates/auth/login.html
  15. 1 1
      flaskbb/templates/auth/register.html
  16. 23 0
      flaskbb/templates/auth/request_account_activation.html
  17. 17 10
      flaskbb/templates/auth/reset_password.html
  18. 10 0
      flaskbb/templates/email/activate_account.html
  19. 11 0
      flaskbb/templates/email/activate_account.txt
  20. 6 2
      flaskbb/templates/email/reset_password.html
  21. 1 1
      flaskbb/templates/email/reset_password.txt
  22. 16 0
      flaskbb/templates/errors/too_many_logins.html
  23. 2 0
      flaskbb/templates/layout.html
  24. 1 0
      flaskbb/templates/management/user_form.html
  25. 45 52
      flaskbb/user/models.py
  26. 25 8
      flaskbb/utils/helpers.py
  27. 3 0
      flaskbb/utils/populate.py
  28. 137 0
      flaskbb/utils/recaptcha.py
  29. 71 0
      flaskbb/utils/tokens.py
  30. 0 31
      flaskbb/utils/widgets.py
  31. 3 1
      manage.py
  32. 30 0
      migrations/versions/221d918aa9f0_add_user_authentication_infos.py
  33. 4 1
      requirements.txt
  34. 3 1
      setup.py
  35. 11 5
      tests/fixtures/user.py
  36. 55 0
      tests/unit/utils/test_tokens.py

+ 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 - 1
flaskbb/app.py

@@ -35,7 +35,7 @@ from flaskbb.forum.views import forum
 from flaskbb.forum.models import Post, Topic, Category, Forum
 # extensions
 from flaskbb.extensions import db, login_manager, mail, cache, redis_store, \
-    debugtoolbar, migrate, themes, plugin_manager, babel, csrf, allows
+    debugtoolbar, migrate, themes, plugin_manager, babel, csrf, allows, limiter
 # various helpers
 from flaskbb.utils.helpers import format_date, time_since, crop_title, \
     is_online, render_markup, mark_online, forum_is_unread, topic_is_unread, \
@@ -117,6 +117,9 @@ def configure_extensions(app):
     # Flask-And-Redis
     redis_store.init_app(app)
 
+    # Flask-Limiter
+    limiter.init_app(app)
+
     # Flask-WhooshAlchemy
     with app.app_context():
         whoosh_index(app, Post)

+ 37 - 5
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,
@@ -31,6 +32,8 @@ class LoginForm(Form):
     password = PasswordField(_("Password"), validators=[
         DataRequired(message=_("A Password is required."))])
 
+    recaptcha = RecaptchaField(_("Captcha"))
+
     remember_me = BooleanField(_("Remember Me"), default=False)
 
     submit = SubmitField(_("Login"))
@@ -51,6 +54,7 @@ class RegisterForm(Form):
 
     confirm_password = PasswordField(_('Confirm Password'))
 
+    recaptcha = RecaptchaField(_("Captcha"))
 
     language = SelectField(_('Language'))
 
@@ -78,10 +82,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."))])
@@ -94,6 +94,8 @@ class ForgotPasswordForm(Form):
         DataRequired(message=_("A E-Mail Address is reguired.")),
         Email()])
 
+    recaptcha = RecaptchaField(_("Captcha"))
+
     submit = SubmitField(_("Request Password"))
 
 
@@ -116,3 +118,33 @@ class ResetPasswordForm(Form):
         email = User.query.filter_by(email=field.data).first()
         if not email:
             raise ValidationError(_("Wrong E-Mail Address."))
+
+
+class RequestActivationForm(Form):
+    username = StringField(_("Username"), validators=[
+        DataRequired(message=_("A Username is required.")),
+        is_username])
+
+    email = StringField(_("E-Mail Address"), validators=[
+        DataRequired(message=_("A E-Mail Address is required.")),
+        Email(message=_("Invalid E-Mail Address."))])
+
+    submit = SubmitField(_("Send Confirmation Mail"))
+
+    def validate_email(self, field):
+        self.user = User.query.filter_by(email=field.data).first()
+        # check if the username matches the one found in the database
+        if not self.user.username == self.username.data:
+            raise ValidationError(_("Account does not exist."))
+
+        if self.user.activated is not None:
+            raise ValidationError(_("Account is already active."))
+
+
+class AccountActivationForm(Form):
+    token = StringField(_("E-Mail Confirmation Token"), validators=[
+        DataRequired(message=_("Please enter the token that we have sent to "
+                               "you."))
+    ])
+
+    submit = SubmitField(_("Confirm E-Mail"))

+ 153 - 54
flaskbb/auth/views.py

@@ -9,59 +9,108 @@
     :copyright: (c) 2014 by the FlaskBB Team.
     :license: BSD, see LICENSE for more details.
 """
-from flask import Blueprint, flash, redirect, url_for, request, current_app
+from datetime import datetime
+
+from flask import Blueprint, flash, redirect, url_for, request, g
 from flask_login import (current_user, login_user, login_required,
                          logout_user, confirm_login, login_fresh)
 from flask_babelplus import gettext as _
 
-from flaskbb.utils.helpers import render_template
-from flaskbb.email import send_reset_token
+from flaskbb.extensions import limiter
+from flaskbb.utils.helpers import (render_template, redirect_or_next,
+                                   format_timedelta)
+from flaskbb.email import send_reset_token, send_activation_token
+from flaskbb.exceptions import AuthenticationError
 from flaskbb.auth.forms import (LoginForm, ReauthForm, ForgotPasswordForm,
-                                ResetPasswordForm)
+                                ResetPasswordForm, RegisterForm,
+                                AccountActivationForm, RequestActivationForm)
 from flaskbb.user.models import User
 from flaskbb.fixtures.settings import available_languages
 from flaskbb.utils.settings import flaskbb_config
+from flaskbb.utils.tokens import get_token_status
 
 auth = Blueprint("auth", __name__)
 
 
+@auth.before_request
+def check_rate_limiting():
+    """Check the the rate limits for each request for this blueprint."""
+    if not flaskbb_config["AUTH_RATELIMIT_ENABLED"]:
+        return None
+    return limiter.check()
+
+
+@auth.errorhandler(429)
+def login_rate_limit_error(error):
+    """Register a custom error handler for a 'Too Many Requests'
+    (HTTP CODE 429) error."""
+    return render_template("errors/too_many_logins.html",
+                           timeout=error.description)
+
+
+def login_rate_limit():
+    """Dynamically load the rate limiting config from the database."""
+    # [count] [per|/] [n (optional)] [second|minute|hour|day|month|year]
+    return "{count}/{timeout}minutes".format(
+        count=flaskbb_config["AUTH_REQUESTS"],
+        timeout=flaskbb_config["AUTH_TIMEOUT"]
+    )
+
+
+def login_rate_limit_message():
+    """Display the amount of time left until the user can access the requested
+    resource again."""
+    current_limit = getattr(g, 'view_rate_limit', None)
+    if current_limit is not None:
+        window_stats = limiter.limiter.get_window_stats(*current_limit)
+        reset_time = datetime.utcfromtimestamp(window_stats[0])
+        timeout = reset_time - datetime.utcnow()
+    return "{timeout}".format(timeout=format_timedelta(timeout))
+
+
+# Activate rate limiting on the whole blueprint
+limiter.limit(login_rate_limit, error_message=login_rate_limit_message)(auth)
+
+
 @auth.route("/login", methods=["GET", "POST"])
 def login():
-    """
-    Logs the user in
-    """
+    """Logs the user in."""
+
+    current_limit = getattr(g, 'view_rate_limit', None)
+    login_recaptcha = False
+    if current_limit is not None:
+        window_stats = limiter.limiter.get_window_stats(*current_limit)
+        stats_diff = flaskbb_config["AUTH_REQUESTS"] - window_stats[1]
+        login_recaptcha = stats_diff >= flaskbb_config["LOGIN_RECAPTCHA"]
 
     if current_user is not None and current_user.is_authenticated:
-        return redirect(url_for("user.profile"))
+        return redirect_or_next(url_for("forum.index"))
 
     form = LoginForm(request.form)
     if form.validate_on_submit():
-        user, authenticated = User.authenticate(form.login.data,
-                                                form.password.data)
-
-        if user and authenticated:
+        try:
+            user = User.authenticate(form.login.data, form.password.data)
             login_user(user, remember=form.remember_me.data)
-            return redirect(request.args.get("next") or
-                            url_for("forum.index"))
+            return redirect_or_next(url_for("forum.index"))
+        except AuthenticationError:
+            flash(_("Wrong Username or Password."), "danger")
 
-        flash(_("Wrong Username or Password."), "danger")
-    return render_template("auth/login.html", form=form)
+    return render_template("auth/login.html", form=form,
+                           login_recaptcha=login_recaptcha)
 
 
 @auth.route("/reauth", methods=["GET", "POST"])
+@limiter.exempt
 @login_required
 def reauth():
-    """
-    Reauthenticates a user
-    """
-
+    """Reauthenticates a user."""
     if not login_fresh():
         form = ReauthForm(request.form)
         if form.validate_on_submit():
             if current_user.check_password(form.password.data):
                 confirm_login()
                 flash(_("Reauthenticated."), "success")
-                return redirect(request.args.get("next") or current_user.url)
+                return redirect_or_next(current_user.url)
 
             flash(_("Wrong password."), "danger")
         return render_template("auth/reauth.html", form=form)
@@ -69,29 +118,26 @@ def reauth():
 
 
 @auth.route("/logout")
+@limiter.exempt
 @login_required
 def logout():
+    """Logs the user out."""
     logout_user()
-    flash(("Logged out"), "success")
+    flash(_("Logged out"), "success")
     return redirect(url_for("forum.index"))
 
 
 @auth.route("/register", methods=["GET", "POST"])
 def register():
-    """
-    Register a new user
-    """
-
+    """Register a new user."""
     if current_user is not None and current_user.is_authenticated:
-        return redirect(url_for("user.profile",
-                                username=current_user.username))
+        return redirect_or_next(url_for("forum.index"))
 
-    if current_app.config["RECAPTCHA_ENABLED"]:
-        from flaskbb.auth.forms import RegisterRecaptchaForm
-        form = RegisterRecaptchaForm(request.form)
-    else:
-        from flaskbb.auth.forms import RegisterForm
-        form = RegisterForm(request.form)
+    if not flaskbb_config["REGISTRATION_ENABLED"]:
+        flash(_("The registration has been disabled."), "info")
+        return redirect_or_next(url_for("forum.index"))
+
+    form = RegisterForm(request.form)
 
     form.language.choices = available_languages()
     form.language.default = flaskbb_config['DEFAULT_LANGUAGE']
@@ -99,21 +145,23 @@ def register():
 
     if form.validate_on_submit():
         user = form.save()
-        login_user(user)
 
-        flash(_("Thanks for registering."), "success")
-        return redirect(url_for("user.profile",
-                                username=current_user.username))
+        if flaskbb_config["ACTIVATE_ACCOUNT"]:
+            send_activation_token(user)
+            flash(_("An account activation email has been sent to %(email)s",
+                    email=user.email), "success")
+        else:
+            login_user(user)
+            flash(_("Thanks for registering."), "success")
+
+        return redirect_or_next(current_user.url)
 
     return render_template("auth/register.html", form=form)
 
 
-@auth.route('/resetpassword', methods=["GET", "POST"])
+@auth.route('/reset-password', methods=["GET", "POST"])
 def forgot_password():
-    """
-    Sends a reset password token to the user.
-    """
-
+    """Sends a reset password token to the user."""
     if not current_user.is_anonymous:
         return redirect(url_for("forum.index"))
 
@@ -122,9 +170,7 @@ def forgot_password():
         user = User.query.filter_by(email=form.email.data).first()
 
         if user:
-            token = user.make_reset_token()
-            send_reset_token(user, token=token)
-
+            send_reset_token(user)
             flash(_("E-Mail sent! Please check your inbox."), "info")
             return redirect(url_for("auth.forgot_password"))
         else:
@@ -133,19 +179,16 @@ def forgot_password():
     return render_template("auth/forgot_password.html", form=form)
 
 
-@auth.route("/resetpassword/<token>", methods=["GET", "POST"])
+@auth.route("/reset-password/<token>", methods=["GET", "POST"])
 def reset_password(token):
-    """
-    Handles the reset password process.
-    """
-
+    """Handles the reset password process."""
     if not current_user.is_anonymous:
         return redirect(url_for("forum.index"))
 
     form = ResetPasswordForm()
     if form.validate_on_submit():
-        user = User.query.filter_by(email=form.email.data).first()
-        expired, invalid, data = user.verify_reset_token(form.token.data)
+        expired, invalid, user = get_token_status(form.token.data,
+                                                  "reset_password")
 
         if invalid:
             flash(_("Your Password Token is invalid."), "danger")
@@ -155,7 +198,7 @@ def reset_password(token):
             flash(_("Your Password Token is expired."), "danger")
             return redirect(url_for("auth.forgot_password"))
 
-        if user and data:
+        if user:
             user.password = form.password.data
             user.save()
             flash(_("Your Password has been updated."), "success")
@@ -163,3 +206,59 @@ def reset_password(token):
 
     form.token.data = token
     return render_template("auth/reset_password.html", form=form)
+
+
+@auth.route("/activate", methods=["GET", "POST"])
+def request_activation_token(token=None):
+    """Requests a new account activation token."""
+    if current_user.is_active or not flaskbb_config["ACTIVATE_ACCOUNT"]:
+        flash(_("This account is already activated."), "info")
+        return redirect(url_for('forum.index'))
+
+    form = RequestActivationForm()
+    if form.validate_on_submit():
+        user = User.query.filter_by(email=form.email.data).first()
+        send_activation_token(user)
+        flash(_("A new account activation token has been sent to "
+                "your email address."), "success")
+        return redirect(url_for("auth.activate_account"))
+
+    return render_template("auth/request_account_activation.html", form=form)
+
+
+@auth.route("/activate/<token>", methods=["GET", "POST"])
+def activate_account(token=None):
+    """Handles the account activation process."""
+    if current_user.is_active or not flaskbb_config["ACTIVATE_ACCOUNT"]:
+        flash(_("This account is already activated."), "info")
+        return redirect(url_for('forum.index'))
+
+    form = None
+    if token is not None:
+        expired, invalid, user = get_token_status(token, "activate_account")
+    else:
+        form = AccountActivationForm()
+        if form.validate_on_submit():
+            expired, invalid, user = get_token_status(form.token.data,
+                                                      "activate_account")
+
+    if invalid:
+        flash(_("Your account activation token is invalid."), "danger")
+        return redirect(url_for("auth.request_email_confirmation"))
+
+    if expired:
+        flash(_("Your account activation token is expired."), "danger")
+        return redirect(url_for("auth.request_activation_token"))
+
+    if user:
+        user.activated = datetime.utcnow()
+        user.save()
+
+        if current_user != user:
+            logout_user()
+            login_user(user)
+
+        flash(_("Your Account has been activated.", "success"))
+        return redirect(url_for("forum.index"))
+
+    return render_template("auth/account_activation.html", form=form)

+ 22 - 0
flaskbb/configs/default.py

@@ -11,6 +11,7 @@
 """
 import os
 import sys
+import datetime
 
 _VERSION_STR = '{0.major}{0.minor}'.format(sys.version_info)
 
@@ -64,6 +65,27 @@ class DefaultConfig(object):
     LOGIN_MESSAGE_CATEGORY = "info"
     REFRESH_MESSAGE_CATEGORY = "info"
 
+    # The name of the cookie to store the “remember me” information in.
+    # Default: remember_token
+    #REMEMBER_COOKIE_NAME = "remember_token"
+    # The amount of time before the cookie expires, as a datetime.timedelta object.
+    # Default: 365 days (1 non-leap Gregorian year)
+    #REMEMBER_COOKIE_DURATION = datetime.timedelta(days=365)
+    # If the “Remember Me” cookie should cross domains,
+    # set the domain value here (i.e. .example.com would allow the cookie
+    # to be used on all subdomains of example.com).
+    # Default: None
+    #REMEMBER_COOKIE_DOMAIN = None
+    # Limits the “Remember Me” cookie to a certain path.
+    # Default: /
+    #REMEMBER_COOKIE_PATH = "/"
+    # Restricts the “Remember Me” cookie’s scope to secure channels (typically HTTPS).
+    # Default: None
+    #REMEMBER_COOKIE_SECURE = None
+    # Prevents the “Remember Me” cookie from being accessed by client-side scripts.
+    # Default: False
+    #REMEMBER_COOKIE_HTTPONLY = False
+
     # Caching
     CACHE_TYPE = "simple"
     CACHE_DEFAULT_TIMEOUT = 60

+ 27 - 13
flaskbb/configs/production.py.example

@@ -51,23 +51,24 @@ class ProductionConfig(DefaultConfig):
 
     ## Mail
     # Local SMTP Server
-    #MAIL_SERVER = "localhost"
-    #MAIL_PORT = 25
-    #MAIL_USE_SSL = False
-    #MAIL_USERNAME = ""
-    #MAIL_PASSWORD = ""
-    #MAIL_DEFAULT_SENDER = "noreply@example.org"
+    MAIL_SERVER = "localhost"
+    MAIL_PORT = 25
+    MAIL_USE_SSL = False
+    MAIL_USERNAME = ""
+    MAIL_PASSWORD = ""
+    MAIL_DEFAULT_SENDER = ("FlaskBB Mailer", "noreply@example.org")
+
 
     # Google Mail Example
-    MAIL_SERVER = "smtp.gmail.com"
-    MAIL_PORT = 465
-    MAIL_USE_SSL = True
-    MAIL_USERNAME = "your_username@gmail.com"
-    MAIL_PASSWORD = "your_password"
-    MAIL_DEFAULT_SENDER = ("Your Name", "your_username@gmail.com")
+    #MAIL_SERVER = "smtp.gmail.com"
+    #MAIL_PORT = 465
+    #MAIL_USE_SSL = True
+    #MAIL_USERNAME = "your_username@gmail.com"
+    #MAIL_PASSWORD = "your_password"
+    #MAIL_DEFAULT_SENDER = ("Your Name", "your_username@gmail.com")
 
     # The user who should recieve the error logs
-    ADMINS = ["your_admin_user@gmail.com"]
+    #ADMINS = ["your_admin_user@gmail.com"]
 
 
     ## Error/Info Logging
@@ -85,6 +86,19 @@ class ProductionConfig(DefaultConfig):
     REDIS_URL = "redis://:password@localhost:6379"
     REDIS_DATABASE = 0
 
+    # Flask-Limiter
+    # A full list with configuration values is available here:
+    # http://flask-limiter.readthedocs.io/en/stable/#configuration
+
+    # You can choose from:
+    #   memory:// (default)
+    #   redis://host:port
+    #   memcached://host:port
+    # Using the redis storage requires the installation of the redis package,
+    # which will be installed if you enable REDIS_ENABLE while memcached
+    # relies on the pymemcache package.
+    #RATELIMIT_STORAGE_URL = "redis://localhost:6379"
+
     # URL Prefixes. Only change it when you know what you are doing.
     FORUM_URL_PREFIX = ""
     USER_URL_PREFIX = "/user"

+ 22 - 2
flaskbb/email.py

@@ -13,11 +13,13 @@ from flask_mail import Message
 from flask_babelplus import lazy_gettext as _
 
 from flaskbb.extensions import mail
+from flaskbb.utils.tokens import make_token
 
 
-def send_reset_token(user, token):
+def send_reset_token(user):
+    token = make_token(user=user, operation="reset_password")
     send_email(
-        subject=_("Password Reset"),
+        subject=_("Password Recovery Confirmation"),
         recipients=[user.email],
         text_body=render_template(
             "email/reset_password.txt",
@@ -32,6 +34,24 @@ def send_reset_token(user, token):
     )
 
 
+def send_activation_token(user):
+    token = make_token(user=user, operation="activate_account")
+    send_email(
+        subject=_("Account Activation"),
+        recipients=[user.email],
+        text_body=render_template(
+            "email/activate_account.txt",
+            user=user,
+            token=token
+        ),
+        html_body=render_template(
+            "email/activate_account.html",
+            user=user,
+            token=token
+        )
+    )
+
+
 def send_email(subject, recipients, text_body, html_body, sender=None):
     msg = Message(subject, recipients=recipients, sender=sender)
     msg.body = text_body

+ 12 - 0
flaskbb/exceptions.py

@@ -17,3 +17,15 @@ class FlaskBBError(HTTPException):
 
 class AuthorizationRequired(FlaskBBError, Forbidden):
     description = "Authorization is required to access this area."
+
+
+class AuthenticationError(FlaskBBError):
+    description = "Invalid username and password combination."
+
+
+class LoginAttemptsExceeded(FlaskBBError):
+    description = "The user has entered the wrong password too many times."
+
+    def __init__(self, user):
+        super(LoginAttemptsExceeded, self).__init__()
+        self.user = user

+ 5 - 0
flaskbb/extensions.py

@@ -20,6 +20,8 @@ from flask_themes2 import Themes
 from flask_plugins import PluginManager
 from flask_babelplus import Babel
 from flask_wtf.csrf import CsrfProtect
+from flask_limiter import Limiter
+from flask_limiter.util import get_remote_address
 
 from flaskbb.exceptions import AuthorizationRequired
 
@@ -59,3 +61,6 @@ babel = Babel()
 
 # CSRF
 csrf = CsrfProtect()
+
+# Rate Limiting
+limiter = Limiter(auto_check=False, key_func=get_remote_address)

+ 64 - 0
flaskbb/fixtures/settings.py

@@ -67,6 +67,70 @@ fixture = (
             }),
         ),
     }),
+    ('auth', {
+        'name': 'Authentication Settings',
+        'description': 'Configurations for the Login and Register process.',
+        'settings': (
+            ('registration_enabled', {
+                'value':        True,
+                'value_type':   "boolean",
+                'name':         "Enable Registration",
+                'description':  "Enable or disable the registration",
+            }),
+            ('activate_account', {
+                'value':        True,
+                'value_type':   "boolean",
+                'name':         "Enable Account Activation",
+                'description':  "Enable to let the user activate their account by sending a email with an activation link."
+            }),
+            ('auth_ratelimit_enabled', {
+                'value':        True,
+                'value_type':   "boolean",
+                'name':         "Enable Auth Rate Limiting",
+                'description':  "Enable rate limiting on 'auth' routes. This will limit the amount of requests per minute to a given amount and time.",
+            }),
+            ('auth_requests', {
+                'value':        20,
+                'value_type':   "integer",
+                'extra':        {'min': 1},
+                'name':         "Auth Requests",
+                'description':  "Number of requests on each 'auth' route before the user has to wait a given timeout until he can access the resource again.",
+            }),
+            ('auth_timeout', {
+                'value':        15,
+                'value_type':   "integer",
+                'extra':        {'min': 0},
+                'name':         "Auth Timeout",
+                'description':  "The timeout for how long the user has to wait until he can access the resource again (in minutes).",
+            }),
+            ('login_recaptcha', {
+                'value':        5,
+                'value_type':   "integer",
+                'extra':        {'min': 0},
+                'name':         "Login reCAPTCHA",
+                'description':  "Use a CAPTCHA after a specified amount of failed login attempts."
+            }),
+            ('recaptcha_enabled', {
+                'value':        False,
+                'value_type':   "boolean",
+                'name':         "Enable reCAPTCHA",
+                'description':  ("Helps to prevent bots from creating accounts. "
+                                 "For more information visit this link: <a href=http://www.google.com/recaptcha>http://www.google.com/recaptcha</a>"),
+            }),
+            ('recaptcha_public_key', {
+                'value':        "",
+                'value_type':   "string",
+                'name':         "reCAPTCHA Site Key",
+                'description':  "Your public recaptcha key ('Site key').",
+            }),
+            ('recaptcha_private_key', {
+                'value':        "",
+                'value_type':   "string",
+                'name':         "reCAPTCHA Secret Key",
+                'description':  "The private key ('Secret key'). Keep this a secret!",
+            }),
+        ),
+    }),
     ('misc', {
         'name': "Misc Settings",
         'description': "Miscellaneous settings.",

+ 3 - 0
flaskbb/management/forms.py

@@ -88,6 +88,9 @@ class UserForm(Form):
     notes = TextAreaField(_("Notes"), validators=[
         Optional(), Length(min=0, max=5000)])
 
+    activated = BooleanField(_("Is active?"), validators=[
+        Optional()])
+
     primary_group = QuerySelectField(
         _("Primary Group"),
         query_factory=select_primary_group,

+ 22 - 0
flaskbb/templates/auth/account_activation.html

@@ -0,0 +1,22 @@
+{% set page_title = _("Account Activation") %}
+{% set active_forum_nav=True %}
+
+{% extends theme("layout.html") %}
+{% block content %}
+{% from theme("macros.html") import horizontal_field %}
+
+<div class="panel page-panel">
+    <div class="panel-heading page-head">
+        {% trans %}Account Activation{% endtrans %}
+    </div>
+
+    <div class="panel-body">
+        <form class="form-horizontal" role="form" method="POST">
+            {{ form.hidden_tag() }}
+            {{ horizontal_field(form.token) }}
+            {{ horizontal_field(form.submit)}}
+        </form>
+    </div>
+</div>
+
+{% endblock %}

+ 8 - 2
flaskbb/templates/auth/forgot_password.html

@@ -1,4 +1,5 @@
-{% set page_title = _("Request Password") %}
+{% set page_title = _("Forgot Password") %}
+{% set active_forum_nav=True %}
 
 {% extends theme("layout.html") %}
 {% block content %}
@@ -6,13 +7,18 @@
 
 <div class="panel page-panel">
     <div class="panel-heading page-head">
-        {% trans %}Reset Password{% endtrans %}
+        {% trans %}Forgot Password{% endtrans %}
     </div>
 
     <div class="panel-body">
         <form class="form-horizontal" role="form" method="POST">
             {{ form.hidden_tag() }}
             {{ horizontal_field(form.email) }}
+
+            {% if flaskbb_config["RECAPTCHA_ENABLED"] %}
+                {{ horizontal_field(form.recaptcha) }}
+            {% endif %}
+
             {{ horizontal_field(form.submit)}}
         </form>
     </div>

+ 4 - 0
flaskbb/templates/auth/login.html

@@ -15,6 +15,10 @@
             {{ horizontal_field(form.login)}}
             {{ horizontal_field(form.password)}}
             {{ horizontal_field(form.remember_me) }}
+            {% if flaskbb_config["RECAPTCHA_ENABLED"] and login_recaptcha %}
+                {{ horizontal_field(form.recaptcha) }}
+            {% endif %}
+
             {{ horizontal_field(form.submit) }}
 
             <div class="form-group">

+ 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 %}
 

+ 23 - 0
flaskbb/templates/auth/request_account_activation.html

@@ -0,0 +1,23 @@
+{% set page_title = _("Request Account Activation") %}
+{% set active_forum_nav=True %}
+
+{% extends theme("layout.html") %}
+{% block content %}
+{% from theme("macros.html") import horizontal_field %}
+
+<div class="panel page-panel">
+    <div class="panel-heading page-head">
+        {% trans %}Request Account Activation{% endtrans %}
+    </div>
+
+    <div class="panel-body">
+        <form class="form-horizontal" role="form" method="POST">
+            {{ form.hidden_tag() }}
+            {{ horizontal_field(form.username) }}
+            {{ horizontal_field(form.email) }}
+            {{ horizontal_field(form.submit)}}
+        </form>
+    </div>
+</div>
+
+{% endblock %}

+ 17 - 10
flaskbb/templates/auth/reset_password.html

@@ -1,18 +1,25 @@
 {% set page_title = _("Reset Password") %}
+{% set active_forum_nav=True %}
 
 {% extends theme("layout.html") %}
 {% block content %}
 {% from theme("macros.html") import horizontal_field %}
 
-<form class="form-horizontal" role="form" method="POST">
-    <h2>{% trans %}Reset Password{% endtrans %}</h2>
-    <hr>
-        {{ form.hidden_tag() }}
-        {{ form.token }}
-        {{ horizontal_field(form.email) }}
-        {{ horizontal_field(form.password) }}
-        {{ horizontal_field(form.confirm_password)}}
-        {{ horizontal_field(form.submit)}}
-</form>
+<div class="panel page-panel">
+    <div class="panel-heading page-head">
+        {% trans %}Reset Password{% endtrans %}
+    </div>
+
+    <div class="panel-body">
+        <form class="form-horizontal" role="form" method="POST">
+            {{ form.hidden_tag() }}
+            {{ form.token }}
+            {{ horizontal_field(form.email) }}
+            {{ horizontal_field(form.password) }}
+            {{ horizontal_field(form.confirm_password)}}
+            {{ horizontal_field(form.submit)}}
+        </form>
+    </div>
+</div>
 
 {% endblock %}

+ 10 - 0
flaskbb/templates/email/activate_account.html

@@ -0,0 +1,10 @@
+{% set link = url_for('auth.activate_account', token=token, _external=True) %}
+
+<p>{% trans user=user.username %}Dear {{ user }},{% endtrans %}</p>
+
+<p>{% trans %}Click the link below to activate your account:{% endtrans %}</p>
+
+<p><a href="{{ link }}">{{ link }}</a></p>
+
+<p>{% trans %}Sincerely,{% endtrans %}</p>
+<p>{% trans %}The Administration{% endtrans %}</p>

+ 11 - 0
flaskbb/templates/email/activate_account.txt

@@ -0,0 +1,11 @@
+{% set link = url_for('auth.activate_account', token=token, _external=True) %}
+
+{% trans user=user.username, link=link %}Dear {{ user }},
+
+Click the link below to activate your account:
+
+{{ link }}
+
+Sincerely,
+The Administration
+{% endtrans %}

+ 6 - 2
flaskbb/templates/email/reset_password.html

@@ -1,5 +1,9 @@
+{% set link = url_for('auth.reset_password', token=token, _external=True) %}
 <p>{% trans user=user.username %}Dear {{ user }},{% endtrans %}</p>
-<p>{% trans %}To reset your password click on the following link:{% endtrans %}</p>
-<p><a href="{{ url_for('auth.reset_password', token=token, _external=True) }}">{{ url_for('auth.reset_password', token=token, _external=True) }}</a></p>
+
+<p>{% trans %}Click the link below to reset your password:{% endtrans %}</p>
+
+<p><a href="{{ link }}">{{ link }}</a></p>
+
 <p>{% trans %}Sincerely,{% endtrans %}</p>
 <p>{% trans %}The Administration{% endtrans %}</p>

+ 1 - 1
flaskbb/templates/email/reset_password.txt

@@ -2,7 +2,7 @@
 
 {% trans user=user.username, link=link %}Dear {{ user }},
 
-To reset your password click on the following link:
+Click the link below to reset your password:
 
 {{ link }}
 

+ 16 - 0
flaskbb/templates/errors/too_many_logins.html

@@ -0,0 +1,16 @@
+{% set page_title = _("Too Many Requests") %}
+
+{% extends theme("layout.html") %}
+{% block content %}
+
+<div class="panel page-panel">
+    <div class="panel-body page-body">
+        <div class="col-md-12 col-sm-12 col-xs-12">
+            <h1>{% trans %}Too Many Requests{% endtrans %}</h1>
+            <p>{% trans %}In order to prevent brute force attacks on accounts we have limited the amount of requests on this route.{% endtrans %}</p>
+            <p>Please try again in <strong>{{ timeout }}</strong>.</p>
+        </div>
+    </div>
+</div>
+
+{% endblock %}

+ 2 - 0
flaskbb/templates/layout.html

@@ -135,7 +135,9 @@
                                     </a>
                                     <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown"><span class="caret"></span></button>
                                     <ul class="dropdown-menu" role="menu">
+                                        {% if flaskbb_config["REGISTRATION_ENABLED"] %}
                                         <li><a href="{{ url_for('auth.register') }}">{% trans %}Register{% endtrans %}</a></li>
+                                        {% endif %}
                                         <li><a href="{{ url_for('auth.forgot_password') }}">{% trans %}Reset Password{% endtrans %}</a></li>
                                     </ul>
                                 </div>

+ 1 - 0
flaskbb/templates/management/user_form.html

@@ -46,6 +46,7 @@
                     {{ horizontal_field(form.avatar) }}
                     {{ horizontal_field(form.primary_group) }}
                     {{ horizontal_field(form.secondary_groups) }}
+                    {{ horizontal_field(form.activated) }}
                     {{ horizontal_field(form.signature, div_class="col-sm-8 editor", rows="5", placeholder="", **{'data-provide': 'markdown', 'class': 'flaskbb-editor'}) }}
                     {{ horizontal_field(form.notes, div_class="col-sm-8 editor", rows="12", placeholder="", **{'data-provide': 'markdown', 'class': 'flaskbb-editor'}) }}
 

+ 45 - 52
flaskbb/user/models.py

@@ -8,16 +8,16 @@
     :copyright: (c) 2014 by the FlaskBB Team.
     :license: BSD, see LICENSE for more details.
 """
-from datetime import datetime
+import os
+from datetime import datetime, timedelta
 
-from itsdangerous import TimedJSONWebSignatureSerializer as Serializer
-from itsdangerous import SignatureExpired
 from werkzeug.security import generate_password_hash, check_password_hash
-from flask import current_app, url_for
+from flask import url_for
 from flask_login import UserMixin, AnonymousUserMixin
 
 from flaskbb._compat import max_integer
 from flaskbb.extensions import db, cache
+from flaskbb.exceptions import AuthenticationError, LoginAttemptsExceeded
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.database import CRUDMixin
 from flaskbb.forum.models import (Post, Topic, topictracker, TopicsRead,
@@ -71,8 +71,7 @@ class Group(db.Model, CRUDMixin):
 
     @classmethod
     def get_guest_group(cls):
-        return cls.query.filter(cls.guest==True).first()
-
+        return cls.query.filter(cls.guest == True).first()
 
 
 class User(db.Model, UserMixin, CRUDMixin):
@@ -93,6 +92,10 @@ class User(db.Model, UserMixin, CRUDMixin):
     avatar = db.Column(db.String(200))
     notes = db.Column(db.Text)
 
+    last_failed_login = db.Column(db.DateTime)
+    login_attempts = db.Column(db.Integer, default=0)
+    activated = db.Column(db.Boolean, default=False)
+
     theme = db.Column(db.String(15))
     language = db.Column(db.String(15), default="en")
 
@@ -123,6 +126,20 @@ class User(db.Model, UserMixin, CRUDMixin):
 
     # Properties
     @property
+    def is_active(self):
+        """Returns the state of the account.
+        If the ``ACTIVATE_ACCOUNT`` option has been disabled, it will always
+        return ``True``. Is the option activated, it will, depending on the
+        state of the account, either return ``True`` or ``False``.
+        """
+        if flaskbb_config["ACTIVATE_ACCOUNT"]:
+            if self.activated:
+                return True
+            return False
+
+        return True
+
+    @property
     def last_post(self):
         """Returns the latest post from the user."""
 
@@ -175,7 +192,9 @@ class User(db.Model, UserMixin, CRUDMixin):
     @property
     def topics_per_day(self):
         """Returns the topics per day count."""
-        return round((float(self.topic_count) / float(self.days_registered)), 1)
+        return round(
+            (float(self.topic_count) / float(self.days_registered)), 1
+        )
 
     # Methods
     def __repr__(self):
@@ -209,59 +228,33 @@ class User(db.Model, UserMixin, CRUDMixin):
     @classmethod
     def authenticate(cls, login, password):
         """A classmethod for authenticating users.
-        It returns true if the user exists and has entered a correct password
+        It returns the user object if the user/password combination is ok.
+        If the user has entered too often a wrong password, he will be locked
+        out of his account for a specified time.
 
         :param login: This can be either a username or a email address.
-
         :param password: The password that is connected to username and email.
         """
-
         user = cls.query.filter(db.or_(User.username == login,
                                        User.email == login)).first()
 
         if user:
-            authenticated = user.check_password(password)
-        else:
-            authenticated = False
-        return user, authenticated
-
-    def _make_token(self, data, timeout):
-        s = Serializer(current_app.config['SECRET_KEY'], timeout)
-        return s.dumps(data)
-
-    def _verify_token(self, token):
-        s = Serializer(current_app.config['SECRET_KEY'])
-        data = None
-        expired, invalid = False, False
-        try:
-            data = s.loads(token)
-        except SignatureExpired:
-            expired = True
-        except Exception:
-            invalid = True
-        return expired, invalid, data
-
-    def make_reset_token(self, expiration=3600):
-        """Creates a reset token. The duration can be configured through the
-        expiration parameter.
-
-        :param expiration: The time in seconds how long the token is valid.
-        """
-        return self._make_token({'id': self.id, 'op': 'reset'}, expiration)
-
-    def verify_reset_token(self, token):
-        """Verifies a reset token. It returns three boolean values based on
-        the state of the token (expired, invalid, data).
-
-        :param token: The reset token that should be checked.
-        """
-
-        expired, invalid, data = self._verify_token(token)
-        if data and data.get('id') == self.id and data.get('op') == 'reset':
-            data = True
-        else:
-            data = False
-        return expired, invalid, data
+            if user.check_password(password):
+                # reset them after a successful login attempt
+                user.login_attempts = 0
+                user.save()
+                return user
+
+            # user exists, wrong password
+            user.login_attempts += 1
+            user.last_failed_login = datetime.utcnow()
+            user.save()
+
+        # protection against account enumeration timing attacks
+        dummy_password = os.urandom(15).encode("base-64")
+        check_password_hash(dummy_password, password)
+
+        raise AuthenticationError
 
     def recalculate(self):
         """Recalculates the post count from the user."""

+ 25 - 8
flaskbb/utils/helpers.py

@@ -18,9 +18,9 @@ from datetime import datetime, timedelta
 
 import requests
 import unidecode
-from flask import session, url_for, flash
+from flask import session, url_for, flash, redirect, request
 from jinja2 import Markup
-from babel.dates import format_timedelta
+from babel.dates import format_timedelta as babel_format_timedelta
 from flask_babelplus import lazy_gettext as _
 from flask_themes2 import render_theme_template
 from flask_login import current_user
@@ -49,6 +49,17 @@ def slugify(text, delim=u'-'):
     return text_type(delim.join(result))
 
 
+def redirect_or_next(endpoint, **kwargs):
+    """Redirects the user back to the page they were viewing or to a specified
+    endpoint. Wraps Flasks :func:`Flask.redirect` function.
+
+    :param endpoint: The fallback endpoint.
+    """
+    return redirect(
+        request.args.get('next') or endpoint, **kwargs
+    )
+
+
 def render_template(template, **context):  # pragma: no cover
     """A helper function that uses the `render_theme_template` function
     without needing to edit all the views
@@ -357,6 +368,17 @@ def format_date(value, format='%Y-%m-%d'):
     return value.strftime(format)
 
 
+def format_timedelta(delta, **kwargs):
+    """Wrapper around babel's format_timedelta to make it user language
+    aware.
+    """
+    locale = flaskbb_config.get("DEFAULT_LANGUAGE", "en")
+    if current_user.is_authenticated and current_user.language is not None:
+        locale = current_user.language
+
+    return babel_format_timedelta(delta, locale=locale, **kwargs)
+
+
 def time_since(time):  # pragma: no cover
     """Returns a string representing time since e.g.
     3 days ago, 5 hours ago.
@@ -364,12 +386,7 @@ def time_since(time):  # pragma: no cover
     :param time: A datetime object
     """
     delta = time - datetime.utcnow()
-
-    locale = "en"
-    if current_user.is_authenticated and current_user.language is not None:
-        locale = current_user.language
-
-    return format_timedelta(delta, add_direction=True, locale=locale)
+    return format_timedelta(delta, add_direction=True)
 
 
 def format_quote(username, content):

+ 3 - 0
flaskbb/utils/populate.py

@@ -8,6 +8,7 @@
     :copyright: (c) 2014 by the FlaskBB Team.
     :license: BSD, see LICENSE for more details.
 """
+from datetime import datetime
 from flaskbb.management.models import Setting, SettingsGroup
 from flaskbb.user.models import User, Group
 from flaskbb.forum.models import Post, Topic, Forum, Category
@@ -175,6 +176,7 @@ def create_admin_user(username, password, email):
     user.password = password
     user.email = email
     user.primary_group_id = admin_group.id
+    user.activated = True
 
     user.save()
     return user
@@ -232,6 +234,7 @@ def create_test_data(users=5, categories=2, forums=2, topics=1, posts=1):
         email = "test%s@example.org" % u
         user = User(username=username, password="test", email=email)
         user.primary_group_id = u
+        user.activated = True
         user.save()
         data_created['users'] += 1
 

+ 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)

+ 71 - 0
flaskbb/utils/tokens.py

@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.utils.tokens
+    ~~~~~~~~~~~~~~~~~~~~
+
+    A module that helps to create and verify various tokens that
+    are used by FlaskBB.
+
+    :copyright: (c) 2014 by the FlaskBB Team.
+    :license: BSD, see LICENSE for more details.
+"""
+from flask import current_app
+from itsdangerous import (TimedJSONWebSignatureSerializer, SignatureExpired,
+                          BadSignature)
+
+from flaskbb.user.models import User
+
+
+def make_token(user, operation, expire=3600):
+    """Generates a JSON Web Signature (JWS).
+    See `RFC 7515 <https://tools.ietf.org/html/rfc7515>` if you want to know
+    more about JWS.
+
+    :param user: The user object for whom the token should be generated.
+    :param operation: The function of the token. For example, you might want
+                      to generate two different tokens. One for a
+                      password reset link, which you hypothetically want
+                      to name 'reset' and the second one, for the generation
+                      of a token for a E-Mail confirmation link, which you
+                      name 'email'.
+    :param expire: The time, in seconds, after which the token should be
+                   invalid. Defaults to 3600.
+    """
+    s = TimedJSONWebSignatureSerializer(
+        current_app.config['SECRET_KEY'], expire
+    )
+    data = {"id": user.id, "op": operation}
+    return s.dumps(data)
+
+
+def get_token_status(token, operation, return_data=False):
+    """Returns the expired status, invalid status, the user and optionally
+    the content of the JSON Web Signature token.
+
+    :param token: A valid JSON Web Signature token.
+    :param operation: The function of the token.
+    :param return_data: If set to ``True``, it will also return the content
+                        of the token.
+    """
+    s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'])
+    user, data = None, None
+    expired, invalid = False, False
+
+    try:
+        data = s.loads(token)
+    except SignatureExpired:
+        expired = True
+    except (BadSignature, TypeError, ValueError):
+        invalid = True
+
+    if data is not None:
+        # check if the operation matches the one from the token
+        if operation == data.get("op", None):
+            user = User.query.filter_by(id=data.get('id')).first()
+        else:
+            invalid = True
+
+    if return_data:
+        return expired, invalid, user, data
+
+    return expired, invalid, user

+ 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))

+ 3 - 1
manage.py

@@ -166,13 +166,15 @@ def insertmassdata():
     insert_mass_data()
 
 
-@manager.option('-s', '--settings', dest="settings")
 @manager.option('-f', '--force', dest="force", default=False)
+@manager.option('-s', '--settings', dest="settings")
 def update(settings=None, force=False):
     """Updates the settings via a fixture. All fixtures have to be placed
     in the `fixture`.
     Usage: python manage.py update -s your_fixture
     """
+    if settings is None:
+        settings = "settings"
 
     try:
         fixture = import_string(

+ 30 - 0
migrations/versions/221d918aa9f0_add_user_authentication_infos.py

@@ -0,0 +1,30 @@
+"""Add user authentication infos
+
+Revision ID: 221d918aa9f0
+Revises: 127be3fb000
+Create Date: 2016-06-06 13:45:52.915050
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '221d918aa9f0'
+down_revision = '127be3fb000'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.add_column('users', sa.Column('activated', sa.Boolean(), nullable=True))
+    op.add_column('users', sa.Column('last_failed_login', sa.DateTime(), nullable=True))
+    op.add_column('users', sa.Column('login_attempts', sa.Integer(), nullable=True))
+    ### end Alembic commands ###
+
+
+def downgrade():
+    ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column('users', 'activated')
+    op.drop_column('users', 'login_attempts')
+    op.drop_column('users', 'last_failed_login')
+    ### end Alembic commands ###

+ 4 - 1
requirements.txt

@@ -8,6 +8,7 @@ flask-allows==0.1.0
 Flask-BabelPlus==1.0.1
 Flask-Cache==0.13.1
 Flask-DebugToolbar==0.10.0
+Flask-Limiter==0.9.3
 Flask-Login==0.3.2
 Flask-Mail==0.9.1
 Flask-Migrate==1.7.0
@@ -19,10 +20,12 @@ Flask-Themes2==0.1.4
 Flask-WTF==0.12
 itsdangerous==0.24
 Jinja2==2.8
+limits==1.1.1
 Mako==1.0.3
 MarkupSafe==0.23
 mistune==0.7.1
 Pygments==2.1
+python-editor==1.0
 pytz==2015.7
 redis==2.10.5
 requests==2.9.1
@@ -31,7 +34,7 @@ six==1.10.0
 speaklater==1.3
 SQLAlchemy==1.0.11
 SQLAlchemy-Utils==0.31.6
-Unidecode==0.04.19
+Unidecode==0.4.19
 Werkzeug==0.11.3
 Whoosh==2.7.0
 WTForms==2.1

+ 3 - 1
setup.py

@@ -62,8 +62,11 @@ setup(
     install_requires=[
         'Babel',
         'Flask',
+        'Flask-Allows',
+        'Flask-BabelPlus',
         'Flask-Cache',
         'Flask-DebugToolbar',
+        'Flask-Limiter',
         'Flask-Login',
         'Flask-Mail',
         'Flask-Migrate',
@@ -106,7 +109,6 @@ setup(
     ],
     dependency_links=[
         'https://github.com/jshipley/Flask-WhooshAlchemy/archive/master.zip#egg=Flask-WhooshAlchemy',
-        'https://github.com/sh4nks/flask-babelex/tarball/master#egg=Flask-BabelEx'
     ],
     classifiers=[
         'Development Status :: 4 - Beta',

+ 11 - 5
tests/fixtures/user.py

@@ -1,3 +1,4 @@
+import datetime
 import pytest
 from flaskbb.user.models import User, Guest
 
@@ -12,7 +13,8 @@ def guest():
 def user(default_groups):
     """Creates a user with normal permissions."""
     user = User(username="test_normal", email="test_normal@example.org",
-                password="test", primary_group=default_groups[3])
+                password="test", primary_group=default_groups[3],
+                activated=True)
     user.save()
     return user
 
@@ -22,7 +24,8 @@ def moderator_user(user, forum, default_groups):
     """Creates a test user with moderator permissions."""
 
     user = User(username="test_mod", email="test_mod@example.org",
-                password="test", primary_group=default_groups[2])
+                password="test", primary_group=default_groups[2],
+                activated=True)
     user.save()
 
     forum.moderators.append(user)
@@ -34,7 +37,8 @@ def moderator_user(user, forum, default_groups):
 def admin_user(default_groups):
     """Creates a admin user."""
     user = User(username="test_admin", email="test_admin@example.org",
-                password="test", primary_group=default_groups[0])
+                password="test", primary_group=default_groups[0],
+                activated=True)
     user.save()
     return user
 
@@ -43,7 +47,8 @@ def admin_user(default_groups):
 def super_moderator_user(default_groups):
     """Creates a super moderator user."""
     user = User(username="test_super_mod", email="test_super@example.org",
-                password="test", primary_group=default_groups[1])
+                password="test", primary_group=default_groups[1],
+                activated=True)
     user.save()
     return user
 
@@ -57,6 +62,7 @@ def Fred(default_groups):
     Our job is stop Fred.
     """
     fred = User(username='Fred', email='fred@fred.fred',
-                password='fred', primary_group=default_groups[3])
+                password='fred', primary_group=default_groups[3],
+                activated=True)
     fred.save()
     return fred

+ 55 - 0
tests/unit/utils/test_tokens.py

@@ -0,0 +1,55 @@
+from flask import current_app
+from itsdangerous import TimedJSONWebSignatureSerializer
+from flaskbb.utils.tokens import make_token, get_token_status
+
+
+def test_make_token(user):
+    token = make_token(user, "test")
+    s = TimedJSONWebSignatureSerializer(current_app.config['SECRET_KEY'])
+    unpacked_token = s.loads(token)
+    assert user.id == unpacked_token["id"]
+    assert "test" == unpacked_token["op"]
+
+
+def test_valid_token_status(user):
+    token = make_token(user, "valid_test")
+    expired, invalid, token_user = get_token_status(token, "valid_test")
+
+    assert not expired
+    assert not invalid
+    assert token_user == user
+
+
+def test_token_status_with_data(user):
+    token = make_token(user, "test_data")
+    expired, invalid, token_user, data = \
+        get_token_status(token, "test_data", return_data=True)
+    assert user.id == data["id"]
+    assert "test_data" == data["op"]
+
+
+def test_token_operation(user):
+    token = make_token(user, "operation_test")
+    expired, invalid, token_user = get_token_status(token, "invalid_op")
+    assert invalid
+    assert not expired
+    assert not token_user
+
+
+def test_invalid_token_status(user):
+    token = "this-is-not-a-token"
+    expired, invalid, token_user, data = \
+        get_token_status(token, "invalid_test", return_data=True)
+
+    assert invalid
+    assert not expired
+    assert not token_user
+    assert data is None
+
+
+def test_expired_token_status(user):
+    token = make_token(user, "expired_test", -1)
+    expired, invalid, token_user = get_token_status(token, "expired_test")
+    assert expired
+    assert not invalid
+    assert not token_user