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

Merge branch 'master' into hooks

* master:
  Rename requirements-travis.yml to use ending .txt
  Update views.py
  Fold Token and Registration validation errors into one error
  Only show activated plugins with settings
  Clean up
  Concede in the view layer rather than the service layer
  Use tox instead of py.test directly for make test
  Make tox + travis + coveralls work together
  Extract some logic into stand alone services
  Ensure email entered belongs to token holder
  Prefer internal blueprints over ones loaded by external plugins
  Fix stupid yapf formatting
  Load user routes from plugin hook
  Load management routes via plugin hook
  Load forum views via plugin hook
  Register auth views via blueprint hook
Peter Justin 7 лет назад
Родитель
Сommit
5e2d42276c
47 измененных файлов с 1911 добавлено и 654 удалено
  1. 17 0
      .coveragerc
  2. 1 0
      .gitignore
  3. 3 5
      .travis.yml
  4. 1 1
      Makefile
  5. 6 3
      flaskbb/_compat.py
  6. 18 21
      flaskbb/app.py
  7. 4 0
      flaskbb/auth/__init__.py
  8. 14 41
      flaskbb/auth/forms.py
  9. 28 0
      flaskbb/auth/plugins.py
  10. 0 0
      flaskbb/auth/services/__init__.py
  11. 80 0
      flaskbb/auth/services/registration.py
  12. 209 128
      flaskbb/auth/views.py
  13. 0 0
      flaskbb/core/__init__.py
  14. 7 0
      flaskbb/core/auth/__init__.py
  15. 41 0
      flaskbb/core/auth/password.py
  16. 61 0
      flaskbb/core/auth/registration.py
  17. 45 0
      flaskbb/core/exceptions.py
  18. 91 0
      flaskbb/core/tokens.py
  19. 0 0
      flaskbb/core/user/__init__.py
  20. 31 0
      flaskbb/core/user/repo.py
  21. 6 2
      flaskbb/exceptions.py
  22. 313 154
      flaskbb/forum/views.py
  23. 351 239
      flaskbb/management/views.py
  24. 5 0
      flaskbb/plugins/spec.py
  25. 10 0
      flaskbb/tokens/__init__.py
  26. 81 0
      flaskbb/tokens/serializer.py
  27. 28 0
      flaskbb/tokens/verifiers.py
  28. 32 0
      flaskbb/user/repo.py
  29. 49 20
      flaskbb/user/views.py
  30. 10 1
      flaskbb/utils/forms.py
  31. 1 0
      requirements-cov.txt
  32. 2 1
      requirements-dev.txt
  33. 7 0
      requirements-test.txt
  34. 4 0
      requirements-travis.txt
  35. 1 0
      requirements.txt
  36. 0 27
      setup.cfg
  37. 66 0
      tests/core/auth/test_password.py
  38. 69 0
      tests/core/auth/test_registration.py
  39. 88 0
      tests/unit/auth/test_validators.py
  40. 10 2
      tests/unit/test_forum_models.py
  41. 7 3
      tests/unit/test_hideable_query.py
  42. 0 1
      tests/unit/test_posts.py
  43. 8 4
      tests/unit/test_requirements.py
  44. 42 0
      tests/unit/tokens/test_serializer.py
  45. 23 0
      tests/unit/tokens/test_verifiers.py
  46. 0 1
      tests/unit/utils/test_translations.py
  47. 41 0
      tox.ini

+ 17 - 0
.coveragerc

@@ -1,8 +1,17 @@
 # .coveragerc to control coverage.py
 [run]
+branch = true
+source = flaskbb
 omit =
     flaskbb/_compat.py
     flaskbb/configs/*
+parallel = true
+
+[paths]
+source =
+    flaskbb/
+    .tox/*/lib/*/site-packages/flaskbb/
+
 
 [report]
 # Regexes for lines to exclude from consideration
@@ -22,4 +31,12 @@ exclude_lines =
     if 0:
     if __name__ == .__main__.:
 
+    @abstractmethod
+
 ignore_errors = True
+precision = 2
+show_missing = true
+skip_covered = true
+
+[html]
+directory = tests/htmlcov

+ 1 - 0
.gitignore

@@ -140,3 +140,4 @@ whoosh_index
 bower_components
 node_modules
 .DS_Store
+.pytest_cache

+ 3 - 5
.travis.yml

@@ -7,12 +7,10 @@ python:
   - "3.6"
 # command to install dependencies
 install:
-  - "pip install -r requirements-dev.txt"
-  - "pip install coveralls"
-  - "pip install -e ."
-# command to run tests
+  - "pip install -r requirements-travis.txt"
 script:
   - flaskbb translations compile
-  - py.test --cov=flaskbb --cov-report=term-missing tests
+  - tox
 after_success:
+  - coverage combine tests
   - coveralls

+ 1 - 1
Makefile

@@ -23,7 +23,7 @@ clean:
 	find . -name '__pycache__' -exec rm -rf {} +
 
 test:
-	py.test
+	tox
 
 run:
 	flaskbb run --debugger --reload

+ 6 - 3
flaskbb/_compat.py

@@ -7,16 +7,19 @@ import sys
 
 PY2 = sys.version_info[0] == 2
 
-if not PY2:     # pragma: no cover
+if not PY2:  # pragma: no cover
+    from abc import ABC
     text_type = str
-    string_types = (str,)
+    string_types = (str, )
     integer_types = (int, )
     intern_method = sys.intern
     range_method = range
     iterkeys = lambda d: iter(d.keys())
     itervalues = lambda d: iter(d.values())
     iteritems = lambda d: iter(d.items())
-else:           # pragma: no cover
+else:  # pragma: no cover
+    from abc import ABCMeta
+    ABC = ABCMeta('ABC', (object, ), {})
     text_type = unicode
     string_types = (str, unicode)
     integer_types = (int, long)

+ 18 - 21
flaskbb/app.py

@@ -23,21 +23,16 @@ from sqlalchemy.engine import Engine
 from sqlalchemy.exc import OperationalError, ProgrammingError
 
 from flaskbb._compat import iteritems, string_types
-from flaskbb.auth.views import auth
 # extensions
 from flaskbb.extensions import (alembic, allows, babel, cache, celery, csrf,
                                 db, debugtoolbar, limiter, login_manager, mail,
                                 redis_store, themes, whooshee)
-from flaskbb.forum.views import forum
-from flaskbb.management.views import management
 from flaskbb.plugins import spec
 from flaskbb.plugins.manager import FlaskBBPluginManager
 from flaskbb.plugins.models import PluginRegistry
 from flaskbb.plugins.utils import remove_zombie_plugins_from_db, template_hook
 # models
 from flaskbb.user.models import Guest, User
-# views
-from flaskbb.user.views import user
 # various helpers
 from flaskbb.utils.helpers import (app_config_from_env, crop_title,
                                    format_date, forum_is_unread,
@@ -58,6 +53,10 @@ from flaskbb.utils.search import (ForumWhoosheer, PostWhoosheer,
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.translations import FlaskBBDomain
 
+from .auth import views as auth_views
+from .forum import views as forum_views
+from .management import views as management_views
+from .user import views as user_views
 
 logger = logging.getLogger(__name__)
 
@@ -78,9 +77,7 @@ def create_app(config=None, instance_path=None):
     """
 
     app = Flask(
-        "flaskbb",
-        instance_path=instance_path,
-        instance_relative_config=True
+        "flaskbb", instance_path=instance_path, instance_relative_config=True
     )
 
     # instance folders are not automatically created by flask
@@ -149,20 +146,15 @@ def configure_celery_app(app, celery):
     TaskBase = celery.Task
 
     class ContextTask(TaskBase):
+
         def __call__(self, *args, **kwargs):
             with app.app_context():
                 return TaskBase.__call__(self, *args, **kwargs)
+
     celery.Task = ContextTask
 
 
 def configure_blueprints(app):
-    app.register_blueprint(forum, url_prefix=app.config["FORUM_URL_PREFIX"])
-    app.register_blueprint(user, url_prefix=app.config["USER_URL_PREFIX"])
-    app.register_blueprint(auth, url_prefix=app.config["AUTH_URL_PREFIX"])
-    app.register_blueprint(
-        management, url_prefix=app.config["ADMIN_URL_PREFIX"]
-    )
-
     app.pluggy.hook.flaskbb_load_blueprints(app=app)
 
 
@@ -249,9 +241,9 @@ def configure_template_filters(app):
         ('can_ban_user', CanBanUser),
     ]
 
-    filters.update([
-        (name, partial(perm, request=request)) for name, perm in permissions
-    ])
+    filters.update(
+        [(name, partial(perm, request=request)) for name, perm in permissions]
+    )
 
     # these create closures
     filters['can_moderate'] = TplCanModerate(request)
@@ -297,6 +289,7 @@ def configure_before_handlers(app):
             db.session.commit()
 
     if app.config["REDIS_ENABLED"]:
+
         @app.before_request
         def mark_current_user_online():
             if current_user.is_authenticated:
@@ -423,8 +416,11 @@ def load_plugins(app):
             plugins = PluginRegistry.query.all()
 
     except (OperationalError, ProgrammingError) as exc:
-        logger.debug("Database is not setup correctly or has not been "
-                     "setup yet.", exc_info=exc)
+        logger.debug(
+            "Database is not setup correctly or has not been "
+            "setup yet.",
+            exc_info=exc
+        )
         # load plugins even though the database isn't setup correctly
         # i.e. when creating the initial database and wanting to install
         # the plugins migration as well
@@ -441,7 +437,8 @@ def load_plugins(app):
     loaded_names = set([p[0] for p in app.pluggy.list_name_plugin()])
     registered_names = set([p.name for p in plugins])
     unregistered = [
-        PluginRegistry(name=name) for name in loaded_names - registered_names
+        PluginRegistry(name=name)
+        for name in loaded_names - registered_names
         # ignore internal FlaskBB modules
         if not name.startswith('flaskbb.') and name != 'flaskbb'
     ]

+ 4 - 0
flaskbb/auth/__init__.py

@@ -1,3 +1,7 @@
 import logging
 
+from pluggy import HookimplMarker
+
+impl = HookimplMarker('flaskbb')
+
 logger = logging.getLogger(__name__)

+ 14 - 41
flaskbb/auth/forms.py

@@ -9,18 +9,17 @@
     :license: BSD, see LICENSE for more details.
 """
 import logging
-from flask_wtf import FlaskForm
-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 wtforms import (BooleanField, HiddenField, PasswordField, SelectField,
+                     StringField, SubmitField)
+from wtforms.validators import (DataRequired, Email, EqualTo, InputRequired,
+                                ValidationError, regexp)
 
 from flaskbb.user.models import User
-from flaskbb.utils.settings import flaskbb_config
-from flaskbb.utils.helpers import time_utcnow
 from flaskbb.utils.fields import RecaptchaField
-
+from flaskbb.utils.forms import FlaskBBForm
+from flaskbb.utils.helpers import time_utcnow
 
 logger = logging.getLogger(__name__)
 
@@ -31,7 +30,7 @@ is_valid_username = regexp(
 )
 
 
-class LoginForm(FlaskForm):
+class LoginForm(FlaskBBForm):
     login = StringField(_("Username or Email address"), validators=[
         DataRequired(message=_("Please enter your username or email address."))
     ])
@@ -49,7 +48,7 @@ class LoginRecaptchaForm(LoginForm):
     recaptcha = RecaptchaField(_("Captcha"))
 
 
-class RegisterForm(FlaskForm):
+class RegisterForm(FlaskBBForm):
     username = StringField(_("Username"), validators=[
         DataRequired(message=_("A valid username is required")),
         is_valid_username])
@@ -73,32 +72,6 @@ class RegisterForm(FlaskForm):
 
     submit = SubmitField(_("Register"))
 
-    def validate_username(self, field):
-        # would through an out of context error if used with validators.Length
-        min_length = flaskbb_config["AUTH_USERNAME_MIN_LENGTH"]
-        max_length = flaskbb_config["AUTH_USERNAME_MAX_LENGTH"]
-        blacklist = [w.strip() for w in
-                     flaskbb_config["AUTH_USERNAME_BLACKLIST"].split(",")]
-
-        if len(field.data) < min_length or len(field.data) > max_length:
-            raise ValidationError(_(
-                "Username must be between %(min)s and %(max)s "
-                "characters long.",
-                min=min_length, max=max_length)
-            )
-        if field.data.lower() in blacklist:
-            raise ValidationError(_(
-                "This is a system reserved name. Choose a different one.")
-            )
-        user = User.query.filter_by(username=field.data.lower()).first()
-        if user:
-            raise ValidationError(_("This username is already taken."))
-
-    def validate_email(self, field):
-        email = User.query.filter_by(email=field.data.lower()).first()
-        if email:
-            raise ValidationError(_("This email address is already taken."))
-
     def save(self):
         user = User(username=self.username.data,
                     email=self.email.data,
@@ -109,14 +82,14 @@ class RegisterForm(FlaskForm):
         return user.save()
 
 
-class ReauthForm(FlaskForm):
+class ReauthForm(FlaskBBForm):
     password = PasswordField(_('Password'), validators=[
         DataRequired(message=_("Please enter your password."))])
 
     submit = SubmitField(_("Refresh Login"))
 
 
-class ForgotPasswordForm(FlaskForm):
+class ForgotPasswordForm(FlaskBBForm):
     email = StringField(_('Email address'), validators=[
         DataRequired(message=_("A valid email address is required.")),
         Email()])
@@ -126,7 +99,7 @@ class ForgotPasswordForm(FlaskForm):
     submit = SubmitField(_("Request Password"))
 
 
-class ResetPasswordForm(FlaskForm):
+class ResetPasswordForm(FlaskBBForm):
     token = HiddenField('Token')
 
     email = StringField(_('Email address'), validators=[
@@ -147,7 +120,7 @@ class ResetPasswordForm(FlaskForm):
             raise ValidationError(_("Wrong email address."))
 
 
-class RequestActivationForm(FlaskForm):
+class RequestActivationForm(FlaskBBForm):
     username = StringField(_("Username"), validators=[
         DataRequired(message=_("A valid username is required.")),
         is_valid_username])
@@ -168,7 +141,7 @@ class RequestActivationForm(FlaskForm):
             raise ValidationError(_("User is already active."))
 
 
-class AccountActivationForm(FlaskForm):
+class AccountActivationForm(FlaskBBForm):
     token = StringField(_("Email confirmation token"), validators=[
         DataRequired(message=_("Please enter the token that we have sent to "
                                "you."))

+ 28 - 0
flaskbb/auth/plugins.py

@@ -0,0 +1,28 @@
+from flask import flash
+from flask_babelplus import gettext as _
+from flask_login import login_user
+
+from . import impl
+from ..email import send_activation_token
+from ..user.models import User
+from ..utils.settings import flaskbb_config
+
+
+@impl
+def flaskbb_user_registered(username):
+    user = User.query.filter_by(username=username).first()
+
+    if flaskbb_config["ACTIVATE_ACCOUNT"]:
+        send_activation_token.delay(
+            user_id=user.id, username=user.username, email=user.email
+        )
+        flash(
+            _(
+                "An account activation email has been sent to "
+                "%(email)s",
+                email=user.email
+            ), "success"
+        )
+    else:
+        login_user(user)
+        flash(_("Thanks for registering."), "success")

+ 0 - 0
flaskbb/auth/services/__init__.py


+ 80 - 0
flaskbb/auth/services/registration.py

@@ -0,0 +1,80 @@
+#  -*- coding: utf-8 -*-
+"""
+    flaskbb.auth.services
+    ~~~~~~~~~~~~~~~~~~~~~
+
+    Implementation of services found in flaskbb.core.auth.services
+
+    :copyright: (c) 2014-2018 the FlaskBB Team.
+    :license: BSD, see LICENSE for more details
+"""
+
+from collections import namedtuple
+
+from sqlalchemy import func
+
+from ...core.auth.registration import UserValidator
+from ...core.exceptions import ValidationError
+
+__all__ = (
+    "UsernameRequirements", "UsernameValidator", "EmailUniquenessValidator",
+    "UsernameUniquenessValidator"
+)
+
+UsernameRequirements = namedtuple(
+    'UsernameRequirements', ['min', 'max', 'blacklist']
+)
+
+
+class UsernameValidator(UserValidator):
+
+    def __init__(self, requirements):
+        self._requirements = requirements
+
+    def validate(self, user_info):
+        if not (self._requirements.min <= len(user_info.username) <=
+                self._requirements.max):
+            raise ValidationError(
+                'username',
+                'Username must be between {} and {} characters long'.format(
+                    self._requirements.min, self._requirements.max
+                )
+            )
+
+        is_blacklisted = user_info.username in self._requirements.blacklist
+        if is_blacklisted:  # pragma: no branch
+            raise ValidationError(
+                'username',
+                '{} is a forbidden username'.format(user_info.username)
+            )
+
+
+class UsernameUniquenessValidator(UserValidator):
+
+    def __init__(self, users):
+        self.users = users
+
+    def validate(self, user_info):
+        count = self.users.query.filter(
+            func.lower(self.users.username) == user_info.username
+        ).count()
+        if count != 0:  # pragma: no branch
+            raise ValidationError(
+                'username',
+                '{} is already registered'.format(user_info.username)
+            )
+
+
+class EmailUniquenessValidator(UserValidator):
+
+    def __init__(self, users):
+        self.users = users
+
+    def validate(self, user_info):
+        count = self.users.query.filter(
+            func.lower(self.users.email) == user_info.email
+        ).count()
+        if count != 0:  # pragma: no branch
+            raise ValidationError(
+                'email', '{} is already registered'.format(user_info.email)
+            )

+ 209 - 128
flaskbb/auth/views.py

@@ -9,10 +9,10 @@
     :copyright: (c) 2014 by the FlaskBB Team.
     :license: BSD, see LICENSE for more details.
 """
-from datetime import datetime
 import logging
+from datetime import datetime, timedelta
 
-from flask import Blueprint, flash, g, redirect, request, url_for
+from flask import Blueprint, current_app, flash, g, redirect, request, url_for
 from flask.views import MethodView
 from flask_babelplus import gettext as _
 from flask_login import (confirm_login, current_user, login_fresh,
@@ -24,7 +24,7 @@ from flaskbb.auth.forms import (AccountActivationForm, ForgotPasswordForm,
                                 ResetPasswordForm)
 from flaskbb.email import send_activation_token, send_reset_token
 from flaskbb.exceptions import AuthenticationError
-from flaskbb.extensions import limiter
+from flaskbb.extensions import db, limiter
 from flaskbb.user.models import User
 from flaskbb.utils.helpers import (anonymous_required, enforce_recaptcha,
                                    format_timedelta, get_available_languages,
@@ -34,53 +34,19 @@ from flaskbb.utils.helpers import (anonymous_required, enforce_recaptcha,
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.tokens import get_token_status
 
+from .services import registration
+from ..core.auth.password import ResetPasswordService
+from ..core.auth.registration import (RegistrationService, UserRegistrationInfo)
+from ..core.exceptions import ValidationError, StopValidation
+from ..core.tokens import TokenError
+from ..tokens import FlaskBBTokenSerializer
+from ..tokens.verifiers import EmailMatchesUserToken
+from ..user.repo import UserRepository
+from .plugins import impl
 
 logger = logging.getLogger(__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)
-
-
 class Logout(MethodView):
     decorators = [limiter.exempt, login_required]
 
@@ -107,9 +73,13 @@ class Login(MethodView):
             try:
                 user = User.authenticate(form.login.data, form.password.data)
                 if not login_user(user, remember=form.remember_me.data):
-                    flash(_("In order to use your account you have to "
+                    flash(
+                        _(
+                            "In order to use your account you have to "
                             "activate it through the link we have sent to "
-                            "your email address."), "danger")
+                            "your email address."
+                        ), "danger"
+                    )
                 return redirect_or_next(url_for("forum.index"))
             except AuthenticationError:
                 flash(_("Wrong username or password."), "danger")
@@ -141,6 +111,9 @@ class Reauth(MethodView):
 class Register(MethodView):
     decorators = [anonymous_required, registration_enabled]
 
+    def __init__(self, registration_service_factory):
+        self.registration_service_factory = registration_service_factory
+
     def form(self):
         form = RegisterForm()
 
@@ -155,25 +128,37 @@ class Register(MethodView):
     def post(self):
         form = self.form()
         if form.validate_on_submit():
-            user = form.save()
-
-            if flaskbb_config["ACTIVATE_ACCOUNT"]:
-                # Any call to an expired model requires a database hit, so
-                # accessing user.id would cause an DetachedInstanceError.
-                # This happens because the `user`'s session does no longer
-                # exist. So we just fire up another query to make sure that
-                # the session for the newly created user is fresh.
-                # PS: `db.session.merge(user)` did not work for me.
-                user = User.query.filter_by(email=user.email).first()
-                send_activation_token.delay(
-                    user_id=user.id, username=user.username, email=user.email
-                )
-                flash(_("An account activation email has been sent to "
-                        "%(email)s", email=user.email), "success")
-            else:
-                login_user(user)
-                flash(_("Thanks for registering."), "success")
+            registration_info = UserRegistrationInfo(
+                username=form.username.data,
+                password=form.password.data,
+                group=4,
+                email=form.email.data,
+                language=form.language.data
+            )
+
+            service = self.registration_service_factory()
+            try:
+                service.register(registration_info)
+            except StopValidation as e:
+                form.populate_errors(e.reasons)
+                return render_template("auth/register.html", form=form)
 
+            else:
+                try:
+                    db.session.commit()
+                except Exception:  # noqa
+                    logger.exception("Uh that looks bad...")
+                    flash(
+                        _(
+                            "Could not process registration due to an unrecoverable error"
+                        ), "danger"
+                    )
+
+                    return render_template("auth/register.html", form=form)
+
+            current_app.pluggy.hook.flaskbb_user_registered(
+                username=registration_info.username
+            )
             return redirect_or_next(url_for('forum.index'))
 
         return render_template("auth/register.html", form=form)
@@ -198,8 +183,12 @@ class ForgotPassword(MethodView):
                 flash(_("Email sent! Please check your inbox."), "info")
                 return redirect(url_for("auth.forgot_password"))
             else:
-                flash(_("You have entered an username or email address that "
-                        "is not linked with your account."), "danger")
+                flash(
+                    _(
+                        "You have entered an username or email address that "
+                        "is not linked with your account."
+                    ), "danger"
+                )
         return render_template("auth/forgot_password.html", form=form)
 
 
@@ -207,6 +196,9 @@ class ResetPassword(MethodView):
     decorators = [anonymous_required]
     form = ResetPasswordForm
 
+    def __init__(self, password_reset_service_factory):
+        self.password_reset_service_factory = password_reset_service_factory
+
     def get(self, token):
         form = self.form()
         form.token.data = token
@@ -215,23 +207,28 @@ class ResetPassword(MethodView):
     def post(self, token):
         form = self.form()
         if form.validate_on_submit():
-            expired, invalid, user = get_token_status(
-                form.token.data, "reset_password"
-            )
-
-            if invalid:
-                flash(_("Your password token is invalid."), "danger")
-                return redirect(url_for("auth.forgot_password"))
-
-            if expired:
-                flash(_("Your password token is expired."), "danger")
-                return redirect(url_for("auth.forgot_password"))
-
-            if user:
-                user.password = form.password.data
-                user.save()
-                flash(_("Your password has been updated."), "success")
-                return redirect(url_for("auth.login"))
+            try:
+                service = self.password_reset_service_factory()
+                service.reset_password(
+                    token,
+                    form.email.data,
+                    form.password.data
+                )
+                db.session.commit()
+            except TokenError as e:
+                flash(_(e.reason), 'danger')
+                return redirect(url_for('auth.forgot_password'))
+            except StopValidation as e:
+                form.populate_errors(e.reasons)
+                form.token.data = token
+                return render_template("auth/reset_password.html", form=form)
+            except Exception:
+                logger.exception("Error when resetting password")
+                flash(_('Error when resetting password'))
+                return redirect(url_for('auth.forgot_password'))
+
+            flash(_("Your password has been updated."), "success")
+            return redirect(url_for("auth.login"))
 
         form.token.data = token
         return render_template("auth/reset_password.html", form=form)
@@ -242,8 +239,9 @@ class RequestActivationToken(MethodView):
     form = RequestActivationForm
 
     def get(self):
-        return render_template("auth/request_account_activation.html",
-                               form=self.form())
+        return render_template(
+            "auth/request_account_activation.html", form=self.form()
+        )
 
     def post(self):
         form = self.form()
@@ -253,13 +251,16 @@ class RequestActivationToken(MethodView):
                 user_id=user.id, username=user.username, email=user.email
             )
             flash(
-                _("A new account activation token has been sent to "
-                  "your email address."), "success"
+                _(
+                    "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)
+        return render_template(
+            "auth/request_account_activation.html", form=form
+        )
 
 
 class ActivateAccount(MethodView):
@@ -269,9 +270,7 @@ class ActivateAccount(MethodView):
     def get(self, token=None):
         expired = invalid = user = None
         if token is not None:
-            expired, invalid, user = get_token_status(
-                token, "activate_account"
-            )
+            expired, invalid, user = get_token_status(token, "activate_account")
 
         if invalid:
             flash(_("Your account activation token is invalid."), "danger")
@@ -292,18 +291,14 @@ class ActivateAccount(MethodView):
             flash(_("Your account has been activated."), "success")
             return redirect(url_for("forum.index"))
 
-        return render_template(
-            "auth/account_activation.html", form=self.form()
-        )
+        return render_template("auth/account_activation.html", form=self.form())
 
     def post(self, token=None):
         expired = invalid = user = None
         form = self.form()
 
         if token is not None:
-            expired, invalid, user = get_token_status(
-                token, "activate_account"
-            )
+            expired, invalid, user = get_token_status(token, "activate_account")
 
         elif form.validate_on_submit():
             expired, invalid, user = get_token_status(
@@ -332,31 +327,117 @@ class ActivateAccount(MethodView):
         return render_template("auth/account_activation.html", form=form)
 
 
-register_view(auth, routes=['/logout'], view_func=Logout.as_view('logout'))
-register_view(auth, routes=['/login'], view_func=Login.as_view('login'))
-register_view(auth, routes=['/reauth'], view_func=Reauth.as_view('reauth'))
-register_view(
-    auth,
-    routes=['/register'],
-    view_func=Register.as_view('register')
-)
-register_view(
-    auth,
-    routes=['/reset-password'],
-    view_func=ForgotPassword.as_view('forgot_password')
-)
-register_view(
-    auth,
-    routes=['/reset-password/<token>'],
-    view_func=ResetPassword.as_view('reset_password')
-)
-register_view(
-    auth,
-    routes=['/activate'],
-    view_func=RequestActivationToken.as_view('request_activation_token')
-)
-register_view(
-    auth,
-    routes=['/activate/confirm', '/activate/confirm/<token>'],
-    view_func=ActivateAccount.as_view('activate_account')
-)
+@impl(tryfirst=True)
+def flaskbb_load_blueprints(app):
+    auth = Blueprint("auth", __name__)
+
+    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))
+
+    @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 registration_service_factory():
+        with app.app_context():
+            blacklist = [
+                w.strip()
+                for w in flaskbb_config["AUTH_USERNAME_BLACKLIST"].split(",")
+            ]
+
+            requirements = registration.UsernameRequirements(
+                min=flaskbb_config["AUTH_USERNAME_MIN_LENGTH"],
+                max=flaskbb_config["AUTH_USERNAME_MAX_LENGTH"],
+                blacklist=blacklist
+            )
+
+        validators = [
+            registration.EmailUniquenessValidator(User),
+            registration.UsernameUniquenessValidator(User),
+            registration.UsernameValidator(requirements)
+        ]
+
+        return RegistrationService(validators, UserRepository(db))
+
+    # Activate rate limiting on the whole blueprint
+    limiter.limit(
+        login_rate_limit, error_message=login_rate_limit_message
+    )(auth)
+
+    register_view(auth, routes=['/logout'], view_func=Logout.as_view('logout'))
+    register_view(auth, routes=['/login'], view_func=Login.as_view('login'))
+    register_view(auth, routes=['/reauth'], view_func=Reauth.as_view('reauth'))
+    register_view(
+        auth,
+        routes=['/register'],
+        view_func=Register.as_view(
+            'register',
+            registration_service_factory=registration_service_factory
+        )
+    )
+    register_view(
+        auth,
+        routes=['/reset-password'],
+        view_func=ForgotPassword.as_view('forgot_password')
+    )
+
+    def reset_service_factory():
+        token_serializer = FlaskBBTokenSerializer(
+            app.config['SECRET_KEY'],
+            expiry=timedelta(hours=1)
+        )
+        verifiers = [
+            EmailMatchesUserToken(User)
+        ]
+        return ResetPasswordService(
+            token_serializer,
+            User,
+            token_verifiers=verifiers
+        )
+
+    register_view(
+        auth,
+        routes=['/reset-password/<token>'],
+        view_func=ResetPassword.as_view(
+            'reset_password',
+            password_reset_service_factory=reset_service_factory
+        )
+    )
+    register_view(
+        auth,
+        routes=['/activate'],
+        view_func=RequestActivationToken.as_view('request_activation_token')
+    )
+    register_view(
+        auth,
+        routes=['/activate/confirm', '/activate/confirm/<token>'],
+        view_func=ActivateAccount.as_view('activate_account')
+    )
+
+    app.register_blueprint(auth, url_prefix=app.config['AUTH_URL_PREFIX'])

+ 0 - 0
flaskbb/core/__init__.py


+ 7 - 0
flaskbb/core/auth/__init__.py

@@ -0,0 +1,7 @@
+"""
+    flaskbb.core.auth
+    ~~~~~~~~~~~~~~~~~
+
+    :copyright: 2014-2018 (c) the FlaskBB Team.
+    :license: BSD, see LICENSE for more details
+"""

+ 41 - 0
flaskbb/core/auth/password.py

@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.core.auth.password
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~
+    Interfaces and services for auth services
+    related to password.
+
+    :copyright: (c) 2014-2018 the FlaskBB Team.
+    :license: BSD, see LICENSE for more details
+"""
+
+from ..exceptions import StopValidation, ValidationError
+from ..tokens import TokenActions, TokenError
+
+
+class ResetPasswordService(object):
+
+    def __init__(self, token_serializer, users, token_verifiers):
+        self.token_serializer = token_serializer
+        self.users = users
+        self.token_verifiers = token_verifiers
+
+    def verify_token(self, token, email):
+        errors = []
+
+        for verifier in self.token_verifiers:
+            try:
+                verifier(token, email=email)
+            except ValidationError as e:
+                errors.append((e.attribute, e.reason))
+
+        if errors:
+            raise StopValidation(errors)
+
+    def reset_password(self, token, email, new_password):
+        token = self.token_serializer.loads(token)
+        if token.operation != TokenActions.RESET_PASSWORD:
+            raise TokenError.invalid()
+        self.verify_token(token, email)
+        user = self.users.query.get(token.user_id)
+        user.password = new_password

+ 61 - 0
flaskbb/core/auth/registration.py

@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.core.auth.services
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    This modules provides services used in authentication and authorization
+    across FlaskBB.
+
+    :copyright: (c) 2014-2018 by the FlaskBB Team.
+    :license: BSD, see LICENSE for more details.
+"""
+
+from abc import abstractmethod
+
+import attr
+
+from ..._compat import ABC
+from ..exceptions import ValidationError, StopValidation
+
+
+@attr.s(hash=True, cmp=False, repr=True, frozen=True)
+class UserRegistrationInfo(object):
+    username = attr.ib()
+    password = attr.ib(repr=False)
+    email = attr.ib()
+    language = attr.ib()
+    group = attr.ib()
+
+
+class UserValidator(ABC):
+    @abstractmethod
+    def validate(self, user_info):
+        """
+        Used to check if a user should be allowed to register.
+        Should raise ValidationError if the user should not be
+        allowed to register.
+        """
+        return True
+
+    def __call__(self, user_info):
+        return self.validate(user_info)
+
+
+class RegistrationService(object):
+    def __init__(self, validators, user_repo):
+        self.validators = validators
+        self.user_repo = user_repo
+
+    def register(self, user_info):
+        failures = []
+
+        for v in self.validators:
+            try:
+                v(user_info)
+            except ValidationError as e:
+                failures.append((e.attribute, e.reason))
+
+        if failures:
+            raise StopValidation(failures)
+
+        self.user_repo.add(user_info)

+ 45 - 0
flaskbb/core/exceptions.py

@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.core.exceptions
+    ~~~~~~~~~~~~~~~~~~~~~~~
+
+    Exceptions raised by flaskbb.core,
+    forms the root of all exceptions in
+    FlaskBB.
+
+    :copyright: (c) 2014-2018 the FlaskBB Team
+    :license: BSD, see LICENSE for more details
+"""
+
+class BaseFlaskBBError(Exception):
+    "Root exception for FlaskBB"
+
+
+class ValidationError(BaseFlaskBBError):
+    """
+    Used to signal validation errors for things such as
+    token verification, user registration, etc.
+    """
+
+    def __init__(self, attribute, reason):
+        self.attribute = attribute
+        self.reason = reason
+        super(ValidationError, self).__init__((attribute, reason))
+
+
+class StopValidation(BaseFlaskBBError):
+    """
+    Raised from validation handlers to signal that
+    validation should end immediately and no further
+    processing should be done.
+
+    The reasons passed should be an iterable of
+    tuples consisting of `(attribute, reason)`
+
+    Can also be used to communicate all errors
+    raised during a validation run.
+    """
+
+    def __init__(self, reasons):
+        self.reasons = reasons
+        super(StopValidation, self).__init__(reasons)

+ 91 - 0
flaskbb/core/tokens.py

@@ -0,0 +1,91 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.core.auth.tokens
+    ~~~~~~~~~~~~~~~~~~~~~~~~
+
+    This module provides ways of interacting
+    with tokens in FlaskBB
+
+    :copyright: (c) 2014-2018 by the FlaskBB Team.
+    :license: BSD, see LICENSE for more details
+"""
+from abc import abstractmethod
+
+import attr
+
+from .._compat import ABC
+from .exceptions import BaseFlaskBBError
+
+class TokenError(BaseFlaskBBError):
+    """
+    Raised when there is an issue with deserializing
+    a token. Has helper classmethods to ensure
+    consistent verbiage.
+    """
+    def __init__(self, reason):
+        self.reason = reason
+        super(TokenError, self).__init__(reason)
+
+    @classmethod
+    def invalid(cls):
+        return cls('Token is invalid')
+
+    @classmethod
+    def expired(cls):
+        return cls('Token is expired')
+
+    # in theory this would never be raised
+    # but it's provided for a generic catchall
+    # when processing goes horribly wrong
+    @classmethod  # pragma: no cover
+    def bad(cls):
+        return cls('Token cannot be processed')
+
+
+# holder for token actions
+# not an enum so plugins can add to it
+class TokenActions:
+    RESET_PASSWORD = 'reset_password'
+    ACTIVATE_ACCOUNT = 'activate_account'
+
+
+@attr.s(frozen=True, cmp=True, hash=True)
+class Token(object):
+    user_id = attr.ib()
+    operation = attr.ib()
+
+
+class TokenSerializer(ABC):
+    """
+    Interface for token serializers.
+
+    dumps must accept a Token instance and produce
+    a JWT
+
+    loads must accept a string representation of
+    a JWT and produce a token instance
+    """
+    @abstractmethod
+    def dumps(self, token):
+        pass
+
+    @abstractmethod
+    def loads(self, raw_token):
+        pass
+
+
+class TokenVerifier(ABC):
+    """
+    Used to verify the validatity of tokens post
+    deserialization, such as an email matching the
+    user id in the provided token.
+
+    Should raise a flaskbb.core.exceptions.ValidationError
+    if verification fails.
+    """
+    @abstractmethod
+    def verify_token(self, token, **kwargs):
+        pass
+
+    def __call__(self, token, **kwargs):
+        return self.verify_token(token, **kwargs)

+ 0 - 0
flaskbb/core/user/__init__.py


+ 31 - 0
flaskbb/core/user/repo.py

@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.core.user.repo
+    ~~~~~~~~~~~~~~~~~~~~~~
+
+    This module provides an abstracted access to users stored in the database.
+
+    :copyright: (c) 2014-2018 the FlaskbBB Team.
+    :license: BSD, see LICENSE for more details
+"""
+
+from ..._compat import ABC
+from abc import abstractmethod
+
+
+class UserRepository(ABC):
+    @abstractmethod
+    def add(self, user_info):
+        pass
+
+    @abstractmethod
+    def find_by(self, **kwargs):
+        pass
+
+    @abstractmethod
+    def get(self, user_id):
+        pass
+
+    @abstractmethod
+    def find_one_by(self, **kwargs):
+        pass

+ 6 - 2
flaskbb/exceptions.py

@@ -8,13 +8,17 @@
     :license: BSD, see LICENSE for more details
 """
 from werkzeug.exceptions import HTTPException, Forbidden
+from .core.exceptions import BaseFlaskBBError
 
 
-class FlaskBBError(HTTPException):
-    "Root exception for FlaskBB"
+
+class FlaskBBHTTPError(BaseFlaskBBError, HTTPException):
     description = "An internal error has occured"
 
 
+FlaskBBError = FlaskBBHTTPError
+
+
 class AuthorizationRequired(FlaskBBError, Forbidden):
     description = "Authorization is required to access this area."
 

+ 313 - 154
flaskbb/forum/views.py

@@ -18,6 +18,7 @@ from flask.views import MethodView
 from flask_allows import And, Permission
 from flask_babelplus import gettext as _
 from flask_login import current_user, login_required
+from pluggy import HookimplMarker
 from sqlalchemy import asc, desc
 
 from flaskbb.extensions import allows, db
@@ -36,10 +37,9 @@ from flaskbb.utils.requirements import (CanAccessForum, CanAccessTopic,
                                         IsAtleastModeratorInForum)
 from flaskbb.utils.settings import flaskbb_config
 
-logger = logging.getLogger(__name__)
-
+impl = HookimplMarker('flaskbb')
 
-forum = Blueprint("forum", __name__)
+logger = logging.getLogger(__name__)
 
 
 class ForumIndex(MethodView):
@@ -55,7 +55,8 @@ class ForumIndex(MethodView):
 
         # Check if we use redis or not
         if not current_app.config['REDIS_ENABLED']:
-            online_users = User.query.filter(User.lastseen >= time_diff()).count()
+            online_users = User.query.filter(User.lastseen >= time_diff()
+                                             ).count()
 
             # Because we do not have server side sessions,
             # we cannot check if there are online guests
@@ -79,9 +80,13 @@ class ForumIndex(MethodView):
 class ViewCategory(MethodView):
 
     def get(self, category_id, slug=None):
-        category, forums = Category.get_forums(category_id=category_id, user=real(current_user))
+        category, forums = Category.get_forums(
+            category_id=category_id, user=real(current_user)
+        )
 
-        return render_template('forum/category.html', forums=forums, category=category)
+        return render_template(
+            'forum/category.html', forums=forums, category=category
+        )
 
 
 class ViewForum(MethodView):
@@ -90,7 +95,9 @@ class ViewForum(MethodView):
     def get(self, forum_id, slug=None):
         page = request.args.get('page', 1, type=int)
 
-        forum_instance, forumsread = Forum.get_forum(forum_id=forum_id, user=real(current_user))
+        forum_instance, forumsread = Forum.get_forum(
+            forum_id=forum_id, user=real(current_user)
+        )
 
         if forum_instance.external:
             return redirect(forum_instance.external)
@@ -116,11 +123,11 @@ class ViewPost(MethodView):
         '''Redirects to a post in a topic.'''
         post = Post.query.filter_by(id=post_id).first_or_404()
         post_in_topic = Post.query.filter(
-            Post.topic_id == post.topic_id,
-            Post.id <= post_id).order_by(Post.id.asc()).count()
-        page = int(math.ceil(
-            post_in_topic / float(flaskbb_config['POSTS_PER_PAGE'])
-        ))
+            Post.topic_id == post.topic_id, Post.id <= post_id
+        ).order_by(Post.id.asc()).count()
+        page = int(
+            math.ceil(post_in_topic / float(flaskbb_config['POSTS_PER_PAGE']))
+        )
 
         return redirect(
             url_for(
@@ -171,8 +178,11 @@ class ViewTopic(MethodView):
             abort(404)
 
         return render_template(
-            'forum/topic.html', topic=topic, posts=posts,
-            last_seen=time_diff(), form=self.form()
+            'forum/topic.html',
+            topic=topic,
+            posts=posts,
+            last_seen=time_diff(),
+            form=self.form()
         )
 
     @allows.requires(CanPostReply)
@@ -191,7 +201,9 @@ class ViewTopic(MethodView):
         else:
             for e in form.errors.get('content', []):
                 flash(e, 'danger')
-            return redirect(url_for('forum.view_topic', topic_id=topic_id, slug=slug))
+            return redirect(
+                url_for('forum.view_topic', topic_id=topic_id, slug=slug)
+            )
 
     def form(self):
         if Permission(CanPostReply):
@@ -214,7 +226,9 @@ class NewTopic(MethodView):
         form = self.form()
         if 'preview' in request.form and form.validate():
             return render_template(
-                'forum/new_topic.html', forum=forum_instance, form=form,
+                'forum/new_topic.html',
+                forum=forum_instance,
+                form=form,
                 preview=form.content.data
             )
         elif 'submit' in request.form and form.validate():
@@ -236,7 +250,9 @@ class ManageForum(MethodView):
 
     def get(self, forum_id, slug=None):
 
-        forum_instance, forumsread = Forum.get_forum(forum_id=forum_id, user=real(current_user))
+        forum_instance, forumsread = Forum.get_forum(
+            forum_id=forum_id, user=real(current_user)
+        )
 
         if forum_instance.external:
             return redirect(forum_instance.external)
@@ -261,9 +277,13 @@ class ManageForum(MethodView):
         )
 
     def post(self, forum_id, slug=None):
-        forum_instance, __ = Forum.get_forum(forum_id=forum_id, user=real(current_user))
+        forum_instance, __ = Forum.get_forum(
+            forum_id=forum_id, user=real(current_user)
+        )
         mod_forum_url = url_for(
-            'forum.manage_forum', forum_id=forum_instance.id, slug=forum_instance.slug
+            'forum.manage_forum',
+            forum_id=forum_instance.id,
+            slug=forum_instance.slug
         )
 
         ids = request.form.getlist('rowid')
@@ -271,15 +291,20 @@ class ManageForum(MethodView):
 
         if not len(tmp_topics) > 0:
             flash(
-                _('In order to perform this action you have to select at '
-                  'least one topic.'), 'danger'
+                _(
+                    'In order to perform this action you have to select at '
+                    'least one topic.'
+                ), 'danger'
             )
             return redirect(mod_forum_url)
 
         # locking/unlocking
         if 'lock' in request.form:
             changed = do_topic_action(
-                topics=tmp_topics, user=real(current_user), action='locked', reverse=False
+                topics=tmp_topics,
+                user=real(current_user),
+                action='locked',
+                reverse=False
             )
 
             flash(_('%(count)s topics locked.', count=changed), 'success')
@@ -287,7 +312,10 @@ class ManageForum(MethodView):
 
         elif 'unlock' in request.form:
             changed = do_topic_action(
-                topics=tmp_topics, user=real(current_user), action='locked', reverse=True
+                topics=tmp_topics,
+                user=real(current_user),
+                action='locked',
+                reverse=True
             )
             flash(_('%(count)s topics unlocked.', count=changed), 'success')
             return redirect(mod_forum_url)
@@ -295,14 +323,20 @@ class ManageForum(MethodView):
         # highlighting/trivializing
         elif 'highlight' in request.form:
             changed = do_topic_action(
-                topics=tmp_topics, user=real(current_user), action='important', reverse=False
+                topics=tmp_topics,
+                user=real(current_user),
+                action='important',
+                reverse=False
             )
             flash(_('%(count)s topics highlighted.', count=changed), 'success')
             return redirect(mod_forum_url)
 
         elif 'trivialize' in request.form:
             changed = do_topic_action(
-                topics=tmp_topics, user=real(current_user), action='important', reverse=True
+                topics=tmp_topics,
+                user=real(current_user),
+                action='important',
+                reverse=True
             )
             flash(_('%(count)s topics trivialized.', count=changed), 'success')
             return redirect(mod_forum_url)
@@ -310,7 +344,10 @@ class ManageForum(MethodView):
         # deleting
         elif 'delete' in request.form:
             changed = do_topic_action(
-                topics=tmp_topics, user=real(current_user), action='delete', reverse=False
+                topics=tmp_topics,
+                user=real(current_user),
+                action='delete',
+                reverse=False
             )
             flash(_('%(count)s topics deleted.', count=changed), 'success')
             return redirect(mod_forum_url)
@@ -326,9 +363,13 @@ class ManageForum(MethodView):
             new_forum = Forum.query.filter_by(id=new_forum_id).first_or_404()
             # check the permission in the current forum and in the new forum
 
-            if not Permission(And(IsAtleastModeratorInForum(forum_id=new_forum_id),
-                                  IsAtleastModeratorInForum(forum=forum_instance))):
-                flash(_('You do not have the permissions to move this topic.'), 'danger')
+            if not Permission(
+                    And(IsAtleastModeratorInForum(forum_id=new_forum_id),
+                        IsAtleastModeratorInForum(forum=forum_instance))):
+                flash(
+                    _('You do not have the permissions to move this topic.'),
+                    'danger'
+                )
                 return redirect(mod_forum_url)
 
             if new_forum.move_topics_to(tmp_topics):
@@ -341,14 +382,20 @@ class ManageForum(MethodView):
         # hiding/unhiding
         elif "hide" in request.form:
             changed = do_topic_action(
-                topics=tmp_topics, user=real(current_user), action="hide", reverse=False
+                topics=tmp_topics,
+                user=real(current_user),
+                action="hide",
+                reverse=False
             )
             flash(_("%(count)s topics hidden.", count=changed), "success")
             return redirect(mod_forum_url)
 
         elif "unhide" in request.form:
             changed = do_topic_action(
-                topics=tmp_topics, user=real(current_user), action="unhide", reverse=False
+                topics=tmp_topics,
+                user=real(current_user),
+                action="unhide",
+                reverse=False
             )
             flash(_("%(count)s topics unhidden.", count=changed), "success")
             return redirect(mod_forum_url)
@@ -375,7 +422,9 @@ class NewPost(MethodView):
         if form.validate_on_submit():
             if 'preview' in request.form:
                 return render_template(
-                    'forum/new_post.html', topic=topic, form=form,
+                    'forum/new_post.html',
+                    topic=topic,
+                    form=form,
                     preview=form.content.data
                 )
             else:
@@ -395,7 +444,9 @@ class ReplyPost(MethodView):
 
     def get(self, topic_id, post_id):
         topic = Topic.query.filter_by(id=topic_id).first_or_404()
-        return render_template('forum/new_post.html', topic=topic, form=self.form())
+        return render_template(
+            'forum/new_post.html', topic=topic, form=self.form()
+        )
 
     def post(self, topic_id, post_id):
         form = self.form()
@@ -403,7 +454,10 @@ class ReplyPost(MethodView):
         if form.validate_on_submit():
             if 'preview' in request.form:
                 return render_template(
-                    'forum/new_post.html', topic=topic, form=form, preview=form.content.data
+                    'forum/new_post.html',
+                    topic=topic,
+                    form=form,
+                    preview=form.content.data
                 )
             else:
                 post = form.save(real(current_user), topic)
@@ -411,7 +465,9 @@ class ReplyPost(MethodView):
         else:
             form.content.data = format_quote(post.username, post.content)
 
-        return render_template('forum/new_post.html', topic=post.topic, form=form)
+        return render_template(
+            'forum/new_post.html', topic=post.topic, form=form
+        )
 
 
 class EditPost(MethodView):
@@ -494,7 +550,9 @@ class MemberList(MethodView):
         users = User.query.order_by(order_func(sort_obj)).paginate(
             page, flaskbb_config['USERS_PER_PAGE'], False
         )
-        return render_template('forum/memberlist.html', users=users, search_form=self.form())
+        return render_template(
+            'forum/memberlist.html', users=users, search_form=self.form()
+        )
 
     def post(self):
         page = request.args.get('page', 1, type=int)
@@ -515,13 +573,19 @@ class MemberList(MethodView):
 
         form = self.form()
         if form.validate():
-            users = form.get_results().paginate(page, flaskbb_config['USERS_PER_PAGE'], False)
-            return render_template('forum/memberlist.html', users=users, search_form=form)
+            users = form.get_results().paginate(
+                page, flaskbb_config['USERS_PER_PAGE'], False
+            )
+            return render_template(
+                'forum/memberlist.html', users=users, search_form=form
+            )
 
         users = User.query.order_by(order_func(sort_obj)).paginate(
             page, flaskbb_config['USERS_PER_PAGE'], False
         )
-        return render_template('forum/memberlist.html', users=users, search_form=form)
+        return render_template(
+            'forum/memberlist.html', users=users, search_form=form
+        )
 
 
 class TopicTracker(MethodView):
@@ -531,7 +595,10 @@ class TopicTracker(MethodView):
         page = request.args.get('page', 1, type=int)
         topics = real(current_user).tracked_topics.outerjoin(
             TopicsRead,
-            db.and_(TopicsRead.topic_id == Topic.id, TopicsRead.user_id == real(current_user).id)
+            db.and_(
+                TopicsRead.topic_id == Topic.id,
+                TopicsRead.user_id == real(current_user).id
+            )
         ).add_entity(TopicsRead).order_by(Topic.last_updated.desc()).paginate(
             page, flaskbb_config['TOPICS_PER_PAGE'], True
         )
@@ -547,7 +614,10 @@ class TopicTracker(MethodView):
 
         real(current_user).save()
 
-        flash(_('%(topic_count)s topics untracked.', topic_count=len(tmp_topics)), 'success')
+        flash(
+            _('%(topic_count)s topics untracked.', topic_count=len(tmp_topics)),
+            'success'
+        )
         return redirect(url_for('forum.topictracker'))
 
 
@@ -561,7 +631,9 @@ class Search(MethodView):
         form = self.form()
         if form.validate_on_submit():
             result = form.get_results()
-            return render_template('forum/search_result.html', form=form, result=result)
+            return render_template(
+                'forum/search_result.html', form=form, result=result
+            )
 
         return render_template('forum/search_form.html', form=form)
 
@@ -571,8 +643,9 @@ class DeleteTopic(MethodView):
 
     def post(self, topic_id, slug=None):
         topic = Topic.query.filter_by(id=topic_id).first_or_404()
-        involved_users = User.query.filter(Post.topic_id == topic.id,
-                                           User.id == Post.user_id).all()
+        involved_users = User.query.filter(
+            Post.topic_id == topic.id, User.id == Post.user_id
+        ).all()
         topic.delete(users=involved_users)
         return redirect(url_for('forum.view_forum', forum_id=topic.forum_id))
 
@@ -667,7 +740,12 @@ class MarkRead(MethodView):
             db.session.add(forumsread)
             db.session.commit()
 
-            flash(_('Forum %(forum)s marked as read.', forum=forum_instance.title), 'success')
+            flash(
+                _(
+                    'Forum %(forum)s marked as read.',
+                    forum=forum_instance.title
+                ), 'success'
+            )
 
             return redirect(forum_instance.url)
 
@@ -700,7 +778,9 @@ class WhoIsOnline(MethodView):
             online_users = get_online_users()
         else:
             online_users = User.query.filter(User.lastseen >= time_diff()).all()
-        return render_template('forum/online_users.html', online_users=online_users)
+        return render_template(
+            'forum/online_users.html', online_users=online_users
+        )
 
 
 class TrackTopic(MethodView):
@@ -728,7 +808,8 @@ class HideTopic(MethodView):
 
     def post(self, topic_id, slug=None):
         topic = Topic.query.with_hidden().filter_by(id=topic_id).first_or_404()
-        if not Permission(Has('makehidden'), IsAtleastModeratorInForum(forum=topic.forum)):
+        if not Permission(Has('makehidden'), IsAtleastModeratorInForum(
+                forum=topic.forum)):
             flash(_("You do not have permission to hide this topic"), "danger")
             return redirect(topic.url)
         topic.hide(user=current_user)
@@ -744,8 +825,11 @@ class UnhideTopic(MethodView):
 
     def post(self, topic_id, slug=None):
         topic = Topic.query.filter_by(id=topic_id).first_or_404()
-        if not Permission(Has('makehidden'), IsAtleastModeratorInForum(forum=topic.forum)):
-            flash(_("You do not have permission to unhide this topic"), "danger")
+        if not Permission(Has('makehidden'), IsAtleastModeratorInForum(
+                forum=topic.forum)):
+            flash(
+                _("You do not have permission to unhide this topic"), "danger"
+            )
             return redirect(topic.url)
         topic.unhide()
         topic.save()
@@ -758,7 +842,8 @@ class HidePost(MethodView):
     def post(self, post_id):
         post = Post.query.filter(Post.id == post_id).first_or_404()
 
-        if not Permission(Has('makehidden'), IsAtleastModeratorInForum(forum=post.topic.forum)):
+        if not Permission(Has('makehidden'), IsAtleastModeratorInForum(
+                forum=post.topic.forum)):
             flash(_("You do not have permission to hide this post"), "danger")
             return redirect(post.topic.url)
 
@@ -787,7 +872,8 @@ class UnhidePost(MethodView):
     def post(self, post_id):
         post = Post.query.filter(Post.id == post_id).first_or_404()
 
-        if not Permission(Has('makehidden'), IsAtleastModeratorInForum(forum=post.topic.forum)):
+        if not Permission(Has('makehidden'), IsAtleastModeratorInForum(
+                forum=post.topic.forum)):
             flash(_("You do not have permission to unhide this post"), "danger")
             return redirect(post.topic.url)
 
@@ -801,106 +887,179 @@ class UnhidePost(MethodView):
         return redirect(post.topic.url)
 
 
-register_view(
-    forum,
-    routes=['/category/<int:category_id>', '/category/<int:category_id>-<slug>'],
-    view_func=ViewCategory.as_view('view_category')
-)
-register_view(
-    forum,
-    routes=['/forum/<int:forum_id>/edit', '/forum/<int:forum_id>-<slug>/edit'],
-    view_func=ManageForum.as_view('manage_forum')
-)
-register_view(
-    forum,
-    routes=['/forum/<int:forum_id>', '/forum/<int:forum_id>-<slug>'],
-    view_func=ViewForum.as_view('view_forum')
-)
-register_view(
-    forum,
-    routes=['/<int:forum_id>/markread', '/<int:forum_id>-<slug>/markread'],
-    view_func=MarkRead.as_view('markread')
-)
-register_view(
-    forum,
-    routes=['/<int:forum_id>/topic/new', '/<int:forum_id>-<slug>/topic/new'],
-    view_func=NewTopic.as_view('new_topic')
-)
-register_view(forum, routes=['/memberlist'], view_func=MemberList.as_view('memberlist'))
-register_view(
-    forum, routes=['/post/<int:post_id>/delete'], view_func=DeletePost.as_view('delete_post')
-)
-register_view(forum, routes=['/post/<int:post_id>/edit'], view_func=EditPost.as_view('edit_post'))
-register_view(forum, routes=['/post/<int:post_id>/raw'], view_func=RawPost.as_view('raw_post'))
-register_view(
-    forum, routes=['/post/<int:post_id>/report'], view_func=ReportView.as_view('report_post')
-)
-register_view(forum, routes=['/post/<int:post_id>'], view_func=ViewPost.as_view('view_post'))
-register_view(forum, routes=['/search'], view_func=Search.as_view('search'))
-register_view(
-    forum,
-    routes=['/topic/<int:topic_id>/delete', '/topic/<int:topic_id>-<slug>/delete'],
-    view_func=DeleteTopic.as_view('delete_topic')
-)
-register_view(
-    forum,
-    routes=['/topic/<int:topic_id>/highlight', '/topic/<int:topic_id>-<slug>/highlight'],
-    view_func=HighlightTopic.as_view('highlight_topic')
-)
-register_view(
-    forum,
-    routes=['/topic/<int:topic_id>/lock', '/topic/<int:topic_id>-<slug>/lock'],
-    view_func=LockTopic.as_view('lock_topic')
-)
-register_view(
-    forum,
-    routes=['/topic/<int:topic_id>/post/<int:post_id>/reply'],
-    view_func=ReplyPost.as_view('reply_post')
-)
-register_view(
-    forum,
-    routes=['/topic/<int:topic_id>/post/new', '/topic/<int:topic_id>-<slug>/post/new'],
-    view_func=NewPost.as_view('new_post')
-)
-register_view(
-    forum,
-    routes=['/topic/<int:topic_id>', '/topic/<int:topic_id>-<slug>'],
-    view_func=ViewTopic.as_view('view_topic')
-)
-register_view(
-    forum,
-    routes=['/topic/<int:topic_id>/trivialize', '/topic/<int:topic_id>-<slug>/trivialize'],
-    view_func=TrivializeTopic.as_view('trivialize_topic')
-)
-register_view(
-    forum,
-    routes=['/topic/<int:topic_id>/unlock', '/topic/<int:topic_id>-<slug>/unlock'],
-    view_func=UnlockTopic.as_view('unlock_topic')
-)
-register_view(
-    forum,
-    routes=['/topictracker/<int:topic_id>/add', '/topictracker/<int:topic_id>-<slug>/add'],
-    view_func=TrackTopic.as_view('track_topic')
-)
-register_view(
-    forum,
-    routes=['/topictracker/<int:topic_id>/delete', '/topictracker/<int:topic_id>-<slug>/delete'],
-    view_func=UntrackTopic.as_view('untrack_topic')
-)
-register_view(forum, routes=['/topictracker'], view_func=TopicTracker.as_view('topictracker'))
-register_view(forum, routes=['/'], view_func=ForumIndex.as_view('index'))
-register_view(forum, routes=['/who-is-online'], view_func=WhoIsOnline.as_view('who_is_online'))
-register_view(
-    forum,
-    routes=["/topic/<int:topic_id>/hide", "/topic/<int:topic_id>-<slug>/hide"],
-    view_func=HideTopic.as_view('hide_topic')
-)
-register_view(
-    forum,
-    routes=["/topic/<int:topic_id>/unhide", "/topic/<int:topic_id>-<slug>/unhide"],
-    view_func=UnhideTopic.as_view('unhide_topic')
-)
-register_view(forum, routes=["/post/<int:post_id>/hide"], view_func=HidePost.as_view('hide_post'))
-register_view(
-    forum, routes=["/post/<int:post_id>/unhide"], view_func=UnhidePost.as_view('unhide_post')
-)
+@impl(tryfirst=True)
+def flaskbb_load_blueprints(app):
+    forum = Blueprint("forum", __name__)
+    register_view(
+        forum,
+        routes=[
+            '/category/<int:category_id>', '/category/<int:category_id>-<slug>'
+        ],
+        view_func=ViewCategory.as_view('view_category')
+    )
+    register_view(
+        forum,
+        routes=[
+            '/forum/<int:forum_id>/edit', '/forum/<int:forum_id>-<slug>/edit'
+        ],
+        view_func=ManageForum.as_view('manage_forum')
+    )
+    register_view(
+        forum,
+        routes=['/forum/<int:forum_id>', '/forum/<int:forum_id>-<slug>'],
+        view_func=ViewForum.as_view('view_forum')
+    )
+    register_view(
+        forum,
+        routes=['/<int:forum_id>/markread', '/<int:forum_id>-<slug>/markread'],
+        view_func=MarkRead.as_view('markread')
+    )
+    register_view(
+        forum,
+        routes=[
+            '/<int:forum_id>/topic/new', '/<int:forum_id>-<slug>/topic/new'
+        ],
+        view_func=NewTopic.as_view('new_topic')
+    )
+    register_view(
+        forum,
+        routes=['/memberlist'],
+        view_func=MemberList.as_view('memberlist')
+    )
+    register_view(
+        forum,
+        routes=['/post/<int:post_id>/delete'],
+        view_func=DeletePost.as_view('delete_post')
+    )
+    register_view(
+        forum,
+        routes=['/post/<int:post_id>/edit'],
+        view_func=EditPost.as_view('edit_post')
+    )
+    register_view(
+        forum,
+        routes=['/post/<int:post_id>/raw'],
+        view_func=RawPost.as_view('raw_post')
+    )
+    register_view(
+        forum,
+        routes=['/post/<int:post_id>/report'],
+        view_func=ReportView.as_view('report_post')
+    )
+    register_view(
+        forum,
+        routes=['/post/<int:post_id>'],
+        view_func=ViewPost.as_view('view_post')
+    )
+    register_view(forum, routes=['/search'], view_func=Search.as_view('search'))
+    register_view(
+        forum,
+        routes=[
+            '/topic/<int:topic_id>/delete',
+            '/topic/<int:topic_id>-<slug>/delete'
+        ],
+        view_func=DeleteTopic.as_view('delete_topic')
+    )
+    register_view(
+        forum,
+        routes=[
+            '/topic/<int:topic_id>/highlight',
+            '/topic/<int:topic_id>-<slug>/highlight'
+        ],
+        view_func=HighlightTopic.as_view('highlight_topic')
+    )
+    register_view(
+        forum,
+        routes=[
+            '/topic/<int:topic_id>/lock', '/topic/<int:topic_id>-<slug>/lock'
+        ],
+        view_func=LockTopic.as_view('lock_topic')
+    )
+    register_view(
+        forum,
+        routes=['/topic/<int:topic_id>/post/<int:post_id>/reply'],
+        view_func=ReplyPost.as_view('reply_post')
+    )
+    register_view(
+        forum,
+        routes=[
+            '/topic/<int:topic_id>/post/new',
+            '/topic/<int:topic_id>-<slug>/post/new'
+        ],
+        view_func=NewPost.as_view('new_post')
+    )
+    register_view(
+        forum,
+        routes=['/topic/<int:topic_id>', '/topic/<int:topic_id>-<slug>'],
+        view_func=ViewTopic.as_view('view_topic')
+    )
+    register_view(
+        forum,
+        routes=[
+            '/topic/<int:topic_id>/trivialize',
+            '/topic/<int:topic_id>-<slug>/trivialize'
+        ],
+        view_func=TrivializeTopic.as_view('trivialize_topic')
+    )
+    register_view(
+        forum,
+        routes=[
+            '/topic/<int:topic_id>/unlock',
+            '/topic/<int:topic_id>-<slug>/unlock'
+        ],
+        view_func=UnlockTopic.as_view('unlock_topic')
+    )
+    register_view(
+        forum,
+        routes=[
+            '/topictracker/<int:topic_id>/add',
+            '/topictracker/<int:topic_id>-<slug>/add'
+        ],
+        view_func=TrackTopic.as_view('track_topic')
+    )
+    register_view(
+        forum,
+        routes=[
+            '/topictracker/<int:topic_id>/delete',
+            '/topictracker/<int:topic_id>-<slug>/delete'
+        ],
+        view_func=UntrackTopic.as_view('untrack_topic')
+    )
+    register_view(
+        forum,
+        routes=['/topictracker'],
+        view_func=TopicTracker.as_view('topictracker')
+    )
+    register_view(forum, routes=['/'], view_func=ForumIndex.as_view('index'))
+    register_view(
+        forum,
+        routes=['/who-is-online'],
+        view_func=WhoIsOnline.as_view('who_is_online')
+    )
+    register_view(
+        forum,
+        routes=[
+            "/topic/<int:topic_id>/hide", "/topic/<int:topic_id>-<slug>/hide"
+        ],
+        view_func=HideTopic.as_view('hide_topic')
+    )
+    register_view(
+        forum,
+        routes=[
+            "/topic/<int:topic_id>/unhide",
+            "/topic/<int:topic_id>-<slug>/unhide"
+        ],
+        view_func=UnhideTopic.as_view('unhide_topic')
+    )
+    register_view(
+        forum,
+        routes=["/post/<int:post_id>/hide"],
+        view_func=HidePost.as_view('hide_post')
+    )
+    register_view(
+        forum,
+        routes=["/post/<int:post_id>/unhide"],
+        view_func=UnhidePost.as_view('unhide_post')
+    )
+
+    app.register_blueprint(forum, url_prefix=app.config["FORUM_URL_PREFIX"])

+ 351 - 239
flaskbb/management/views.py

@@ -19,6 +19,8 @@ from flask.views import MethodView
 from flask_allows import Not, Permission
 from flask_babelplus import gettext as _
 from flask_login import current_user, login_fresh
+from pluggy import HookimplMarker
+
 from flaskbb import __version__ as flaskbb_version
 from flaskbb.extensions import allows, celery, db
 from flaskbb.forum.forms import UserSearchForm
@@ -28,28 +30,19 @@ from flaskbb.management.forms import (AddForumForm, AddGroupForm, AddUserForm,
                                       EditGroupForm, EditUserForm)
 from flaskbb.management.models import Setting, SettingsGroup
 from flaskbb.plugins.models import PluginRegistry, PluginStore
-from flaskbb.user.models import Group, Guest, User
 from flaskbb.plugins.utils import validate_plugin
+from flaskbb.user.models import Group, Guest, User
+from flaskbb.utils.forms import populate_settings_dict, populate_settings_form
 from flaskbb.utils.helpers import (get_online_users, register_view,
                                    render_template, time_diff, time_utcnow)
 from flaskbb.utils.requirements import (CanBanUser, CanEditUser, IsAdmin,
                                         IsAtleastModerator,
                                         IsAtleastSuperModerator)
 from flaskbb.utils.settings import flaskbb_config
-from flaskbb.utils.forms import populate_settings_dict, populate_settings_form
-
-logger = logging.getLogger(__name__)
-
 
-management = Blueprint("management", __name__)
+impl = HookimplMarker('flaskbb')
 
-
-@management.before_request
-def check_fresh_login():
-    """Checks if the login is fresh for the current user, otherwise the user
-    has to reauthenticate."""
-    if not login_fresh():
-        return current_app.login_manager.needs_refresh()
+logger = logging.getLogger(__name__)
 
 
 class ManagementSettings(MethodView):
@@ -65,9 +58,14 @@ class ManagementSettings(MethodView):
         active_nav = {}  # used to build the navigation
         plugin_obj = None
         if plugin is not None:
-            plugin_obj = PluginRegistry.query.filter_by(name=plugin).first_or_404()
-            active_nav.update({'key': plugin_obj.name,
-                               'title': plugin_obj.name.title()})
+            plugin_obj = PluginRegistry.query.filter_by(name=plugin
+                                                        ).first_or_404()
+            active_nav.update(
+                {
+                    'key': plugin_obj.name,
+                    'title': plugin_obj.name.title()
+                }
+            )
             form = plugin_obj.get_settings_form()
             old_settings = plugin_obj.settings
 
@@ -85,18 +83,28 @@ class ManagementSettings(MethodView):
 
         # get all groups and plugins - used to build the navigation
         all_groups = SettingsGroup.query.all()
-        all_plugins = PluginRegistry.query.filter(PluginRegistry.values != None).all()
+        all_plugins = PluginRegistry.query.filter(db.and_(
+            PluginRegistry.values != None,
+            PluginRegistry.enabled == True
+        )).all()
         form = populate_settings_form(form, old_settings)
 
-        return render_template("management/settings.html", form=form,
-                               all_groups=all_groups, all_plugins=all_plugins,
-                               active_nav=active_nav)
+        return render_template(
+            "management/settings.html",
+            form=form,
+            all_groups=all_groups,
+            all_plugins=all_plugins,
+            active_nav=active_nav
+        )
 
     def post(self, slug=None, plugin=None):
         form, old_settings, plugin_obj, active_nav = \
             self._determine_active_settings(slug, plugin)
         all_groups = SettingsGroup.query.all()
-        all_plugins = PluginRegistry.query.all()
+        all_plugins = PluginRegistry.query.filter(db.and_(
+            PluginRegistry.values != None,
+            PluginRegistry.enabled == True
+        )).all()
 
         if form.validate_on_submit():
             new_settings = populate_settings_dict(form, old_settings)
@@ -108,9 +116,13 @@ class ManagementSettings(MethodView):
 
             flash(_("Settings saved."), "success")
 
-        return render_template("management/settings.html", form=form,
-                               all_groups=all_groups, all_plugins=all_plugins,
-                               active_nav=active_nav)
+        return render_template(
+            "management/settings.html",
+            form=form,
+            all_groups=all_groups,
+            all_plugins=all_plugins,
+            active_nav=active_nav
+        )
 
 
 class ManageUsers(MethodView):
@@ -125,7 +137,9 @@ class ManageUsers(MethodView):
             page, flaskbb_config['USERS_PER_PAGE'], False
         )
 
-        return render_template('management/users.html', users=users, search_form=form)
+        return render_template(
+            'management/users.html', users=users, search_form=form
+        )
 
     def post(self):
         page = request.args.get('page', 1, type=int)
@@ -134,13 +148,17 @@ class ManageUsers(MethodView):
         if form.validate():
             users = form.get_results().\
                 paginate(page, flaskbb_config['USERS_PER_PAGE'], False)
-            return render_template('management/users.html', users=users, search_form=form)
+            return render_template(
+                'management/users.html', users=users, search_form=form
+            )
 
         users = User.query.order_by(User.id.asc()).paginate(
             page, flaskbb_config['USERS_PER_PAGE'], False
         )
 
-        return render_template('management/users.html', users=users, search_form=form)
+        return render_template(
+            'management/users.html', users=users, search_form=form
+        )
 
 
 class EditUser(MethodView):
@@ -151,13 +169,15 @@ class EditUser(MethodView):
         user = User.query.filter_by(id=user_id).first_or_404()
         form = self.form(user)
         member_group = db.and_(
-            *[
+            * [
                 db.not_(getattr(Group, p))
                 for p in ['admin', 'mod', 'super_mod', 'banned', 'guest']
             ]
         )
 
-        filt = db.or_(Group.id.in_(g.id for g in current_user.groups), member_group)
+        filt = db.or_(
+            Group.id.in_(g.id for g in current_user.groups), member_group
+        )
 
         if Permission(IsAtleastSuperModerator, identity=current_user):
             filt = db.or_(filt, Group.mod)
@@ -173,19 +193,23 @@ class EditUser(MethodView):
         form.primary_group.query = group_query
         form.secondary_groups.query = group_query
 
-        return render_template('management/user_form.html', form=form, title=_('Edit User'))
+        return render_template(
+            'management/user_form.html', form=form, title=_('Edit User')
+        )
 
     def post(self, user_id):
         user = User.query.filter_by(id=user_id).first_or_404()
 
         member_group = db.and_(
-            *[
+            * [
                 db.not_(getattr(Group, p))
                 for p in ['admin', 'mod', 'super_mod', 'banned', 'guest']
             ]
         )
 
-        filt = db.or_(Group.id.in_(g.id for g in current_user.groups), member_group)
+        filt = db.or_(
+            Group.id.in_(g.id for g in current_user.groups), member_group
+        )
 
         if Permission(IsAtleastSuperModerator, identity=current_user):
             filt = db.or_(filt, Group.mod)
@@ -214,7 +238,9 @@ class EditUser(MethodView):
             flash(_('User updated.'), 'success')
             return redirect(url_for('management.edit_user', user_id=user.id))
 
-        return render_template('management/user_form.html', form=form, title=_('Edit User'))
+        return render_template(
+            'management/user_form.html', form=form, title=_('Edit User')
+        )
 
 
 class DeleteUser(MethodView):
@@ -264,7 +290,9 @@ class AddUser(MethodView):
     form = AddUserForm
 
     def get(self):
-        return render_template('management/user_form.html', form=self.form(), title=_('Add User'))
+        return render_template(
+            'management/user_form.html', form=self.form(), title=_('Add User')
+        )
 
     def post(self):
         form = self.form()
@@ -273,7 +301,9 @@ class AddUser(MethodView):
             flash(_('User added.'), 'success')
             return redirect(url_for('management.users'))
 
-        return render_template('management/user_form.html', form=form, title=_('Add User'))
+        return render_template(
+            'management/user_form.html', form=form, title=_('Add User')
+        )
 
 
 class BannedUsers(MethodView):
@@ -284,30 +314,38 @@ class BannedUsers(MethodView):
         page = request.args.get('page', 1, type=int)
         search_form = self.form()
 
-        users = User.query.filter(Group.banned == True, Group.id == User.primary_group_id
-                                  ).paginate(page, flaskbb_config['USERS_PER_PAGE'], False)
+        users = User.query.filter(
+            Group.banned == True, Group.id == User.primary_group_id
+        ).paginate(page, flaskbb_config['USERS_PER_PAGE'], False)
 
         return render_template(
-            'management/banned_users.html', users=users, search_form=search_form
+            'management/banned_users.html',
+            users=users,
+            search_form=search_form
         )
 
     def post(self):
         page = request.args.get('page', 1, type=int)
         search_form = self.form()
 
-        users = User.query.filter(Group.banned == True, Group.id == User.primary_group_id
-                                  ).paginate(page, flaskbb_config['USERS_PER_PAGE'], False)
+        users = User.query.filter(
+            Group.banned == True, Group.id == User.primary_group_id
+        ).paginate(page, flaskbb_config['USERS_PER_PAGE'], False)
 
         if search_form.validate():
             users = search_form.get_results().\
                 paginate(page, flaskbb_config['USERS_PER_PAGE'], False)
 
             return render_template(
-                'management/banned_users.html', users=users, search_form=search_form
+                'management/banned_users.html',
+                users=users,
+                search_form=search_form
             )
 
         return render_template(
-            'management/banned_users.html', users=users, search_form=search_form
+            'management/banned_users.html',
+            users=users,
+            search_form=search_form
         )
 
 
@@ -316,7 +354,9 @@ class BanUser(MethodView):
 
     def post(self, user_id=None):
         if not Permission(CanBanUser, identity=current_user):
-            flash(_("You do not have the permissions to ban this user."), "danger")
+            flash(
+                _("You do not have the permissions to ban this user."), "danger"
+            )
             return redirect(url_for("management.overview"))
 
         # ajax request
@@ -328,18 +368,24 @@ class BanUser(MethodView):
             for user in users:
                 # don't let a user ban himself and do not allow a moderator to ban
                 # a admin user
-                if (current_user.id == user.id or Permission(IsAdmin, identity=user)
+                if (current_user.id == user.id
+                        or Permission(IsAdmin, identity=user)
                         and Permission(Not(IsAdmin), current_user)):
                     continue
 
                 elif user.ban():
                     data.append(
                         {
-                            "id": user.id,
-                            "type": "ban",
-                            "reverse": "unban",
-                            "reverse_name": _("Unban"),
-                            "reverse_url": url_for("management.unban_user", user_id=user.id)
+                            "id":
+                            user.id,
+                            "type":
+                            "ban",
+                            "reverse":
+                            "unban",
+                            "reverse_name":
+                            _("Unban"),
+                            "reverse_url":
+                            url_for("management.unban_user", user_id=user.id)
                         }
                     )
 
@@ -352,7 +398,8 @@ class BanUser(MethodView):
 
         user = User.query.filter_by(id=user_id).first_or_404()
         # Do not allow moderators to ban admins
-        if Permission(IsAdmin, identity=user) and Permission(Not(IsAdmin), identity=current_user):
+        if Permission(IsAdmin, identity=user) and Permission(
+                Not(IsAdmin), identity=current_user):
             flash(_("A moderator cannot ban an admin user."), "danger")
             return redirect(url_for("management.overview"))
 
@@ -369,7 +416,10 @@ class UnbanUser(MethodView):
     def post(self, user_id=None):
 
         if not Permission(CanBanUser, identity=current_user):
-            flash(_("You do not have the permissions to unban this user."), "danger")
+            flash(
+                _("You do not have the permissions to unban this user."),
+                "danger"
+            )
             return redirect(url_for("management.overview"))
 
         # ajax request
@@ -426,7 +476,9 @@ class AddGroup(MethodView):
 
     def get(self):
         return render_template(
-            'management/group_form.html', form=self.form(), title=_('Add Group')
+            'management/group_form.html',
+            form=self.form(),
+            title=_('Add Group')
         )
 
     def post(self):
@@ -436,7 +488,9 @@ class AddGroup(MethodView):
             flash(_('Group added.'), 'success')
             return redirect(url_for('management.groups'))
 
-        return render_template('management/group_form.html', form=form, title=_('Add Group'))
+        return render_template(
+            'management/group_form.html', form=form, title=_('Add Group')
+        )
 
 
 class EditGroup(MethodView):
@@ -446,7 +500,9 @@ class EditGroup(MethodView):
     def get(self, group_id):
         group = Group.query.filter_by(id=group_id).first_or_404()
         form = self.form(group)
-        return render_template('management/group_form.html', form=form, title=_('Edit Group'))
+        return render_template(
+            'management/group_form.html', form=form, title=_('Edit Group')
+        )
 
     def post(self, group_id):
         group = Group.query.filter_by(id=group_id).first_or_404()
@@ -462,7 +518,9 @@ class EditGroup(MethodView):
             flash(_('Group updated.'), 'success')
             return redirect(url_for('management.groups', group_id=group.id))
 
-        return render_template('management/group_form.html', form=form, title=_('Edit Group'))
+        return render_template(
+            'management/group_form.html', form=form, title=_('Edit Group')
+        )
 
 
 class DeleteGroup(MethodView):
@@ -535,11 +593,15 @@ class EditForum(MethodView):
         form = self.form(forum)
 
         if forum.moderators:
-            form.moderators.data = ','.join([user.username for user in forum.moderators])
+            form.moderators.data = ','.join(
+                [user.username for user in forum.moderators]
+            )
         else:
             form.moderators.data = None
 
-        return render_template('management/forum_form.html', form=form, title=_('Edit Forum'))
+        return render_template(
+            'management/forum_form.html', form=form, title=_('Edit Forum')
+        )
 
     def post(self, forum_id):
         forum = Forum.query.filter_by(id=forum_id).first_or_404()
@@ -551,11 +613,15 @@ class EditForum(MethodView):
             return redirect(url_for('management.edit_forum', forum_id=forum.id))
         else:
             if forum.moderators:
-                form.moderators.data = ','.join([user.username for user in forum.moderators])
+                form.moderators.data = ','.join(
+                    [user.username for user in forum.moderators]
+                )
             else:
                 form.moderators.data = None
 
-        return render_template('management/forum_form.html', form=form, title=_('Edit Forum'))
+        return render_template(
+            'management/forum_form.html', form=form, title=_('Edit Forum')
+        )
 
 
 class AddForum(MethodView):
@@ -571,7 +637,9 @@ class AddForum(MethodView):
             category = Category.query.filter_by(id=category_id).first()
             form.category.data = category
 
-        return render_template('management/forum_form.html', form=form, title=_('Add Forum'))
+        return render_template(
+            'management/forum_form.html', form=form, title=_('Add Forum')
+        )
 
     def post(self, category_id=None):
         form = self.form()
@@ -586,7 +654,9 @@ class AddForum(MethodView):
                 category = Category.query.filter_by(id=category_id).first()
                 form.category.data = category
 
-        return render_template('management/forum_form.html', form=form, title=_('Add Forum'))
+        return render_template(
+            'management/forum_form.html', form=form, title=_('Add Forum')
+        )
 
 
 class DeleteForum(MethodView):
@@ -595,8 +665,9 @@ class DeleteForum(MethodView):
     def post(self, forum_id):
         forum = Forum.query.filter_by(id=forum_id).first_or_404()
 
-        involved_users = User.query.filter(Topic.forum_id == forum.id,
-                                           Post.user_id == User.id).all()
+        involved_users = User.query.filter(
+            Topic.forum_id == forum.id, Post.user_id == User.id
+        ).all()
 
         forum.delete(involved_users)
 
@@ -610,7 +681,9 @@ class AddCategory(MethodView):
 
     def get(self):
         return render_template(
-            'management/category_form.html', form=self.form(), title=_('Add Category')
+            'management/category_form.html',
+            form=self.form(),
+            title=_('Add Category')
         )
 
     def post(self):
@@ -621,7 +694,9 @@ class AddCategory(MethodView):
             flash(_('Category added.'), 'success')
             return redirect(url_for('management.forums'))
 
-        return render_template('management/category_form.html', form=form, title=_('Add Category'))
+        return render_template(
+            'management/category_form.html', form=form, title=_('Add Category')
+        )
 
 
 class EditCategory(MethodView):
@@ -634,7 +709,9 @@ class EditCategory(MethodView):
         form = self.form(obj=category)
 
         return render_template(
-            'management/category_form.html', form=form, title=_('Edit Category')
+            'management/category_form.html',
+            form=form,
+            title=_('Edit Category')
         )
 
     def post(self, category_id):
@@ -648,7 +725,9 @@ class EditCategory(MethodView):
             category.save()
 
         return render_template(
-            'management/category_form.html', form=form, title=_('Edit Category')
+            'management/category_form.html',
+            form=form,
+            title=_('Edit Category')
         )
 
 
@@ -659,7 +738,8 @@ class DeleteCategory(MethodView):
         category = Category.query.filter_by(id=category_id).first_or_404()
 
         involved_users = User.query.filter(
-            Forum.category_id == category.id, Topic.forum_id == Forum.id, Post.user_id == User.id
+            Forum.category_id == category.id, Topic.forum_id == Forum.id,
+            Post.user_id == User.id
         ).all()
 
         category.delete(involved_users)
@@ -727,7 +807,10 @@ class MarkReportRead(MethodView):
         if report_id:
             report = Report.query.filter_by(id=report_id).first_or_404()
             if report.zapped:
-                flash(_("Report %(id)s is already marked as read.", id=report.id), "success")
+                flash(
+                    _("Report %(id)s is already marked as read.", id=report.id),
+                    "success"
+                )
                 return redirect(url_for("management.reports"))
 
             report.zapped_by = current_user.id
@@ -790,10 +873,12 @@ class ManagementOverview(MethodView):
 
     def get(self):
         # user and group stats
-        banned_users = User.query.filter(Group.banned == True,
-                                         Group.id == User.primary_group_id).count()
+        banned_users = User.query.filter(
+            Group.banned == True, Group.id == User.primary_group_id
+        ).count()
         if not current_app.config["REDIS_ENABLED"]:
-            online_users = User.query.filter(User.lastseen >= time_diff()).count()
+            online_users = User.query.filter(User.lastseen >= time_diff()
+                                             ).count()
         else:
             online_users = len(get_online_users())
 
@@ -855,14 +940,21 @@ class EnablePlugin(MethodView):
         plugin = PluginRegistry.query.filter_by(name=name).first_or_404()
 
         if plugin.enabled:
-            flash(_("Plugin %(plugin)s is already enabled.", plugin=plugin.name), "info")
+            flash(
+                _("Plugin %(plugin)s is already enabled.", plugin=plugin.name),
+                "info"
+            )
             return redirect(url_for("management.plugins"))
 
         plugin.enabled = True
         plugin.save()
 
-        flash(_("Plugin %(plugin)s enabled. Please restart FlaskBB now.",
-                plugin=plugin.name), "success")
+        flash(
+            _(
+                "Plugin %(plugin)s enabled. Please restart FlaskBB now.",
+                plugin=plugin.name
+            ), "success"
+        )
         return redirect(url_for("management.plugins"))
 
 
@@ -874,14 +966,20 @@ class DisablePlugin(MethodView):
         plugin = PluginRegistry.query.filter_by(name=name).first_or_404()
 
         if not plugin.enabled:
-            flash(_("Plugin %(plugin)s is already disabled.", plugin=plugin.name),
-                  "info")
+            flash(
+                _("Plugin %(plugin)s is already disabled.", plugin=plugin.name),
+                "info"
+            )
             return redirect(url_for("management.plugins"))
 
         plugin.enabled = False
         plugin.save()
-        flash(_("Plugin %(plugin)s disabled. Please restart FlaskBB now.",
-                plugin=plugin.name), "success")
+        flash(
+            _(
+                "Plugin %(plugin)s disabled. Please restart FlaskBB now.",
+                plugin=plugin.name
+            ), "success"
+        )
         return redirect(url_for("management.plugins"))
 
 
@@ -905,8 +1003,12 @@ class InstallPlugin(MethodView):
         plugin = PluginRegistry.query.filter_by(name=name).first_or_404()
 
         if not plugin.enabled:
-            flash(_("Can't install plugin. Enable '%(plugin)s' plugin first.",
-                    plugin=plugin.name), "danger")
+            flash(
+                _(
+                    "Can't install plugin. Enable '%(plugin)s' plugin first.",
+                    plugin=plugin.name
+                ), "danger"
+            )
             return redirect(url_for("management.plugins"))
 
         plugin.add_settings(plugin_module.SETTINGS)
@@ -914,161 +1016,171 @@ class InstallPlugin(MethodView):
         return redirect(url_for("management.plugins"))
 
 
-# Categories
-register_view(
-    management,
-    routes=['/category/add'],
-    view_func=AddCategory.as_view('add_category')
-)
-register_view(
-    management,
-    routes=["/category/<int:category_id>/delete"],
-    view_func=DeleteCategory.as_view('delete_category')
-)
-register_view(
-    management,
-    routes=['/category/<int:category_id>/edit'],
-    view_func=EditCategory.as_view('edit_category')
-)
-
-# Forums
-register_view(
-    management,
-    routes=['/forums/add', '/forums/<int:category_id>/add'],
-    view_func=AddForum.as_view('add_forum')
-)
-register_view(
-    management,
-    routes=['/forums/<int:forum_id>/delete'],
-    view_func=DeleteForum.as_view('delete_forum')
-)
-register_view(
-    management,
-    routes=['/forums/<int:forum_id>/edit'],
-    view_func=EditForum.as_view('edit_forum')
-)
-register_view(
-    management,
-    routes=['/forums'],
-    view_func=Forums.as_view('forums')
-)
-
-# Groups
-register_view(
-    management,
-    routes=['/groups/add'],
-    view_func=AddGroup.as_view('add_group')
-)
-register_view(
-    management,
-    routes=['/groups/<int:group_id>/delete', '/groups/delete'],
-    view_func=DeleteGroup.as_view('delete_group')
-)
-register_view(
-    management,
-    routes=['/groups/<int:group_id>/edit'],
-    view_func=EditGroup.as_view('edit_group')
-)
-register_view(
-    management,
-    routes=['/groups'],
-    view_func=Groups.as_view('groups')
-)
-
-# Plugins
-register_view(
-    management,
-    routes=['/plugins/<path:name>/disable'],
-    view_func=DisablePlugin.as_view('disable_plugin')
-)
-register_view(
-    management,
-    routes=['/plugins/<path:name>/enable'],
-    view_func=EnablePlugin.as_view('enable_plugin')
-)
-register_view(
-    management,
-    routes=['/plugins/<path:name>/install'],
-    view_func=InstallPlugin.as_view('install_plugin')
-)
-register_view(
-    management,
-    routes=['/plugins/<path:name>/uninstall'],
-    view_func=UninstallPlugin.as_view('uninstall_plugin')
-)
-register_view(
-    management,
-    routes=['/plugins'],
-    view_func=PluginsView.as_view('plugins')
-)
-
-# Reports
-register_view(
-    management,
-    routes=['/reports/<int:report_id>/delete', '/reports/delete'],
-    view_func=DeleteReport.as_view('delete_report')
-)
-register_view(
-    management,
-    routes=['/reports/<int:report_id>/markread', '/reports/markread'],
-    view_func=MarkReportRead.as_view('report_markread')
-)
-register_view(
-    management,
-    routes=['/reports/unread'],
-    view_func=UnreadReports.as_view('unread_reports')
-)
-register_view(
-    management,
-    routes=['/reports'],
-    view_func=Reports.as_view('reports')
-)
-
-# Settings
-register_view(
-    management,
-    routes=['/settings', '/settings/<path:slug>', '/settings/plugin/<path:plugin>'],
-    view_func=ManagementSettings.as_view('settings')
-)
-
-# Users
-register_view(
-    management,
-    routes=['/users/add'],
-    view_func=AddUser.as_view('add_user')
-)
-register_view(
-    management,
-    routes=['/users/banned'],
-    view_func=BannedUsers.as_view('banned_users')
-)
-register_view(
-    management,
-    routes=['/users/ban', '/users/<int:user_id>/ban'],
-    view_func=BanUser.as_view('ban_user')
-)
-register_view(
-    management,
-    routes=['/users/delete', '/users/<int:user_id>/delete'],
-    view_func=DeleteUser.as_view('delete_user')
-)
-register_view(
-    management,
-    routes=['/users/<int:user_id>/edit'],
-    view_func=EditUser.as_view('edit_user')
-)
-register_view(
-    management,
-    routes=['/users/unban', '/users/<int:user_id>/unban'],
-    view_func=UnbanUser.as_view('unban_user')
-)
-register_view(
-    management,
-    routes=['/users'],
-    view_func=ManageUsers.as_view('users')
-)
-register_view(
-    management,
-    routes=['/'],
-    view_func=ManagementOverview.as_view('overview')
-)
+@impl(tryfirst=True)
+def flaskbb_load_blueprints(app):
+    management = Blueprint("management", __name__)
+
+    @management.before_request
+    def check_fresh_login():
+        """Checks if the login is fresh for the current user, otherwise the user
+        has to reauthenticate."""
+        if not login_fresh():
+            return current_app.login_manager.needs_refresh()
+
+    # Categories
+    register_view(
+        management,
+        routes=['/category/add'],
+        view_func=AddCategory.as_view('add_category')
+    )
+    register_view(
+        management,
+        routes=["/category/<int:category_id>/delete"],
+        view_func=DeleteCategory.as_view('delete_category')
+    )
+    register_view(
+        management,
+        routes=['/category/<int:category_id>/edit'],
+        view_func=EditCategory.as_view('edit_category')
+    )
+
+    # Forums
+    register_view(
+        management,
+        routes=['/forums/add', '/forums/<int:category_id>/add'],
+        view_func=AddForum.as_view('add_forum')
+    )
+    register_view(
+        management,
+        routes=['/forums/<int:forum_id>/delete'],
+        view_func=DeleteForum.as_view('delete_forum')
+    )
+    register_view(
+        management,
+        routes=['/forums/<int:forum_id>/edit'],
+        view_func=EditForum.as_view('edit_forum')
+    )
+    register_view(
+        management, routes=['/forums'], view_func=Forums.as_view('forums')
+    )
+
+    # Groups
+    register_view(
+        management,
+        routes=['/groups/add'],
+        view_func=AddGroup.as_view('add_group')
+    )
+    register_view(
+        management,
+        routes=['/groups/<int:group_id>/delete', '/groups/delete'],
+        view_func=DeleteGroup.as_view('delete_group')
+    )
+    register_view(
+        management,
+        routes=['/groups/<int:group_id>/edit'],
+        view_func=EditGroup.as_view('edit_group')
+    )
+    register_view(
+        management, routes=['/groups'], view_func=Groups.as_view('groups')
+    )
+
+    # Plugins
+    register_view(
+        management,
+        routes=['/plugins/<path:name>/disable'],
+        view_func=DisablePlugin.as_view('disable_plugin')
+    )
+    register_view(
+        management,
+        routes=['/plugins/<path:name>/enable'],
+        view_func=EnablePlugin.as_view('enable_plugin')
+    )
+    register_view(
+        management,
+        routes=['/plugins/<path:name>/install'],
+        view_func=InstallPlugin.as_view('install_plugin')
+    )
+    register_view(
+        management,
+        routes=['/plugins/<path:name>/uninstall'],
+        view_func=UninstallPlugin.as_view('uninstall_plugin')
+    )
+    register_view(
+        management,
+        routes=['/plugins'],
+        view_func=PluginsView.as_view('plugins')
+    )
+
+    # Reports
+    register_view(
+        management,
+        routes=['/reports/<int:report_id>/delete', '/reports/delete'],
+        view_func=DeleteReport.as_view('delete_report')
+    )
+    register_view(
+        management,
+        routes=['/reports/<int:report_id>/markread', '/reports/markread'],
+        view_func=MarkReportRead.as_view('report_markread')
+    )
+    register_view(
+        management,
+        routes=['/reports/unread'],
+        view_func=UnreadReports.as_view('unread_reports')
+    )
+    register_view(
+        management, routes=['/reports'], view_func=Reports.as_view('reports')
+    )
+
+    # Settings
+    register_view(
+        management,
+        routes=[
+            '/settings', '/settings/<path:slug>',
+            '/settings/plugin/<path:plugin>'
+        ],
+        view_func=ManagementSettings.as_view('settings')
+    )
+
+    # Users
+    register_view(
+        management,
+        routes=['/users/add'],
+        view_func=AddUser.as_view('add_user')
+    )
+    register_view(
+        management,
+        routes=['/users/banned'],
+        view_func=BannedUsers.as_view('banned_users')
+    )
+    register_view(
+        management,
+        routes=['/users/ban', '/users/<int:user_id>/ban'],
+        view_func=BanUser.as_view('ban_user')
+    )
+    register_view(
+        management,
+        routes=['/users/delete', '/users/<int:user_id>/delete'],
+        view_func=DeleteUser.as_view('delete_user')
+    )
+    register_view(
+        management,
+        routes=['/users/<int:user_id>/edit'],
+        view_func=EditUser.as_view('edit_user')
+    )
+    register_view(
+        management,
+        routes=['/users/unban', '/users/<int:user_id>/unban'],
+        view_func=UnbanUser.as_view('unban_user')
+    )
+    register_view(
+        management, routes=['/users'], view_func=ManageUsers.as_view('users')
+    )
+    register_view(
+        management,
+        routes=['/'],
+        view_func=ManagementOverview.as_view('overview')
+    )
+
+    app.register_blueprint(
+        management, url_prefix=app.config["ADMIN_URL_PREFIX"]
+    )

+ 5 - 0
flaskbb/plugins/spec.py

@@ -142,6 +142,11 @@ def flaskbb_event_topic_save_after(topic, is_new):
     """
 
 
+@spec
+def flaskbb_user_registered(username):
+    """Hook for handling events after a user is registered"""
+
+
 # Form hooks
 @spec
 def flaskbb_form_new_post(form):

+ 10 - 0
flaskbb/tokens/__init__.py

@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.tokens
+    ~~~~~~~~~~~~~~
+
+    :copyright: 2014-2018 the FlaskBB Team
+    :license: BSD, see LICENSE for more details
+"""
+
+from .serializer import FlaskBBTokenSerializer

+ 81 - 0
flaskbb/tokens/serializer.py

@@ -0,0 +1,81 @@
+# -*- coding: utf -*-
+"""
+    flaskbb.tokens.serializer
+    ~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    :copyright: (c) 2018 the FlaskBB Team.
+    :license: BSD, see LICENSE for more details
+"""
+
+from datetime import timedelta
+
+from itsdangerous import (BadData, BadSignature, SignatureExpired,
+                          TimedJSONWebSignatureSerializer)
+
+from ..core import tokens
+
+
+class FlaskBBTokenSerializer(tokens.TokenSerializer):
+    """
+    Default token serializer for FlaskBB. Generates JWTs
+    that are time sensitive. By default they will expire after
+    1 hour.
+
+    It creates tokens from flaskbb.core.tokens.Token instances
+    and creates instances of that class when loading tokens.
+
+    When loading a token, if an error occurs related to the
+    token itself, a flaskbb.core.tokens.TokenError will be
+    raised. Exceptions not caused by parsing the token
+    are simply propagated.
+
+    :str secret_key: The secret key used to sign the tokens
+    :timedelta expiry: Expiration of tokens
+    """
+
+    def __init__(self, secret_key, expiry=timedelta(hours=1)):
+        self._serializer = TimedJSONWebSignatureSerializer(
+            secret_key, int(expiry.total_seconds())
+        )
+
+    def dumps(self, token):
+        """
+        Transforms an instance of flaskbb.core.tokens.Token into
+        a text serialized JWT.
+
+        :flaskbb.core.tokens.Token token: Token to transformed into a JWT
+        :returns str: A fully serialized token
+        """
+        return self._serializer.dumps(
+            {
+                'id': token.user_id,
+                'op': token.operation,
+            }
+        )
+
+    def loads(self, raw_token):
+        """
+        Transforms a JWT into a flaskbb.core.tokens.Token.
+
+        If a token is invalid due to it being malformed,
+        tampered with or expired, a flaskbb.core.tokens.TokenError
+        is raised. Errors not related to token parsing are
+        simply propagated.
+
+        :str raw_token: JWT to be parsed
+        :returns flaskbb.core.tokens.Token: Parsed token
+        """
+        try:
+            parsed = self._serializer.loads(raw_token)
+        except SignatureExpired:
+            raise tokens.TokenError.expired()
+        except BadSignature:  # pragma: no branch
+            raise tokens.TokenError.invalid()
+        # ideally we never end up here as BadSignature should
+        # catch everything else, however since this is the root
+        # exception for itsdangerous we'll catch it down and
+        # and re-raise our own
+        except BadData:  # pragma: no cover
+            raise tokens.TokenError.bad()
+        else:
+            return tokens.Token(user_id=parsed['id'], operation=parsed['op'])

+ 28 - 0
flaskbb/tokens/verifiers.py

@@ -0,0 +1,28 @@
+# -*- utf-8 -*-
+"""
+    flaskbb.tokens.verifiers
+    ~~~~~~~~~~~~~~~~~~~~~~~~
+    Token verifier implementations
+
+    :copyright: (c) 2014-2018 the FlaskBB Team
+    :license: BSD, see LICENSE for more details
+"""
+
+from ..core.tokens import TokenVerifier
+from ..core.exceptions import ValidationError
+
+
+class EmailMatchesUserToken(TokenVerifier):
+    """
+    Ensures that the token submitted for use matches
+    the email entered by the user.
+
+    :param User: User model for querying against
+    """
+    def __init__(self, users):
+        self.users = users
+
+    def verify_token(self, token, email, **kwargs):
+        user = self.users.query.get(token.user_id)
+        if user.email.lower() != email.lower():
+            raise ValidationError("email", "Wrong email")

+ 32 - 0
flaskbb/user/repo.py

@@ -0,0 +1,32 @@
+from datetime import datetime
+
+from pytz import UTC
+
+from ..core.user.repo import UserRepository as BaseUserRepository
+from .models import User
+
+
+class UserRepository(BaseUserRepository):
+
+    def __init__(self, db):
+        self.db = db
+
+    def add(self, user_info):
+        user = User(
+            username=user_info.username,
+            email=user_info.email,
+            password=user_info.password,
+            language=user_info.language,
+            primary_group_id=user_info.group,
+            date_joined=datetime.now(UTC)
+        )
+        self.db.session.add(user)
+
+    def get(self, user_id):
+        return User.query.get(user_id)
+
+    def find_by(self, **kwargs):
+        return User.query.filter_by(**kwargs).all()
+
+    def find_one_by(self, **kwargs):
+        return User.query.filter_by(**kwargs).first()

+ 49 - 20
flaskbb/user/views.py

@@ -10,10 +10,12 @@
     :license: BSD, see LICENSE for more details.
 """
 import logging
+
 from flask import Blueprint, flash, request
 from flask.views import MethodView
 from flask_babelplus import gettext as _
 from flask_login import current_user, login_required
+from pluggy import HookimplMarker
 
 from flaskbb.user.forms import (ChangeEmailForm, ChangePasswordForm,
                                 ChangeUserDetailsForm, GeneralSettingsForm)
@@ -22,10 +24,9 @@ from flaskbb.utils.helpers import (get_available_languages,
                                    get_available_themes, register_view,
                                    render_template)
 
-logger = logging.getLogger(__name__)
-
+impl = HookimplMarker('flaskbb')
 
-user = Blueprint("user", __name__)
+logger = logging.getLogger(__name__)
 
 
 class UserSettings(MethodView):
@@ -85,7 +86,9 @@ class ChangeEmail(MethodView):
     form = ChangeEmailForm
 
     def get(self):
-        return render_template("user/change_email.html", form=self.form(current_user))
+        return render_template(
+            "user/change_email.html", form=self.form(current_user)
+        )
 
     def post(self):
         form = self.form(current_user)
@@ -102,7 +105,9 @@ class ChangeUserDetails(MethodView):
     form = ChangeUserDetailsForm
 
     def get(self):
-        return render_template("user/change_user_details.html", form=self.form(obj=current_user))
+        return render_template(
+            "user/change_user_details.html", form=self.form(obj=current_user)
+        )
 
     def post(self):
         form = self.form(obj=current_user)
@@ -141,18 +146,42 @@ class UserProfile(MethodView):
         return render_template("user/profile.html", user=user)
 
 
-register_view(user, routes=['/settings/email'], view_func=ChangeEmail.as_view('change_email'))
-register_view(user, routes=['/settings/general'], view_func=UserSettings.as_view('settings'))
-register_view(
-    user, routes=['/settings/password'], view_func=ChangePassword.as_view('change_password')
-)
-register_view(
-    user,
-    routes=["/settings/user-details"],
-    view_func=ChangeUserDetails.as_view('change_user_details')
-)
-register_view(user, routes=['/<username>/posts'], view_func=AllUserPosts.as_view('view_all_posts'))
-register_view(
-    user, routes=['/<username>/topics'], view_func=AllUserTopics.as_view('view_all_topics')
-)
-register_view(user, routes=['/<username>'], view_func=UserProfile.as_view('profile'))
+@impl(tryfirst=True)
+def flaskbb_load_blueprints(app):
+    user = Blueprint("user", __name__)
+    register_view(
+        user,
+        routes=['/settings/email'],
+        view_func=ChangeEmail.as_view('change_email')
+    )
+    register_view(
+        user,
+        routes=['/settings/general'],
+        view_func=UserSettings.as_view('settings')
+    )
+    register_view(
+        user,
+        routes=['/settings/password'],
+        view_func=ChangePassword.as_view('change_password')
+    )
+    register_view(
+        user,
+        routes=["/settings/user-details"],
+        view_func=ChangeUserDetails.as_view('change_user_details')
+    )
+    register_view(
+        user,
+        routes=['/<username>/posts'],
+        view_func=AllUserPosts.as_view('view_all_posts')
+    )
+    register_view(
+        user,
+        routes=['/<username>/topics'],
+        view_func=AllUserTopics.as_view('view_all_topics')
+    )
+
+    register_view(
+        user, routes=['/<username>'], view_func=UserProfile.as_view('profile')
+    )
+
+    app.register_blueprint(user, url_prefix=app.config["USER_URL_PREFIX"])

+ 10 - 1
flaskbb/utils/forms.py

@@ -15,6 +15,15 @@ from flaskbb._compat import text_type, iteritems
 from enum import Enum
 
 
+class FlaskBBForm(FlaskForm):
+    def populate_errors(self, errors):
+        for (attribute, reason) in errors:
+            self.errors.setdefault(attribute, []).append(reason)
+            field = getattr(self, attribute, None)
+            if field:
+                field.errors.append(reason)
+
+
 class SettingValueType(Enum):
     string = 0
     integer = 1
@@ -52,7 +61,7 @@ def populate_settings_form(form, settings):
 def generate_settings_form(settings):
     """Generates a settings form which includes field validation
     based on our Setting Schema."""
-    class SettingsForm(FlaskForm):
+    class SettingsForm(FlaskBBForm):
         pass
 
     # now parse the settings in this group

+ 1 - 0
requirements-cov.txt

@@ -0,0 +1 @@
+coverage==4.5.1

+ 2 - 1
requirements-dev.txt

@@ -1,4 +1,4 @@
--r requirements.txt
+-rrequirements.txt
 cov-core
 coverage
 py
@@ -7,3 +7,4 @@ pytest-cov
 Sphinx
 alabaster
 flake8
+tox==2.9.0

+ 7 - 0
requirements-test.txt

@@ -0,0 +1,7 @@
+-rrequirements.txt
+-rrequirements-cov.txt
+flake8==3.5.0
+pytest==3.4.2
+pytest-flake8==0.9.1
+pytest-mock==1.7.1
+freezegun==0.3.10

+ 4 - 0
requirements-travis.txt

@@ -0,0 +1,4 @@
+-r requirements-cov.txt
+-r requirements.txt
+tox-travis==0.10
+coveralls==1.3.0

+ 1 - 0
requirements.txt

@@ -1,5 +1,6 @@
 alembic==0.9.8
 amqp==2.1.4
+attrs==17.4.0
 Babel==2.5.3
 billiard==3.5.0.2
 blinker==1.4

+ 0 - 27
setup.cfg

@@ -1,29 +1,2 @@
-[tox]
-envlist = py27,py36
-
-[testenv]
-deps = -rrequirements-dev.txt
-whitelist_externals = make
-commands = py.test
-
-[tool:pytest]
-norecursedirs = docs flaskbb logs migrations whoosh_index
-addopts =  -vvl --strict --cov=flaskbb --cov-report=term-missing tests
-
-[flake8]
-ignore = E712, E711, C901
-exclude =
-    .git,
-    __pycache__,
-    docs/source/conf.py,
-    migrations,
-    flaskbb/configs,
-    flaskbb/fixtures,
-    flaskbb/_compat.py,
-    build,
-    dist,
-    *.egg-info
-max-complexity = 10
-
 [bdist_wheel]
 universal=1

+ 66 - 0
tests/core/auth/test_password.py

@@ -0,0 +1,66 @@
+import json
+
+import pytest
+from flaskbb.core.auth import password
+from flaskbb.core.exceptions import StopValidation, ValidationError
+from flaskbb.core.tokens import Token, TokenActions, TokenError
+from flaskbb.user.models import User
+from werkzeug.security import check_password_hash
+
+
+class SimpleTokenSerializer:
+
+    @staticmethod
+    def dumps(token):
+        return json.dumps({'user_id': token.user_id, 'op': token.operation})
+
+    @staticmethod
+    def loads(raw_token):
+        loaded = json.loads(raw_token)
+        return Token(user_id=loaded['user_id'], operation=loaded['op'])
+
+
+class TestPasswordReset(object):
+
+    def test_raises_token_error_if_not_a_password_reset(self):
+        service = password.ResetPasswordService(
+            SimpleTokenSerializer, User, []
+        )
+        raw_token = SimpleTokenSerializer.dumps(
+            Token(user_id=1, operation=TokenActions.ACTIVATE_ACCOUNT)
+        )
+
+        with pytest.raises(TokenError) as excinfo:
+            service.reset_password(
+                raw_token, "some@e.mail", "a great password!"
+            )
+
+        assert "invalid" in str(excinfo.value)
+
+    def test_raises_StopValidation_if_verifiers_fail(self):
+        token = SimpleTokenSerializer.dumps(
+            Token(user_id=1, operation=TokenActions.RESET_PASSWORD)
+        )
+
+        def verifier(*a, **k):
+            raise ValidationError('attr', 'no')
+
+        service = password.ResetPasswordService(
+            SimpleTokenSerializer, User, [verifier]
+        )
+
+        with pytest.raises(StopValidation) as excinfo:
+            service.reset_password(token, "an@e.mail", "great password!")
+        assert ("attr", "no") in excinfo.value.reasons
+
+    def test_sets_user_password_to_provided_if_verifiers_pass(self, Fred):
+        token = SimpleTokenSerializer.dumps(
+            Token(user_id=Fred.id, operation=TokenActions.RESET_PASSWORD)
+        )
+
+        service = password.ResetPasswordService(
+            SimpleTokenSerializer, User, []
+        )
+
+        service.reset_password(token, Fred.email, "newpasswordwhodis")
+        assert check_password_hash(Fred.password, "newpasswordwhodis")

+ 69 - 0
tests/core/auth/test_registration.py

@@ -0,0 +1,69 @@
+import pytest
+from flaskbb.core.auth import registration
+from flaskbb.core.exceptions import StopValidation, ValidationError
+from flaskbb.core.user.repo import UserRepository
+
+
+class RaisingValidator(registration.UserValidator):
+
+    def validate(self, user_info):
+        raise ValidationError(
+            'test', 'just a little whoopsie-diddle'
+        )
+
+
+def test_doesnt_register_user_if_validator_fails_with_ValidationError(
+        mocker
+):
+    repo = mocker.Mock(UserRepository)
+    service = registration.RegistrationService([RaisingValidator()], repo)
+
+    with pytest.raises(StopValidation):
+        service.register(
+            registration.UserRegistrationInfo(
+                username='fred',
+                password='lol',
+                email='fred@fred.fred',
+                language='fredspeak',
+                group=4
+            )
+        )
+
+    repo.add.assert_not_called()
+
+
+def test_gathers_up_all_errors_during_registration(mocker):
+    repo = mocker.Mock(UserRepository)
+    service = registration.RegistrationService([
+        RaisingValidator(), RaisingValidator()
+    ], repo)
+
+    with pytest.raises(StopValidation) as excinfo:
+        service.register(
+            registration.UserRegistrationInfo(
+                username='fred',
+                password='lol',
+                email='fred@fred.fred',
+                language='fredspeak',
+                group=4
+            )
+        )
+
+    repo.add.assert_not_called()
+    assert len(excinfo.value.reasons) == 2
+    assert all(('test', 'just a little whoopsie-diddle') == r
+               for r in excinfo.value.reasons)
+
+
+def test_registers_user_if_no_errors_occurs(mocker):
+    repo = mocker.Mock(UserRepository)
+    service = registration.RegistrationService([], repo)
+    user_info = registration.UserRegistrationInfo(
+        username='fred',
+        password='lol',
+        email='fred@fred.fred',
+        language='fredspeak',
+        group=4
+    )
+    service.register(user_info)
+    repo.add.assert_called_with(user_info)

+ 88 - 0
tests/unit/auth/test_validators.py

@@ -0,0 +1,88 @@
+import pytest
+from flaskbb.auth.services.registration import (EmailUniquenessValidator,
+                                                UsernameRequirements,
+                                                UsernameUniquenessValidator,
+                                                UsernameValidator)
+from flaskbb.core.auth.registration import UserRegistrationInfo
+from flaskbb.core.exceptions import ValidationError
+from flaskbb.user.models import User
+
+
+def test_raises_if_username_too_short():
+    requirements = UsernameRequirements(min=4, max=100, blacklist=set())
+    validator = UsernameValidator(requirements)
+
+    registration = UserRegistrationInfo(
+        username='no', password='no', email='no@no.no', group=4, language='no'
+    )
+
+    with pytest.raises(ValidationError) as excinfo:
+        validator(registration)
+
+    assert excinfo.value.attribute == 'username'
+    assert 'must be between' in excinfo.value.reason
+
+
+def test_raises_if_username_too_long():
+    requirements = UsernameRequirements(min=0, max=1, blacklist=set())
+    validator = UsernameValidator(requirements)
+
+    registration = UserRegistrationInfo(
+        username='no', password='no', email='no@no.no', group=4, language='no'
+    )
+
+    with pytest.raises(ValidationError) as excinfo:
+        validator(registration)
+
+    assert excinfo.value.attribute == 'username'
+    assert 'must be between' in excinfo.value.reason
+
+
+def test_raises_if_username_in_blacklist():
+    requirements = UsernameRequirements(min=1, max=100, blacklist=set(['no']))
+    validator = UsernameValidator(requirements)
+
+    registration = UserRegistrationInfo(
+        username='no', password='no', email='no@no.no', group=4, language='no'
+    )
+
+    with pytest.raises(ValidationError) as excinfo:
+        validator(registration)
+
+    assert excinfo.value.attribute == 'username'
+    assert 'forbidden username' in excinfo.value.reason
+
+
+# fred's back. :(
+def test_raises_if_user_already_registered(Fred):
+    validator = UsernameUniquenessValidator(User)
+    registration = UserRegistrationInfo(
+        username='fred',
+        email='fred@fred.fred',
+        language='fred',
+        group=4,
+        password='fred'
+    )
+
+    with pytest.raises(ValidationError) as excinfo:
+        validator(registration)
+
+    assert excinfo.value.attribute == 'username'
+    assert 'already registered' in excinfo.value.reason
+
+
+def test_raises_if_user_email_already_registered(Fred):
+    validator = EmailUniquenessValidator(User)
+    registration = UserRegistrationInfo(
+        username='fred',
+        email='fred@fred.fred',
+        language='fred',
+        group=4,
+        password='fred'
+    )
+
+    with pytest.raises(ValidationError) as excinfo:
+        validator(registration)
+
+    assert excinfo.value.attribute == 'email'
+    assert 'already registered' in excinfo.value.reason

+ 10 - 2
tests/unit/test_forum_models.py

@@ -630,7 +630,11 @@ def test_retrieving_hidden_posts(topic, user):
     assert Post.query.get(new_post.id) is None
     assert Post.query.get(new_post.id, include_hidden=True) == new_post
     assert Post.query.filter(Post.id == new_post.id).first() is None
-    assert Post.query.with_hidden().filter(Post.id == new_post.id).first() == new_post
+    hidden_post = Post.query\
+        .with_hidden()\
+        .filter(Post.id == new_post.id)\
+        .first()
+    assert hidden_post == new_post
 
 
 def test_retrieving_hidden_topics(topic, user):
@@ -639,4 +643,8 @@ def test_retrieving_hidden_topics(topic, user):
     assert Topic.query.get(topic.id) is None
     assert Topic.query.get(topic.id, include_hidden=True) == topic
     assert Topic.query.filter(Topic.id == topic.id).first() is None
-    assert Topic.query.with_hidden().filter(Topic.id == topic.id).first() == topic
+    hidden_topic = Topic.query\
+        .with_hidden()\
+        .filter(Topic.id == topic.id)\
+        .first()
+    assert hidden_topic == topic

+ 7 - 3
tests/unit/test_hideable_query.py

@@ -1,8 +1,10 @@
 from flask_login import login_user
+
 from flaskbb.forum.models import Topic
 
 
-def test_guest_user_cannot_see_hidden_posts(guest, topic, user, request_context):
+def test_guest_user_cannot_see_hidden_posts(guest, topic, user,
+                                            request_context):
     topic.hide(user)
     login_user(guest)
     assert Topic.query.filter(Topic.id == topic.id).first() is None
@@ -14,13 +16,15 @@ def test_regular_user_cannot_see_hidden_posts(topic, user, request_context):
     assert Topic.query.filter(Topic.id == topic.id).first() is None
 
 
-def test_moderator_user_can_see_hidden_posts(topic, moderator_user, request_context):
+def test_moderator_user_can_see_hidden_posts(topic, moderator_user,
+                                             request_context):
     topic.hide(moderator_user)
     login_user(moderator_user)
     assert Topic.query.filter(Topic.id == topic.id).first() is not None
 
 
-def test_super_moderator_user_can_see_hidden_posts(topic, super_moderator_user, request_context):
+def test_super_moderator_user_can_see_hidden_posts(topic, super_moderator_user,
+                                                   request_context):
     topic.hide(super_moderator_user)
     login_user(super_moderator_user)
     assert Topic.query.filter(Topic.id == topic.id).first() is not None

+ 0 - 1
tests/unit/test_posts.py

@@ -1 +0,0 @@
-

+ 8 - 4
tests/unit/test_requirements.py

@@ -60,7 +60,8 @@ def test_CanEditTopic_with_member(user, topic, request_context):
     assert r.CanEditPost(user, request)
 
 
-def test_Fred_cannot_edit_other_members_post(user, Fred, topic, request_context):
+def test_Fred_cannot_edit_other_members_post(user, Fred, topic,
+                                             request_context):
     push_onto_request_context(topic=topic)
     assert not r.CanEditPost(Fred, request)
 
@@ -70,12 +71,14 @@ def test_Fred_CannotEditLockedTopic(Fred, topic_locked, request_context):
     assert not r.CanEditPost(Fred, request)
 
 
-def test_Moderator_in_Forum_CanEditLockedTopic(moderator_user, topic_locked, request_context):
+def test_Moderator_in_Forum_CanEditLockedTopic(moderator_user, topic_locked,
+                                               request_context):
     push_onto_request_context(topic=topic_locked)
     assert r.CanEditPost(moderator_user, request)
 
 
-def test_FredIsAMod_but_still_cant_edit_topic_in_locked_forum(Fred, topic_locked, default_groups, request_context):
+def test_FredIsAMod_but_still_cant_edit_topic_in_locked_forum(
+        Fred, topic_locked, default_groups, request_context):
 
     Fred.primary_group = default_groups[2]
 
@@ -83,7 +86,8 @@ def test_FredIsAMod_but_still_cant_edit_topic_in_locked_forum(Fred, topic_locked
     assert not r.CanEditPost(Fred, request)
 
 
-def test_Fred_cannot_reply_to_locked_topic(Fred, topic_locked, request_context):
+def test_Fred_cannot_reply_to_locked_topic(Fred, topic_locked,
+                                           request_context):
     push_onto_request_context(topic=topic_locked)
     assert not r.CanPostReply(Fred, request)
 

+ 42 - 0
tests/unit/tokens/test_serializer.py

@@ -0,0 +1,42 @@
+from datetime import datetime, timedelta
+
+import pytest
+from freezegun import freeze_time
+
+from flaskbb import tokens
+from flaskbb.core.tokens import Token, TokenActions, TokenError
+
+
+def test_can_round_trip_token():
+    serializer = tokens.FlaskBBTokenSerializer(
+        'hello i am secret', timedelta(seconds=100)
+    )
+    token = Token(user_id=1, operation=TokenActions.RESET_PASSWORD)
+    roundtrip = serializer.loads(serializer.dumps(token))
+
+    assert token == roundtrip
+
+
+def test_raises_token_error_with_bad_data():
+    serializer = tokens.FlaskBBTokenSerializer(
+        'hello i am also secret', timedelta(seconds=100)
+    )
+
+    with pytest.raises(TokenError) as excinfo:
+        serializer.loads('not actually a token')
+    assert 'invalid' in str(excinfo.value)
+
+
+def test_expired_token_raises():
+    serializer = tokens.FlaskBBTokenSerializer(
+        'i am a secret not', expiry=timedelta(seconds=1)
+    )
+    dumped_token = serializer.dumps(
+        Token(user_id=1, operation=TokenActions.RESET_PASSWORD)
+    )
+
+    with freeze_time(datetime.now() + timedelta(days=10)):
+        with pytest.raises(TokenError) as excinfo:
+            serializer.loads(dumped_token)
+
+    assert 'expired' in str(excinfo.value)

+ 23 - 0
tests/unit/tokens/test_verifiers.py

@@ -0,0 +1,23 @@
+import pytest
+
+from flaskbb.core.tokens import Token, TokenActions
+from flaskbb.core.exceptions import ValidationError
+from flaskbb.tokens import verifiers
+from flaskbb.user.models import User
+
+
+def test_raises_if_email_doesnt_match_token_user(Fred):
+    verifier = verifiers.EmailMatchesUserToken(User)
+    token = Token(user_id=1, operation=TokenActions.RESET_PASSWORD)
+
+    with pytest.raises(ValidationError) as excinfo:
+        verifier(token, email="not really")
+
+    assert excinfo.value.attribute == "email"
+    assert excinfo.value.reason == "Wrong email"
+
+
+def test_doesnt_raise_if_email_matches_token_user(Fred):
+    verifier = verifiers.EmailMatchesUserToken(User)
+    token = Token(user_id=Fred.id, operation=TokenActions.ACTIVATE_ACCOUNT)
+    verifier(token, email=Fred.email)

+ 0 - 1
tests/unit/utils/test_translations.py

@@ -1,6 +1,5 @@
 from flask import current_app
 from babel.support import Translations
-from flaskbb.utils.translations import FlaskBBDomain
 
 
 def test_flaskbbdomain_translations(default_settings):

+ 41 - 0
tox.ini

@@ -0,0 +1,41 @@
+[tox]
+envlist = py27,py34,py35,py36,cov-report,cov-store
+
+[testenv]
+use_develop = true
+deps =
+    py27: mock==2.0.0
+    -r{toxinidir}/requirements-test.txt
+setenv =
+    COVERAGE_FILE = tests/.coverage.{envname}
+    PYTHONDONTWRITEBYTECODE=1
+commands =
+    coverage run -m pytest {toxinidir}/tests
+
+[testenv:cov-report]
+skip_install = true
+setenv =
+    COVERAGE_FILE = tests/.coverage
+deps =
+    -r{toxinidir}/requirements-cov.txt
+commands =
+    coverage combine tests
+    coverage report
+
+[testenv:cov-store]
+skip_install = true
+setenv =
+    COVERAGE_FILE = tests/.coverage
+deps =
+    -r{toxinidir}/requirements-cov.txt
+commands =
+    coverage html
+
+
+[flake8]
+ignore = E712, E711, C901
+max-complexity = 10
+
+[pytest]
+addopts =  -vvl --strict --flake8 --capture fd
+