Browse Source

Merge branch 'authentication-revamp'

sh4nks 9 years ago
parent
commit
c5b0d40545

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

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

+ 37 - 5
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,
@@ -31,6 +32,8 @@ class LoginForm(Form):
     password = PasswordField(_("Password"), validators=[
     password = PasswordField(_("Password"), validators=[
         DataRequired(message=_("A Password is required."))])
         DataRequired(message=_("A Password is required."))])
 
 
+    recaptcha = RecaptchaField(_("Captcha"))
+
     remember_me = BooleanField(_("Remember Me"), default=False)
     remember_me = BooleanField(_("Remember Me"), default=False)
 
 
     submit = SubmitField(_("Login"))
     submit = SubmitField(_("Login"))
@@ -51,6 +54,7 @@ class RegisterForm(Form):
 
 
     confirm_password = PasswordField(_('Confirm Password'))
     confirm_password = PasswordField(_('Confirm Password'))
 
 
+    recaptcha = RecaptchaField(_("Captcha"))
 
 
     language = SelectField(_('Language'))
     language = SelectField(_('Language'))
 
 
@@ -78,10 +82,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."))])
@@ -94,6 +94,8 @@ class ForgotPasswordForm(Form):
         DataRequired(message=_("A E-Mail Address is reguired.")),
         DataRequired(message=_("A E-Mail Address is reguired.")),
         Email()])
         Email()])
 
 
+    recaptcha = RecaptchaField(_("Captcha"))
+
     submit = SubmitField(_("Request Password"))
     submit = SubmitField(_("Request Password"))
 
 
 
 
@@ -116,3 +118,33 @@ class ResetPasswordForm(Form):
         email = User.query.filter_by(email=field.data).first()
         email = User.query.filter_by(email=field.data).first()
         if not email:
         if not email:
             raise ValidationError(_("Wrong E-Mail Address."))
             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.
     :copyright: (c) 2014 by the FlaskBB Team.
     :license: BSD, see LICENSE for more details.
     :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,
 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 _
 
 
-from flaskbb.utils.helpers import render_template
+from flaskbb.extensions import limiter
-from flaskbb.email import send_reset_token
+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,
 from flaskbb.auth.forms import (LoginForm, ReauthForm, ForgotPasswordForm,
-                                ResetPasswordForm)
+                                ResetPasswordForm, RegisterForm,
+                                AccountActivationForm, RequestActivationForm)
 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
+from flaskbb.utils.tokens import get_token_status
 
 
 auth = Blueprint("auth", __name__)
 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"])
 @auth.route("/login", methods=["GET", "POST"])
 def login():
 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:
     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)
     form = LoginForm(request.form)
     if form.validate_on_submit():
     if form.validate_on_submit():
-        user, authenticated = User.authenticate(form.login.data,
+        try:
-                                                form.password.data)
+            user = User.authenticate(form.login.data, form.password.data)
-
-        if user and authenticated:
             login_user(user, remember=form.remember_me.data)
             login_user(user, remember=form.remember_me.data)
-            return redirect(request.args.get("next") or
+            return redirect_or_next(url_for("forum.index"))
-                            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"])
 @auth.route("/reauth", methods=["GET", "POST"])
+@limiter.exempt
 @login_required
 @login_required
 def reauth():
 def reauth():
-    """
+    """Reauthenticates a user."""
-    Reauthenticates a user
-    """
-
     if not login_fresh():
     if not login_fresh():
         form = ReauthForm(request.form)
         form = ReauthForm(request.form)
         if form.validate_on_submit():
         if form.validate_on_submit():
             if current_user.check_password(form.password.data):
             if current_user.check_password(form.password.data):
                 confirm_login()
                 confirm_login()
                 flash(_("Reauthenticated."), "success")
                 flash(_("Reauthenticated."), "success")
-                return redirect(request.args.get("next") or current_user.url)
+                return redirect_or_next(current_user.url)
 
 
             flash(_("Wrong password."), "danger")
             flash(_("Wrong password."), "danger")
         return render_template("auth/reauth.html", form=form)
         return render_template("auth/reauth.html", form=form)
@@ -69,29 +118,26 @@ def reauth():
 
 
 
 
 @auth.route("/logout")
 @auth.route("/logout")
+@limiter.exempt
 @login_required
 @login_required
 def logout():
 def logout():
+    """Logs the user out."""
     logout_user()
     logout_user()
-    flash(("Logged out"), "success")
+    flash(_("Logged out"), "success")
     return redirect(url_for("forum.index"))
     return redirect(url_for("forum.index"))
 
 
 
 
 @auth.route("/register", methods=["GET", "POST"])
 @auth.route("/register", methods=["GET", "POST"])
 def register():
 def register():
-    """
+    """Register a new user."""
-    Register a new user
-    """
-
     if current_user is not None and current_user.is_authenticated:
     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"))
-                                username=current_user.username))
 
 
-    if current_app.config["RECAPTCHA_ENABLED"]:
+    if not flaskbb_config["REGISTRATION_ENABLED"]:
-        from flaskbb.auth.forms import RegisterRecaptchaForm
+        flash(_("The registration has been disabled."), "info")
-        form = RegisterRecaptchaForm(request.form)
+        return redirect_or_next(url_for("forum.index"))
-    else:
+
-        from flaskbb.auth.forms import RegisterForm
+    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']
@@ -99,21 +145,23 @@ def register():
 
 
     if form.validate_on_submit():
     if form.validate_on_submit():
         user = form.save()
         user = form.save()
-        login_user(user)
 
 
-        flash(_("Thanks for registering."), "success")
+        if flaskbb_config["ACTIVATE_ACCOUNT"]:
-        return redirect(url_for("user.profile",
+            send_activation_token(user)
-                                username=current_user.username))
+            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)
     return render_template("auth/register.html", form=form)
 
 
 
 
-@auth.route('/resetpassword', methods=["GET", "POST"])
+@auth.route('/reset-password', methods=["GET", "POST"])
 def forgot_password():
 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:
     if not current_user.is_anonymous:
         return redirect(url_for("forum.index"))
         return redirect(url_for("forum.index"))
 
 
@@ -122,9 +170,7 @@ def forgot_password():
         user = User.query.filter_by(email=form.email.data).first()
         user = User.query.filter_by(email=form.email.data).first()
 
 
         if user:
         if user:
-            token = user.make_reset_token()
+            send_reset_token(user)
-            send_reset_token(user, token=token)
-
             flash(_("E-Mail sent! Please check your inbox."), "info")
             flash(_("E-Mail sent! Please check your inbox."), "info")
             return redirect(url_for("auth.forgot_password"))
             return redirect(url_for("auth.forgot_password"))
         else:
         else:
@@ -133,19 +179,16 @@ def forgot_password():
     return render_template("auth/forgot_password.html", form=form)
     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):
 def reset_password(token):
-    """
+    """Handles the reset password process."""
-    Handles the reset password process.
-    """
-
     if not current_user.is_anonymous:
     if not current_user.is_anonymous:
         return redirect(url_for("forum.index"))
         return redirect(url_for("forum.index"))
 
 
     form = ResetPasswordForm()
     form = ResetPasswordForm()
     if form.validate_on_submit():
     if form.validate_on_submit():
-        user = User.query.filter_by(email=form.email.data).first()
+        expired, invalid, user = get_token_status(form.token.data,
-        expired, invalid, data = user.verify_reset_token(form.token.data)
+                                                  "reset_password")
 
 
         if invalid:
         if invalid:
             flash(_("Your Password Token is invalid."), "danger")
             flash(_("Your Password Token is invalid."), "danger")
@@ -155,7 +198,7 @@ def reset_password(token):
             flash(_("Your Password Token is expired."), "danger")
             flash(_("Your Password Token is expired."), "danger")
             return redirect(url_for("auth.forgot_password"))
             return redirect(url_for("auth.forgot_password"))
 
 
-        if user and data:
+        if user:
             user.password = form.password.data
             user.password = form.password.data
             user.save()
             user.save()
             flash(_("Your Password has been updated."), "success")
             flash(_("Your Password has been updated."), "success")
@@ -163,3 +206,59 @@ def reset_password(token):
 
 
     form.token.data = token
     form.token.data = token
     return render_template("auth/reset_password.html", form=form)
     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 os
 import sys
 import sys
+import datetime
 
 
 _VERSION_STR = '{0.major}{0.minor}'.format(sys.version_info)
 _VERSION_STR = '{0.major}{0.minor}'.format(sys.version_info)
 
 
@@ -64,6 +65,27 @@ class DefaultConfig(object):
     LOGIN_MESSAGE_CATEGORY = "info"
     LOGIN_MESSAGE_CATEGORY = "info"
     REFRESH_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
     # Caching
     CACHE_TYPE = "simple"
     CACHE_TYPE = "simple"
     CACHE_DEFAULT_TIMEOUT = 60
     CACHE_DEFAULT_TIMEOUT = 60

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

@@ -51,23 +51,24 @@ class ProductionConfig(DefaultConfig):
 
 
     ## Mail
     ## Mail
     # Local SMTP Server
     # Local SMTP Server
-    #MAIL_SERVER = "localhost"
+    MAIL_SERVER = "localhost"
-    #MAIL_PORT = 25
+    MAIL_PORT = 25
-    #MAIL_USE_SSL = False
+    MAIL_USE_SSL = False
-    #MAIL_USERNAME = ""
+    MAIL_USERNAME = ""
-    #MAIL_PASSWORD = ""
+    MAIL_PASSWORD = ""
-    #MAIL_DEFAULT_SENDER = "noreply@example.org"
+    MAIL_DEFAULT_SENDER = ("FlaskBB Mailer", "noreply@example.org")
+
 
 
     # Google Mail Example
     # Google Mail Example
-    MAIL_SERVER = "smtp.gmail.com"
+    #MAIL_SERVER = "smtp.gmail.com"
-    MAIL_PORT = 465
+    #MAIL_PORT = 465
-    MAIL_USE_SSL = True
+    #MAIL_USE_SSL = True
-    MAIL_USERNAME = "your_username@gmail.com"
+    #MAIL_USERNAME = "your_username@gmail.com"
-    MAIL_PASSWORD = "your_password"
+    #MAIL_PASSWORD = "your_password"
-    MAIL_DEFAULT_SENDER = ("Your Name", "your_username@gmail.com")
+    #MAIL_DEFAULT_SENDER = ("Your Name", "your_username@gmail.com")
 
 
     # The user who should recieve the error logs
     # The user who should recieve the error logs
-    ADMINS = ["your_admin_user@gmail.com"]
+    #ADMINS = ["your_admin_user@gmail.com"]
 
 
 
 
     ## Error/Info Logging
     ## Error/Info Logging
@@ -85,6 +86,19 @@ class ProductionConfig(DefaultConfig):
     REDIS_URL = "redis://:password@localhost:6379"
     REDIS_URL = "redis://:password@localhost:6379"
     REDIS_DATABASE = 0
     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.
     # URL Prefixes. Only change it when you know what you are doing.
     FORUM_URL_PREFIX = ""
     FORUM_URL_PREFIX = ""
     USER_URL_PREFIX = "/user"
     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 flask_babelplus import lazy_gettext as _
 
 
 from flaskbb.extensions import mail
 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(
     send_email(
-        subject=_("Password Reset"),
+        subject=_("Password Recovery Confirmation"),
         recipients=[user.email],
         recipients=[user.email],
         text_body=render_template(
         text_body=render_template(
             "email/reset_password.txt",
             "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):
 def send_email(subject, recipients, text_body, html_body, sender=None):
     msg = Message(subject, recipients=recipients, sender=sender)
     msg = Message(subject, recipients=recipients, sender=sender)
     msg.body = text_body
     msg.body = text_body

+ 12 - 0
flaskbb/exceptions.py

@@ -17,3 +17,15 @@ class FlaskBBError(HTTPException):
 
 
 class AuthorizationRequired(FlaskBBError, Forbidden):
 class AuthorizationRequired(FlaskBBError, Forbidden):
     description = "Authorization is required to access this area."
     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_plugins import PluginManager
 from flask_babelplus import Babel
 from flask_babelplus import Babel
 from flask_wtf.csrf import CsrfProtect
 from flask_wtf.csrf import CsrfProtect
+from flask_limiter import Limiter
+from flask_limiter.util import get_remote_address
 
 
 from flaskbb.exceptions import AuthorizationRequired
 from flaskbb.exceptions import AuthorizationRequired
 
 
@@ -59,3 +61,6 @@ babel = Babel()
 
 
 # CSRF
 # CSRF
 csrf = CsrfProtect()
 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', {
     ('misc', {
         'name': "Misc Settings",
         'name': "Misc Settings",
         'description': "Miscellaneous settings.",
         'description': "Miscellaneous settings.",

+ 3 - 0
flaskbb/management/forms.py

@@ -88,6 +88,9 @@ class UserForm(Form):
     notes = TextAreaField(_("Notes"), validators=[
     notes = TextAreaField(_("Notes"), validators=[
         Optional(), Length(min=0, max=5000)])
         Optional(), Length(min=0, max=5000)])
 
 
+    activated = BooleanField(_("Is active?"), validators=[
+        Optional()])
+
     primary_group = QuerySelectField(
     primary_group = QuerySelectField(
         _("Primary Group"),
         _("Primary Group"),
         query_factory=select_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") %}
 {% extends theme("layout.html") %}
 {% block content %}
 {% block content %}
@@ -6,13 +7,18 @@
 
 
 <div class="panel page-panel">
 <div class="panel page-panel">
     <div class="panel-heading page-head">
     <div class="panel-heading page-head">
-        {% trans %}Reset Password{% endtrans %}
+        {% trans %}Forgot Password{% endtrans %}
     </div>
     </div>
 
 
     <div class="panel-body">
     <div class="panel-body">
         <form class="form-horizontal" role="form" method="POST">
         <form class="form-horizontal" role="form" method="POST">
             {{ form.hidden_tag() }}
             {{ form.hidden_tag() }}
             {{ horizontal_field(form.email) }}
             {{ horizontal_field(form.email) }}
+
+            {% if flaskbb_config["RECAPTCHA_ENABLED"] %}
+                {{ horizontal_field(form.recaptcha) }}
+            {% endif %}
+
             {{ horizontal_field(form.submit)}}
             {{ horizontal_field(form.submit)}}
         </form>
         </form>
     </div>
     </div>

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

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

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

+ 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 page_title = _("Reset Password") %}
+{% set active_forum_nav=True %}
 
 
 {% extends theme("layout.html") %}
 {% extends theme("layout.html") %}
 {% block content %}
 {% block content %}
 {% from theme("macros.html") import horizontal_field %}
 {% from theme("macros.html") import horizontal_field %}
 
 
-<form class="form-horizontal" role="form" method="POST">
+<div class="panel page-panel">
-    <h2>{% trans %}Reset Password{% endtrans %}</h2>
+    <div class="panel-heading page-head">
-    <hr>
+        {% trans %}Reset Password{% endtrans %}
-        {{ form.hidden_tag() }}
+    </div>
-        {{ form.token }}
+
-        {{ horizontal_field(form.email) }}
+    <div class="panel-body">
-        {{ horizontal_field(form.password) }}
+        <form class="form-horizontal" role="form" method="POST">
-        {{ horizontal_field(form.confirm_password)}}
+            {{ form.hidden_tag() }}
-        {{ horizontal_field(form.submit)}}
+            {{ form.token }}
-</form>
+            {{ horizontal_field(form.email) }}
+            {{ horizontal_field(form.password) }}
+            {{ horizontal_field(form.confirm_password)}}
+            {{ horizontal_field(form.submit)}}
+        </form>
+    </div>
+</div>
 
 
 {% endblock %}
 {% 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 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 %}Sincerely,{% endtrans %}</p>
 <p>{% trans %}The Administration{% 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 }},
 {% 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 }}
 {{ 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>
                                     </a>
                                     <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown"><span class="caret"></span></button>
                                     <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown"><span class="caret"></span></button>
                                     <ul class="dropdown-menu" role="menu">
                                     <ul class="dropdown-menu" role="menu">
+                                        {% if flaskbb_config["REGISTRATION_ENABLED"] %}
                                         <li><a href="{{ url_for('auth.register') }}">{% trans %}Register{% endtrans %}</a></li>
                                         <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>
                                         <li><a href="{{ url_for('auth.forgot_password') }}">{% trans %}Reset Password{% endtrans %}</a></li>
                                     </ul>
                                     </ul>
                                 </div>
                                 </div>

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

@@ -46,6 +46,7 @@
                     {{ horizontal_field(form.avatar) }}
                     {{ horizontal_field(form.avatar) }}
                     {{ horizontal_field(form.primary_group) }}
                     {{ horizontal_field(form.primary_group) }}
                     {{ horizontal_field(form.secondary_groups) }}
                     {{ 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.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'}) }}
                     {{ 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.
     :copyright: (c) 2014 by the FlaskBB Team.
     :license: BSD, see LICENSE for more details.
     :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 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 flask_login import UserMixin, AnonymousUserMixin
 
 
 from flaskbb._compat import max_integer
 from flaskbb._compat import max_integer
 from flaskbb.extensions import db, cache
 from flaskbb.extensions import db, cache
+from flaskbb.exceptions import AuthenticationError, LoginAttemptsExceeded
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.database import CRUDMixin
 from flaskbb.utils.database import CRUDMixin
 from flaskbb.forum.models import (Post, Topic, topictracker, TopicsRead,
 from flaskbb.forum.models import (Post, Topic, topictracker, TopicsRead,
@@ -71,8 +71,7 @@ class Group(db.Model, CRUDMixin):
 
 
     @classmethod
     @classmethod
     def get_guest_group(cls):
     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):
 class User(db.Model, UserMixin, CRUDMixin):
@@ -93,6 +92,10 @@ class User(db.Model, UserMixin, CRUDMixin):
     avatar = db.Column(db.String(200))
     avatar = db.Column(db.String(200))
     notes = db.Column(db.Text)
     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))
     theme = db.Column(db.String(15))
     language = db.Column(db.String(15), default="en")
     language = db.Column(db.String(15), default="en")
 
 
@@ -123,6 +126,20 @@ class User(db.Model, UserMixin, CRUDMixin):
 
 
     # Properties
     # Properties
     @property
     @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):
     def last_post(self):
         """Returns the latest post from the user."""
         """Returns the latest post from the user."""
 
 
@@ -175,7 +192,9 @@ class User(db.Model, UserMixin, CRUDMixin):
     @property
     @property
     def topics_per_day(self):
     def topics_per_day(self):
         """Returns the topics per day count."""
         """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
     # Methods
     def __repr__(self):
     def __repr__(self):
@@ -209,59 +228,33 @@ class User(db.Model, UserMixin, CRUDMixin):
     @classmethod
     @classmethod
     def authenticate(cls, login, password):
     def authenticate(cls, login, password):
         """A classmethod for authenticating users.
         """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 login: This can be either a username or a email address.
-
         :param password: The password that is connected to username and email.
         :param password: The password that is connected to username and email.
         """
         """
-
         user = cls.query.filter(db.or_(User.username == login,
         user = cls.query.filter(db.or_(User.username == login,
                                        User.email == login)).first()
                                        User.email == login)).first()
 
 
         if user:
         if user:
-            authenticated = user.check_password(password)
+            if user.check_password(password):
-        else:
+                # reset them after a successful login attempt
-            authenticated = False
+                user.login_attempts = 0
-        return user, authenticated
+                user.save()
-
+                return user
-    def _make_token(self, data, timeout):
+
-        s = Serializer(current_app.config['SECRET_KEY'], timeout)
+            # user exists, wrong password
-        return s.dumps(data)
+            user.login_attempts += 1
-
+            user.last_failed_login = datetime.utcnow()
-    def _verify_token(self, token):
+            user.save()
-        s = Serializer(current_app.config['SECRET_KEY'])
+
-        data = None
+        # protection against account enumeration timing attacks
-        expired, invalid = False, False
+        dummy_password = os.urandom(15).encode("base-64")
-        try:
+        check_password_hash(dummy_password, password)
-            data = s.loads(token)
+
-        except SignatureExpired:
+        raise AuthenticationError
-            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
 
 
     def recalculate(self):
     def recalculate(self):
         """Recalculates the post count from the user."""
         """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 requests
 import unidecode
 import unidecode
-from flask import session, url_for, flash
+from flask import session, url_for, flash, redirect, request
 from jinja2 import Markup
 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_babelplus import lazy_gettext as _
 from flask_themes2 import render_theme_template
 from flask_themes2 import render_theme_template
 from flask_login import current_user
 from flask_login import current_user
@@ -49,6 +49,17 @@ def slugify(text, delim=u'-'):
     return text_type(delim.join(result))
     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
 def render_template(template, **context):  # pragma: no cover
     """A helper function that uses the `render_theme_template` function
     """A helper function that uses the `render_theme_template` function
     without needing to edit all the views
     without needing to edit all the views
@@ -357,6 +368,17 @@ def format_date(value, format='%Y-%m-%d'):
     return value.strftime(format)
     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
 def time_since(time):  # pragma: no cover
     """Returns a string representing time since e.g.
     """Returns a string representing time since e.g.
     3 days ago, 5 hours ago.
     3 days ago, 5 hours ago.
@@ -364,12 +386,7 @@ def time_since(time):  # pragma: no cover
     :param time: A datetime object
     :param time: A datetime object
     """
     """
     delta = time - datetime.utcnow()
     delta = time - datetime.utcnow()
-
+    return format_timedelta(delta, add_direction=True)
-    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)
 
 
 
 
 def format_quote(username, content):
 def format_quote(username, content):

+ 3 - 0
flaskbb/utils/populate.py

@@ -8,6 +8,7 @@
     :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.
 """
 """
+from datetime import datetime
 from flaskbb.management.models import Setting, SettingsGroup
 from flaskbb.management.models import Setting, SettingsGroup
 from flaskbb.user.models import User, Group
 from flaskbb.user.models import User, Group
 from flaskbb.forum.models import Post, Topic, Forum, Category
 from flaskbb.forum.models import Post, Topic, Forum, Category
@@ -175,6 +176,7 @@ def create_admin_user(username, password, email):
     user.password = password
     user.password = password
     user.email = email
     user.email = email
     user.primary_group_id = admin_group.id
     user.primary_group_id = admin_group.id
+    user.activated = True
 
 
     user.save()
     user.save()
     return user
     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
         email = "test%s@example.org" % u
         user = User(username=username, password="test", email=email)
         user = User(username=username, password="test", email=email)
         user.primary_group_id = u
         user.primary_group_id = u
+        user.activated = True
         user.save()
         user.save()
         data_created['users'] += 1
         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.
     :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))

+ 3 - 1
manage.py

@@ -166,13 +166,15 @@ def insertmassdata():
     insert_mass_data()
     insert_mass_data()
 
 
 
 
-@manager.option('-s', '--settings', dest="settings")
 @manager.option('-f', '--force', dest="force", default=False)
 @manager.option('-f', '--force', dest="force", default=False)
+@manager.option('-s', '--settings', dest="settings")
 def update(settings=None, force=False):
 def update(settings=None, force=False):
     """Updates the settings via a fixture. All fixtures have to be placed
     """Updates the settings via a fixture. All fixtures have to be placed
     in the `fixture`.
     in the `fixture`.
     Usage: python manage.py update -s your_fixture
     Usage: python manage.py update -s your_fixture
     """
     """
+    if settings is None:
+        settings = "settings"
 
 
     try:
     try:
         fixture = import_string(
         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-BabelPlus==1.0.1
 Flask-Cache==0.13.1
 Flask-Cache==0.13.1
 Flask-DebugToolbar==0.10.0
 Flask-DebugToolbar==0.10.0
+Flask-Limiter==0.9.3
 Flask-Login==0.3.2
 Flask-Login==0.3.2
 Flask-Mail==0.9.1
 Flask-Mail==0.9.1
 Flask-Migrate==1.7.0
 Flask-Migrate==1.7.0
@@ -19,10 +20,12 @@ Flask-Themes2==0.1.4
 Flask-WTF==0.12
 Flask-WTF==0.12
 itsdangerous==0.24
 itsdangerous==0.24
 Jinja2==2.8
 Jinja2==2.8
+limits==1.1.1
 Mako==1.0.3
 Mako==1.0.3
 MarkupSafe==0.23
 MarkupSafe==0.23
 mistune==0.7.1
 mistune==0.7.1
 Pygments==2.1
 Pygments==2.1
+python-editor==1.0
 pytz==2015.7
 pytz==2015.7
 redis==2.10.5
 redis==2.10.5
 requests==2.9.1
 requests==2.9.1
@@ -31,7 +34,7 @@ six==1.10.0
 speaklater==1.3
 speaklater==1.3
 SQLAlchemy==1.0.11
 SQLAlchemy==1.0.11
 SQLAlchemy-Utils==0.31.6
 SQLAlchemy-Utils==0.31.6
-Unidecode==0.04.19
+Unidecode==0.4.19
 Werkzeug==0.11.3
 Werkzeug==0.11.3
 Whoosh==2.7.0
 Whoosh==2.7.0
 WTForms==2.1
 WTForms==2.1

+ 3 - 1
setup.py

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

+ 11 - 5
tests/fixtures/user.py

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