Browse Source

Extract some logic into stand alone services

* Implements reset password and registration into stand alone services
* Moves tokening into its own services
* Adds basic user repository interface (this is provisional)
* Adds support for tox
Alec Nikolas Reiter 7 years ago
parent
commit
946bc5fd09

+ 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

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

+ 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


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

@@ -0,0 +1,77 @@
+#  -*- 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 UserRegistrationError, UserValidator
+from ...core.tokens import TokenVerifier, TokenVerificationError
+
+__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 UserRegistrationError(
+                'username',
+                'Username must be between {} and {} characters long'.format(
+                    self._requirements.min, self._requirements.max
+                )
+            )
+
+        if user_info.username in self._requirements.blacklist:  # pragma: no branch
+            raise UserRegistrationError(
+                '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 UserRegistrationError(
+                '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 UserRegistrationError(
+                'email', '{} is already registered'.format(user_info.email)
+            )

+ 127 - 70
flaskbb/auth/views.py

@@ -10,9 +10,9 @@
     :license: BSD, see LICENSE for more details.
 """
 import logging
-from datetime import datetime
+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,
@@ -23,7 +23,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,
@@ -32,33 +32,20 @@ from flaskbb.utils.helpers import (anonymous_required, enforce_recaptcha,
                                    requires_unactivated)
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.tokens import get_token_status
-from pluggy import HookimplMarker
 
-impl = HookimplMarker('flaskbb')
+from . import services
+from ..core.auth.password import ResetPasswordService
+from ..core.auth.registration import (RegistrationService, StopRegistration,
+                                  UserRegistrationInfo)
+from ..core.tokens import StopTokenVerification, TokenError
+from ..tokens import FlaskBBTokenSerializer
+from ..tokens.verifiers import EmailMatchesUserToken
+from ..user.repo import UserRepository
+from .plugins import impl
 
 logger = logging.getLogger(__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))
-
-
 class Logout(MethodView):
     decorators = [limiter.exempt, login_required]
 
@@ -123,6 +110,9 @@ class Reauth(MethodView):
 class Register(MethodView):
     decorators = [anonymous_required, registration_enabled]
 
+    def __init__(self, registration_service):
+        self.registration_service = registration_service
+
     def form(self):
         form = RegisterForm()
 
@@ -137,30 +127,36 @@ 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"
-                )
+            registration_info = UserRegistrationInfo(
+                username=form.username.data,
+                password=form.password.data,
+                group=4,
+                email=form.email.data,
+                language=form.language.data
+            )
+
+            try:
+                self.registration_service.register(registration_info)
+            except StopRegistration as e:
+                form.populate_errors(e.reasons)
+                return render_template("auth/register.html", form=form)
+
             else:
-                login_user(user)
-                flash(_("Thanks for registering."), "success")
+                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,6 +194,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
@@ -206,27 +205,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:
-                if user.email != form.email.data:
-                    form.email.errors = [_("Wrong email")]
-                    form.token.data = token
-                    return render_template("auth/reset_password.html", form=form)
-                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 StopTokenVerification 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)
@@ -329,6 +329,24 @@ class ActivateAccount(MethodView):
 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."""
@@ -344,6 +362,25 @@ def flaskbb_load_blueprints(app):
             "errors/too_many_logins.html", timeout=error.description
         )
 
+    def validators_factory():
+        with app.app_context():
+            requirements = services.UsernameRequirements(
+                min=flaskbb_config["AUTH_USERNAME_MIN_LENGTH"],
+                max=flaskbb_config["AUTH_USERNAME_MAX_LENGTH"],
+                blacklist=[
+                    w.strip()
+                    for w in flaskbb_config["AUTH_USERNAME_BLACKLIST"].split(",")
+                ]
+            )
+
+        return [
+            services.EmailUniquenessValidator(User),
+            services.UsernameUniquenessValidator(User),
+            services.UsernameValidator(requirements)
+        ]
+
+    service = RegistrationService(validators_factory, UserRepository(db))
+
     # Activate rate limiting on the whole blueprint
     limiter.limit(
         login_rate_limit, error_message=login_rate_limit_message
@@ -353,17 +390,37 @@ def flaskbb_load_blueprints(app):
     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')
+        auth,
+        routes=['/register'],
+        view_func=Register.as_view('register', registration_service=service)
     )
     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')
+        view_func=ResetPassword.as_view(
+            'reset_password',
+            password_reset_service_factory=reset_service_factory
+        )
     )
     register_view(
         auth,

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

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

@@ -0,0 +1,40 @@
+# -*- 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 ..tokens import (StopTokenVerification, Token, TokenActions, TokenError,
+                      TokenVerificationError)
+
+
+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 TokenVerificationError as e:
+                errors.append((e.attribute, e.reason))
+
+        if errors:
+            raise StopTokenVerification(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

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

@@ -0,0 +1,86 @@
+# -*- 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 BaseFlaskBBError
+
+
+@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 RegistrationError(BaseFlaskBBError):
+    pass
+
+
+class UserRegistrationError(RegistrationError):
+    """
+    Thrown when a user attempts to register but should
+    not be allowed to complete registration.
+
+    If the reason is not tied to a specific attribute then
+    the attribute property should be set to None.
+    """
+
+    def __init__(self, attribute, reason):
+        super(UserRegistrationError, self).__init__(reason)
+        self.attribute = attribute
+        self.reason = reason
+
+
+class StopRegistration(RegistrationError):
+    def __init__(self, reasons):
+        super(StopRegistration, self).__init__()
+        self.reasons = reasons
+
+
+class UserValidator(ABC):
+    @abstractmethod
+    def validate(self, user_info):
+        """
+        Used to check if a user should be allowed to register.
+        Should raise UserRegistrationError 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_factory, user_repo):
+        self.validators_factory = validators_factory
+        self.user_repo = user_repo
+
+    def register(self, user_info):
+        failures = []
+
+        for v in self.validators_factory():
+            try:
+                v(user_info)
+            except UserRegistrationError as e:
+                failures.append((e.attribute, e.reason))
+
+        if failures:
+            raise StopRegistration(failures)
+
+        self.user_repo.add(user_info)

+ 118 - 0
flaskbb/core/tokens.py

@@ -0,0 +1,118 @@
+# -*- 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')
+
+
+class TokenVerificationError(BaseFlaskBBError):
+    """
+    Raised from token verifiers in order to signal an error.
+
+    This is not an exception representing an invalid token
+    because it is malformed, expired, etc. This is used
+    for issues such as the token's user doesn't match
+    other information provided.
+    """
+    def __init__(self, attribute, reason):
+        self.reason = reason
+        self.attribute = attribute
+        super(TokenVerificationError, self).__init__((attribute, reason))
+
+
+class StopTokenVerification(BaseFlaskBBError):
+    """
+    Raised from services using token verifies to signal all
+    errors associated with verifiying a token.
+
+    Alternatively, can be raised from a token verifier
+    to halt all further validation and immediately
+    signify a major error.
+    """
+    def __init__(self, reasons):
+        self.reasons = reasons
+        super(StopTokenVerification, self).__init__(reasons)
+
+
+# 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.
+    """
+    @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

+ 8 - 1
flaskbb/exceptions.py

@@ -10,11 +10,18 @@
 from werkzeug.exceptions import HTTPException, Forbidden
 
 
-class FlaskBBError(HTTPException):
+class BaseFlaskBBError(Exception):
     "Root exception for FlaskBB"
     description = "An internal error has occured"
 
 
+class FlaskBBHTTPError(BaseFlaskBBError, HTTPException):
+    pass
+
+
+FlaskBBError = FlaskBBHTTPError
+
+
 class AuthorizationRequired(FlaskBBError, Forbidden):
     description = "Authorization is required to access this area."
 

+ 4 - 0
flaskbb/plugins/spec.py

@@ -63,6 +63,10 @@ def flaskbb_cli(cli):
     """Hook for registering CLI commands."""
 
 
+@spec
+def flaskbb_user_registered(username):
+    """Hook for handling events after a user is registered"""
+
 # Template Hooks
 
 @spec

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

+ 27 - 0
flaskbb/tokens/verifiers.py

@@ -0,0 +1,27 @@
+# -*- 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, TokenVerificationError
+
+
+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 TokenVerificationError("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()

+ 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

+ 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

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

@@ -0,0 +1,58 @@
+import json
+
+import pytest
+from werkzeug.security import check_password_hash
+
+from flaskbb.core.auth import password
+from flaskbb.core.tokens import (StopTokenVerification, Token, TokenActions,
+                                 TokenError, TokenVerificationError)
+from flaskbb.user.models import User
+
+
+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_StopTokenVerification_if_verifiers_fail(self):
+        token = SimpleTokenSerializer.dumps(
+            Token(user_id=1, operation=TokenActions.RESET_PASSWORD))
+
+        def verifier(*a, **k):
+            raise TokenVerificationError('attr', 'no')
+
+        service = password.ResetPasswordService(SimpleTokenSerializer, User,
+                                                [verifier])
+
+        with pytest.raises(StopTokenVerification) 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")

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

@@ -0,0 +1,60 @@
+import pytest
+from flaskbb.core.auth import registration
+from flaskbb.core.user.repo import UserRepository
+
+
+class RaisingValidator(registration.UserValidator):
+    def validate(self, user_info):
+        raise registration.UserRegistrationError(
+            'test', 'just a little whoopsie-diddle')
+
+
+def test_doesnt_register_user_if_validator_fails_with_UserRegistrationError(
+        mocker):
+    repo = mocker.Mock(UserRepository)
+    service = registration.RegistrationService(lambda: [RaisingValidator()],
+                                               repo)
+
+    with pytest.raises(registration.StopRegistration):
+        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(
+        lambda: [RaisingValidator(), RaisingValidator()], repo)
+
+    with pytest.raises(registration.StopRegistration) 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(lambda: [], 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)

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

@@ -0,0 +1,86 @@
+import pytest
+from flaskbb.auth.services.registration import (EmailUniquenessValidator,
+                                                UsernameRequirements,
+                                                UsernameUniquenessValidator,
+                                                UsernameValidator)
+from flaskbb.core.auth.registration import (UserRegistrationError,
+                                            UserRegistrationInfo)
+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(UserRegistrationError) 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(UserRegistrationError) 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(UserRegistrationError) 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(UserRegistrationError) 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(UserRegistrationError) 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)

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

@@ -0,0 +1,22 @@
+import pytest
+
+from flaskbb.core.tokens import Token, TokenActions, TokenVerificationError
+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(TokenVerificationError) 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
+