Browse Source

Merge pull request #424 from justanr/more-auth-services

More auth services
Peter Justin 7 years ago
parent
commit
972388a08b

+ 0 - 1
.coveragerc

@@ -36,7 +36,6 @@ exclude_lines =
 ignore_errors = True
 ignore_errors = True
 precision = 2
 precision = 2
 show_missing = true
 show_missing = true
-skip_covered = true
 
 
 [html]
 [html]
 directory = tests/htmlcov
 directory = tests/htmlcov

+ 83 - 74
flaskbb/auth/forms.py

@@ -14,16 +14,13 @@ from flask_babelplus import lazy_gettext as _
 from wtforms import (BooleanField, HiddenField, PasswordField, SelectField,
 from wtforms import (BooleanField, HiddenField, PasswordField, SelectField,
                      StringField, SubmitField)
                      StringField, SubmitField)
 from wtforms.validators import (DataRequired, Email, EqualTo, InputRequired,
 from wtforms.validators import (DataRequired, Email, EqualTo, InputRequired,
-                                ValidationError, regexp)
+                                regexp)
 
 
-from flaskbb.user.models import User
 from flaskbb.utils.fields import RecaptchaField
 from flaskbb.utils.fields import RecaptchaField
 from flaskbb.utils.forms import FlaskBBForm
 from flaskbb.utils.forms import FlaskBBForm
-from flaskbb.utils.helpers import time_utcnow
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
-
 USERNAME_RE = r'^[\w.+-]+$'
 USERNAME_RE = r'^[\w.+-]+$'
 is_valid_username = regexp(
 is_valid_username = regexp(
     USERNAME_RE, message=_("You can only use letters, numbers or dashes.")
     USERNAME_RE, message=_("You can only use letters, numbers or dashes.")
@@ -31,12 +28,19 @@ is_valid_username = regexp(
 
 
 
 
 class LoginForm(FlaskBBForm):
 class LoginForm(FlaskBBForm):
-    login = StringField(_("Username or Email address"), validators=[
-        DataRequired(message=_("Please enter your username or email address."))
-    ])
-
-    password = PasswordField(_("Password"), validators=[
-        DataRequired(message=_("Please enter your password."))])
+    login = StringField(
+        _("Username or Email address"),
+        validators=[
+            DataRequired(
+                message=_("Please enter your username or email address.")
+            )
+        ]
+    )
+
+    password = PasswordField(
+        _("Password"),
+        validators=[DataRequired(message=_("Please enter your password."))]
+    )
 
 
     remember_me = BooleanField(_("Remember me"), default=False)
     remember_me = BooleanField(_("Remember me"), default=False)
 
 
@@ -49,17 +53,29 @@ class LoginRecaptchaForm(LoginForm):
 
 
 
 
 class RegisterForm(FlaskBBForm):
 class RegisterForm(FlaskBBForm):
-    username = StringField(_("Username"), validators=[
-        DataRequired(message=_("A valid username is required")),
-        is_valid_username])
-
-    email = StringField(_("Email address"), validators=[
-        DataRequired(message=_("A valid email address is required.")),
-        Email(message=_("Invalid email address."))])
-
-    password = PasswordField(_('Password'), validators=[
-        InputRequired(),
-        EqualTo('confirm_password', message=_('Passwords must match.'))])
+    username = StringField(
+        _("Username"),
+        validators=[
+            DataRequired(message=_("A valid username is required")),
+            is_valid_username
+        ]
+    )
+
+    email = StringField(
+        _("Email address"),
+        validators=[
+            DataRequired(message=_("A valid email address is required.")),
+            Email(message=_("Invalid email address."))
+        ]
+    )
+
+    password = PasswordField(
+        _('Password'),
+        validators=[
+            InputRequired(),
+            EqualTo('confirm_password', message=_('Passwords must match.'))
+        ]
+    )
 
 
     confirm_password = PasswordField(_('Confirm password'))
     confirm_password = PasswordField(_('Confirm password'))
 
 
@@ -67,32 +83,32 @@ class RegisterForm(FlaskBBForm):
 
 
     language = SelectField(_('Language'))
     language = SelectField(_('Language'))
 
 
-    accept_tos = BooleanField(_("I accept the Terms of Service"), validators=[
-        DataRequired(message=_("Please accept the TOS."))], default=True)
+    accept_tos = BooleanField(
+        _("I accept the Terms of Service"),
+        validators=[DataRequired(message=_("Please accept the TOS."))],
+        default=True
+    )
 
 
     submit = SubmitField(_("Register"))
     submit = SubmitField(_("Register"))
 
 
-    def save(self):
-        user = User(username=self.username.data,
-                    email=self.email.data,
-                    password=self.password.data,
-                    date_joined=time_utcnow(),
-                    primary_group_id=4,
-                    language=self.language.data)
-        return user.save()
-
 
 
 class ReauthForm(FlaskBBForm):
 class ReauthForm(FlaskBBForm):
-    password = PasswordField(_('Password'), validators=[
-        DataRequired(message=_("Please enter your password."))])
+    password = PasswordField(
+        _('Password'),
+        validators=[DataRequired(message=_("Please enter your password."))]
+    )
 
 
     submit = SubmitField(_("Refresh Login"))
     submit = SubmitField(_("Refresh Login"))
 
 
 
 
 class ForgotPasswordForm(FlaskBBForm):
 class ForgotPasswordForm(FlaskBBForm):
-    email = StringField(_('Email address'), validators=[
-        DataRequired(message=_("A valid email address is required.")),
-        Email()])
+    email = StringField(
+        _('Email address'),
+        validators=[
+            DataRequired(message=_("A valid email address is required.")),
+            Email()
+        ]
+    )
 
 
     recaptcha = RecaptchaField(_("Captcha"))
     recaptcha = RecaptchaField(_("Captcha"))
 
 
@@ -102,49 +118,42 @@ class ForgotPasswordForm(FlaskBBForm):
 class ResetPasswordForm(FlaskBBForm):
 class ResetPasswordForm(FlaskBBForm):
     token = HiddenField('Token')
     token = HiddenField('Token')
 
 
-    email = StringField(_('Email address'), validators=[
-        DataRequired(message=_("A valid email address is required.")),
-        Email()])
-
-    password = PasswordField(_('Password'), validators=[
-        InputRequired(),
-        EqualTo('confirm_password', message=_('Passwords must match.'))])
+    email = StringField(
+        _('Email address'),
+        validators=[
+            DataRequired(message=_("A valid email address is required.")),
+            Email()
+        ]
+    )
+
+    password = PasswordField(
+        _('Password'),
+        validators=[
+            InputRequired(),
+            EqualTo('confirm_password', message=_('Passwords must match.'))
+        ]
+    )
 
 
     confirm_password = PasswordField(_('Confirm password'))
     confirm_password = PasswordField(_('Confirm password'))
 
 
     submit = SubmitField(_("Reset password"))
     submit = SubmitField(_("Reset password"))
 
 
-    def validate_email(self, field):
-        email = User.query.filter_by(email=field.data).first()
-        if not email:
-            raise ValidationError(_("Wrong email address."))
-
 
 
 class RequestActivationForm(FlaskBBForm):
 class RequestActivationForm(FlaskBBForm):
-    username = StringField(_("Username"), validators=[
-        DataRequired(message=_("A valid username is required.")),
-        is_valid_username])
-
-    email = StringField(_("Email address"), validators=[
-        DataRequired(message=_("A valid email address is required.")),
-        Email(message=_("Invalid email address."))])
+    username = StringField(
+        _("Username"),
+        validators=[
+            DataRequired(message=_("A valid username is required.")),
+            is_valid_username
+        ]
+    )
+
+    email = StringField(
+        _("Email address"),
+        validators=[
+            DataRequired(message=_("A valid email address is required.")),
+            Email(message=_("Invalid email address."))
+        ]
+    )
 
 
     submit = SubmitField(_("Send Confirmation Mail"))
     submit = SubmitField(_("Send Confirmation Mail"))
-
-    def validate_email(self, field):
-        self.user = User.query.filter_by(email=field.data).first()
-        # check if the username matches the one found in the database
-        if not self.user.username == self.username.data:
-            raise ValidationError(_("User does not exist."))
-
-        if self.user.activated is True:
-            raise ValidationError(_("User is already active."))
-
-
-class AccountActivationForm(FlaskBBForm):
-    token = StringField(_("Email confirmation token"), validators=[
-        DataRequired(message=_("Please enter the token that we have sent to "
-                               "you."))
-    ])
-
-    submit = SubmitField(_("Confirm Email"))

+ 12 - 4
flaskbb/auth/plugins.py

@@ -1,11 +1,20 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.auth.plugins
+    ~~~~~~~~~~~~~~~~~~~~
+    Plugin implementations for FlaskBB auth hooks
+
+    :copyright: (c) 2014-2018 the FlaskBB Team.
+    :license: BSD, see LICENSE for more details
+"""
 from flask import flash
 from flask import flash
 from flask_babelplus import gettext as _
 from flask_babelplus import gettext as _
 from flask_login import login_user
 from flask_login import login_user
 
 
 from . import impl
 from . import impl
-from ..email import send_activation_token
 from ..user.models import User
 from ..user.models import User
 from ..utils.settings import flaskbb_config
 from ..utils.settings import flaskbb_config
+from .services.factories import account_activator_factory
 
 
 
 
 @impl
 @impl
@@ -13,9 +22,8 @@ def flaskbb_event_user_registered(username):
     user = User.query.filter_by(username=username).first()
     user = User.query.filter_by(username=username).first()
 
 
     if flaskbb_config["ACTIVATE_ACCOUNT"]:
     if flaskbb_config["ACTIVATE_ACCOUNT"]:
-        send_activation_token.delay(
-            user_id=user.id, username=user.username, email=user.email
-        )
+        service = account_activator_factory()
+        service.initiate_account_activation(user.email)
         flash(
         flash(
             _(
             _(
                 "An account activation email has been sent to "
                 "An account activation email has been sent to "

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

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.auth.services
+    ~~~~~~~~~~~~~~~~~~~~~
+    Public module of implemenations of auth related services
+    in FlaskBB. If you are developing a plugin or extending
+    FlaskBB, you should import from this module rather than
+    submodules.
+
+    :copyright: (c) 2014-2018 the FlaskBB Team.
+    :license: BSD, see LICENSE for more details
+"""
+
+from .activation import AccountActivator
+from .factories import (account_activator_factory,
+                        registration_service_factory, reset_service_factory)
+from .password import ResetPasswordService
+from .registration import (EmailUniquenessValidator, UsernameRequirements,
+                           UsernameUniquenessValidator, UsernameValidator)

+ 51 - 0
flaskbb/auth/services/activation.py

@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.auth.services.activation
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+    Handlers for activating accounts in FlaskBB
+
+    :copyright: (c) 2014-2018 the FlaskBB Team
+    :license: BSD, see LICENSE for more details
+"""
+
+from flask_babelplus import gettext as _
+
+from ...core.auth.activation import AccountActivator as _AccountActivator
+from ...core.exceptions import ValidationError
+from ...core.tokens import Token, TokenActions, TokenError
+from ...email import send_activation_token
+
+
+class AccountActivator(_AccountActivator):
+
+    def __init__(self, token_serializer, users):
+        self.token_serializer = token_serializer
+        self.users = users
+
+    def initiate_account_activation(self, email):
+        user = self.users.query.filter_by(email=email).first()
+
+        if user is None:
+            raise ValidationError('email', _("Entered email doesn't exist"))
+
+        if user.activated:
+            raise ValidationError('email', _('Account is already activated'))
+
+        token = self.token_serializer.dumps(
+            Token(user_id=user.id, operation=TokenActions.ACTIVATE_ACCOUNT)
+        )
+
+        send_activation_token.delay(
+            token=token, username=user.username, email=user.email
+        )
+
+    def activate_account(self, token):
+        token = self.token_serializer.loads(token)
+        if token.operation != TokenActions.ACTIVATE_ACCOUNT:
+            raise TokenError.invalid()
+        user = self.users.query.get(token.user_id)
+        if user.activated:
+            raise ValidationError(
+                'activated', _('Account is already activated')
+            )
+        user.activated = True

+ 64 - 0
flaskbb/auth/services/factories.py

@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.auth.services.factories
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+    Factory functions for various FlaskBB auth services
+
+    These factories are provisional.
+
+    :copyright: 2014-2018 the FlaskBB Team.
+    :license: BSD, see LICENSE for more details
+"""
+from datetime import timedelta
+
+from flask import current_app
+
+from ...extensions import db
+from ...tokens import FlaskBBTokenSerializer
+from ...tokens.verifiers import EmailMatchesUserToken
+from ...user.models import User
+from ...user.repo import UserRepository
+from ...utils.settings import flaskbb_config
+from .activation import AccountActivator
+from .password import ResetPasswordService
+from .registration import (EmailUniquenessValidator, RegistrationService,
+                           UsernameRequirements, UsernameUniquenessValidator,
+                           UsernameValidator)
+
+
+def registration_service_factory():
+    blacklist = [
+        w.strip()
+        for w in flaskbb_config["AUTH_USERNAME_BLACKLIST"].split(",")
+    ]
+
+    requirements = UsernameRequirements(
+        min=flaskbb_config["AUTH_USERNAME_MIN_LENGTH"],
+        max=flaskbb_config["AUTH_USERNAME_MAX_LENGTH"],
+        blacklist=blacklist
+    )
+
+    validators = [
+        EmailUniquenessValidator(User),
+        UsernameUniquenessValidator(User),
+        UsernameValidator(requirements)
+    ]
+
+    return RegistrationService(validators, UserRepository(db))
+
+
+def reset_service_factory():
+    token_serializer = FlaskBBTokenSerializer(
+        current_app.config['SECRET_KEY'], expiry=timedelta(hours=1)
+    )
+    verifiers = [EmailMatchesUserToken(User)]
+    return ResetPasswordService(
+        token_serializer, User, token_verifiers=verifiers
+    )
+
+
+def account_activator_factory():
+    token_serializer = FlaskBBTokenSerializer(
+        current_app.config['SECRET_KEY'], expiry=timedelta(hours=1)
+    )
+    return AccountActivator(token_serializer, User)

+ 58 - 0
flaskbb/auth/services/password.py

@@ -0,0 +1,58 @@
+"""
+    flaskbb.auth.password
+    ~~~~~~~~~~~~~~~~~~~~~
+
+    Password reset manager
+
+    :copyright: (c) 2014-2018 the FlaskBB Team.
+    :license: BSD, see LICENSE for more details
+"""
+
+from flask_babelplus import gettext as _
+
+from ...core.auth.password import ResetPasswordService as _ResetPasswordService
+from ...core.exceptions import StopValidation, ValidationError
+from ...core.tokens import Token, TokenActions, TokenError
+from ...email import send_reset_token
+
+
+class ResetPasswordService(_ResetPasswordService):
+
+    def __init__(self, token_serializer, users, token_verifiers):
+        self.token_serializer = token_serializer
+        self.users = users
+        self.token_verifiers = token_verifiers
+
+    def initiate_password_reset(self, email):
+        user = self.users.query.filter_by(email=email).first()
+
+        if user is None:
+            raise ValidationError('email', _('Invalid email'))
+
+        token = self.token_serializer.dumps(
+            Token(user_id=user.id, operation=TokenActions.RESET_PASSWORD)
+        )
+
+        send_reset_token.delay(
+            token=token, username=user.username, email=user.email
+        )
+
+    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
+
+    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)

+ 36 - 7
flaskbb/auth/services/registration.py

@@ -11,10 +11,11 @@
 
 
 from collections import namedtuple
 from collections import namedtuple
 
 
+from flask_babelplus import gettext as _
 from sqlalchemy import func
 from sqlalchemy import func
 
 
-from ...core.auth.registration import UserValidator
-from ...core.exceptions import ValidationError
+from ...core.auth.registration import UserRegistrationService, UserValidator
+from ...core.exceptions import StopValidation, ValidationError
 
 
 __all__ = (
 __all__ = (
     "UsernameRequirements", "UsernameValidator", "EmailUniquenessValidator",
     "UsernameRequirements", "UsernameValidator", "EmailUniquenessValidator",
@@ -36,8 +37,10 @@ class UsernameValidator(UserValidator):
                 self._requirements.max):
                 self._requirements.max):
             raise ValidationError(
             raise ValidationError(
                 'username',
                 'username',
-                'Username must be between {} and {} characters long'.format(
-                    self._requirements.min, self._requirements.max
+                _(
+                    'Username must be between %(min)s and %(max)s characters long',
+                    min=self._requirements.min,
+                    max=self._requirements.max
                 )
                 )
             )
             )
 
 
@@ -45,7 +48,10 @@ class UsernameValidator(UserValidator):
         if is_blacklisted:  # pragma: no branch
         if is_blacklisted:  # pragma: no branch
             raise ValidationError(
             raise ValidationError(
                 'username',
                 'username',
-                '{} is a forbidden username'.format(user_info.username)
+                _(
+                    '%(username)s is a forbidden username',
+                    username=user_info.username
+                )
             )
             )
 
 
 
 
@@ -61,7 +67,10 @@ class UsernameUniquenessValidator(UserValidator):
         if count != 0:  # pragma: no branch
         if count != 0:  # pragma: no branch
             raise ValidationError(
             raise ValidationError(
                 'username',
                 'username',
-                '{} is already registered'.format(user_info.username)
+                _(
+                    '%(username)s is already registered',
+                    username=user_info.username
+                )
             )
             )
 
 
 
 
@@ -76,5 +85,25 @@ class EmailUniquenessValidator(UserValidator):
         ).count()
         ).count()
         if count != 0:  # pragma: no branch
         if count != 0:  # pragma: no branch
             raise ValidationError(
             raise ValidationError(
-                'email', '{} is already registered'.format(user_info.email)
+                'email',
+                _('%(email)s is already registered', email=user_info.email)
             )
             )
+
+
+class RegistrationService(UserRegistrationService):
+
+    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)

+ 82 - 131
flaskbb/auth/views.py

@@ -10,7 +10,7 @@
     :license: BSD, see LICENSE for more details.
     :license: BSD, see LICENSE for more details.
 """
 """
 import logging
 import logging
-from datetime import datetime, timedelta
+from datetime import datetime
 
 
 from flask import Blueprint, current_app, 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.views import MethodView
@@ -18,11 +18,9 @@ from flask_babelplus import gettext as _
 from flask_login import (confirm_login, current_user, login_fresh,
 from flask_login import (confirm_login, current_user, login_fresh,
                          login_required, login_user, logout_user)
                          login_required, login_user, logout_user)
 
 
-from flaskbb.auth.forms import (AccountActivationForm, ForgotPasswordForm,
-                                LoginForm, LoginRecaptchaForm, ReauthForm,
-                                RegisterForm, RequestActivationForm,
-                                ResetPasswordForm)
-from flaskbb.email import send_activation_token, send_reset_token
+from flaskbb.auth.forms import (ForgotPasswordForm, LoginForm,
+                                LoginRecaptchaForm, ReauthForm, RegisterForm,
+                                RequestActivationForm, ResetPasswordForm)
 from flaskbb.exceptions import AuthenticationError
 from flaskbb.exceptions import AuthenticationError
 from flaskbb.extensions import db, limiter
 from flaskbb.extensions import db, limiter
 from flaskbb.user.models import User
 from flaskbb.user.models import User
@@ -32,17 +30,13 @@ from flaskbb.utils.helpers import (anonymous_required, enforce_recaptcha,
                                    registration_enabled, render_template,
                                    registration_enabled, render_template,
                                    requires_unactivated)
                                    requires_unactivated)
 from flaskbb.utils.settings import flaskbb_config
 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.auth.registration import UserRegistrationInfo
+from ..core.exceptions import StopValidation, ValidationError
 from ..core.tokens import TokenError
 from ..core.tokens import TokenError
-from ..tokens import FlaskBBTokenSerializer
-from ..tokens.verifiers import EmailMatchesUserToken
-from ..user.repo import UserRepository
 from .plugins import impl
 from .plugins import impl
+from .services import (account_activator_factory, registration_service_factory,
+                       reset_service_factory)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -147,10 +141,11 @@ class Register(MethodView):
                 try:
                 try:
                     db.session.commit()
                     db.session.commit()
                 except Exception:  # noqa
                 except Exception:  # noqa
-                    logger.exception("Uh that looks bad...")
+                    logger.exception("Database error while resetting password")
                     flash(
                     flash(
                         _(
                         _(
-                            "Could not process registration due to an unrecoverable error"
+                            "Could not process registration due"
+                            "to an unrecoverable error"
                         ), "danger"
                         ), "danger"
                     )
                     )
 
 
@@ -168,27 +163,30 @@ class ForgotPassword(MethodView):
     decorators = [anonymous_required]
     decorators = [anonymous_required]
     form = ForgotPasswordForm
     form = ForgotPasswordForm
 
 
+    def __init__(self, password_reset_service_factory):
+        self.password_reset_service_factory = password_reset_service_factory
+
     def get(self):
     def get(self):
         return render_template("auth/forgot_password.html", form=self.form())
         return render_template("auth/forgot_password.html", form=self.form())
 
 
     def post(self):
     def post(self):
         form = self.form()
         form = self.form()
         if form.validate_on_submit():
         if form.validate_on_submit():
-            user = User.query.filter_by(email=form.email.data).first()
 
 
-            if user:
-                send_reset_token.delay(
-                    user_id=user.id, username=user.username, email=user.email
-                )
-                flash(_("Email sent! Please check your inbox."), "info")
-                return redirect(url_for("auth.forgot_password"))
-            else:
+            try:
+                self.password_reset_service_factory(
+                ).initiate_password_reset(form.email.data)
+            except ValidationError:
                 flash(
                 flash(
                     _(
                     _(
                         "You have entered an username or email address that "
                         "You have entered an username or email address that "
                         "is not linked with your account."
                         "is not linked with your account."
                     ), "danger"
                     ), "danger"
                 )
                 )
+            else:
+                flash(_("Email sent! Please check your inbox."), "info")
+                return redirect(url_for("auth.forgot_password"))
+
         return render_template("auth/forgot_password.html", form=form)
         return render_template("auth/forgot_password.html", form=form)
 
 
 
 
@@ -210,13 +208,11 @@ class ResetPassword(MethodView):
             try:
             try:
                 service = self.password_reset_service_factory()
                 service = self.password_reset_service_factory()
                 service.reset_password(
                 service.reset_password(
-                    token,
-                    form.email.data,
-                    form.password.data
+                    token, form.email.data, form.password.data
                 )
                 )
                 db.session.commit()
                 db.session.commit()
             except TokenError as e:
             except TokenError as e:
-                flash(_(e.reason), 'danger')
+                flash(e.reason, 'danger')
                 return redirect(url_for('auth.forgot_password'))
                 return redirect(url_for('auth.forgot_password'))
             except StopValidation as e:
             except StopValidation as e:
                 form.populate_errors(e.reasons)
                 form.populate_errors(e.reasons)
@@ -238,6 +234,9 @@ class RequestActivationToken(MethodView):
     decorators = [requires_unactivated]
     decorators = [requires_unactivated]
     form = RequestActivationForm
     form = RequestActivationForm
 
 
+    def __init__(self, account_activator_factory):
+        self.account_activator_factory = account_activator_factory
+
     def get(self):
     def get(self):
         return render_template(
         return render_template(
             "auth/request_account_activation.html", form=self.form()
             "auth/request_account_activation.html", form=self.form()
@@ -246,17 +245,19 @@ class RequestActivationToken(MethodView):
     def post(self):
     def post(self):
         form = self.form()
         form = self.form()
         if form.validate_on_submit():
         if form.validate_on_submit():
-            user = User.query.filter_by(email=form.email.data).first()
-            send_activation_token.delay(
-                user_id=user.id, username=user.username, email=user.email
-            )
-            flash(
-                _(
-                    "A new account activation token has been sent to "
-                    "your email address."
-                ), "success"
-            )
-            return redirect(url_for("auth.activate_account"))
+            activator = self.account_activator_factory()
+            try:
+                activator.initiate_account_activation(form.email.data)
+            except ValidationError as e:
+                form.populate_errors([(e.attribute, e.reason)])
+            else:
+                flash(
+                    _(
+                        "A new account activation token has been sent to "
+                        "your email address."
+                    ), "success"
+                )
+                return redirect(url_for("auth.activate_account"))
 
 
         return render_template(
         return render_template(
             "auth/request_account_activation.html", form=form
             "auth/request_account_activation.html", form=form
@@ -264,67 +265,41 @@ class RequestActivationToken(MethodView):
 
 
 
 
 class ActivateAccount(MethodView):
 class ActivateAccount(MethodView):
-    form = AccountActivationForm
     decorators = [requires_unactivated]
     decorators = [requires_unactivated]
 
 
-    def get(self, token=None):
-        expired = invalid = user = None
-        if token is not None:
-            expired, invalid, user = get_token_status(token, "activate_account")
-
-        if invalid:
-            flash(_("Your account activation token is invalid."), "danger")
-            return redirect(url_for("auth.request_activation_token"))
-
-        if expired:
-            flash(_("Your account activation token is expired."), "danger")
-            return redirect(url_for("auth.request_activation_token"))
-
-        if user:
-            user.activated = True
-            user.save()
-
-            if current_user != user:
-                logout_user()
-                login_user(user)
-
-            flash(_("Your account has been activated."), "success")
-            return redirect(url_for("forum.index"))
-
-        return render_template("auth/account_activation.html", form=self.form())
+    def __init__(self, account_activator_factory):
+        self.account_activator_factory = account_activator_factory
 
 
-    def post(self, token=None):
-        expired = invalid = user = None
-        form = self.form()
+    def get(self, token=None):
+        activator = self.account_activator_factory()
+        try:
+            activator.activate_account(token)
+        except TokenError as e:
+            flash(e.reason, 'danger')
+        except ValidationError as e:
+            flash(e.reason, 'danger')
+            return redirect('forum.index')
+
+        else:
+            try:
+                db.session.commit()
+            except Exception:  # noqa
+                logger.exception("Database error while activating account")
+                flash(
+                    _(
+                        "Could not activate account due to an unrecoverable error"
+                    ), "danger"
+                )
 
 
-        if token is not None:
-            expired, invalid, user = get_token_status(token, "activate_account")
+                return redirect('auth.request_activation_token')
 
 
-        elif form.validate_on_submit():
-            expired, invalid, user = get_token_status(
-                form.token.data, "activate_account"
+            flash(
+                _("Your account has been activated and you can now login."),
+                "success"
             )
             )
-
-        if invalid:
-            flash(_("Your account activation token is invalid."), "danger")
-            return redirect(url_for("auth.request_activation_token"))
-
-        if expired:
-            flash(_("Your account activation token is expired."), "danger")
-            return redirect(url_for("auth.request_activation_token"))
-
-        if user:
-            user.activated = True
-            user.save()
-
-            if current_user != user:
-                logout_user()
-                login_user(user)
-
-            flash(_("Your account has been activated."), "success")
             return redirect(url_for("forum.index"))
             return redirect(url_for("forum.index"))
 
 
-        return render_template("auth/account_activation.html", form=form)
+        return render_template("auth/account_activation.html")
 
 
 
 
 @impl(tryfirst=True)
 @impl(tryfirst=True)
@@ -364,27 +339,6 @@ def flaskbb_load_blueprints(app):
             "errors/too_many_logins.html", timeout=error.description
             "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
     # Activate rate limiting on the whole blueprint
     limiter.limit(
     limiter.limit(
         login_rate_limit, error_message=login_rate_limit_message
         login_rate_limit, error_message=login_rate_limit_message
@@ -401,25 +355,15 @@ def flaskbb_load_blueprints(app):
             registration_service_factory=registration_service_factory
             registration_service_factory=registration_service_factory
         )
         )
     )
     )
+
     register_view(
     register_view(
         auth,
         auth,
         routes=['/reset-password'],
         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
+        view_func=ForgotPassword.as_view(
+            'forgot_password',
+            password_reset_service_factory=reset_service_factory
         )
         )
+    )
 
 
     register_view(
     register_view(
         auth,
         auth,
@@ -429,15 +373,22 @@ def flaskbb_load_blueprints(app):
             password_reset_service_factory=reset_service_factory
             password_reset_service_factory=reset_service_factory
         )
         )
     )
     )
+
     register_view(
     register_view(
         auth,
         auth,
         routes=['/activate'],
         routes=['/activate'],
-        view_func=RequestActivationToken.as_view('request_activation_token')
+        view_func=RequestActivationToken.as_view(
+            'request_activation_token',
+            account_activator_factory=account_activator_factory
+        )
     )
     )
+
     register_view(
     register_view(
         auth,
         auth,
         routes=['/activate/confirm', '/activate/confirm/<token>'],
         routes=['/activate/confirm', '/activate/confirm/<token>'],
-        view_func=ActivateAccount.as_view('activate_account')
+        view_func=ActivateAccount.as_view(
+            'activate_account',
+            account_activator_factory=account_activator_factory
+        )
     )
     )
-
     app.register_blueprint(auth, url_prefix=app.config['AUTH_URL_PREFIX'])
     app.register_blueprint(auth, url_prefix=app.config['AUTH_URL_PREFIX'])

+ 24 - 0
flaskbb/core/auth/activation.py

@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.core.auth.activation
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+    Interfaces for handling account activation
+    in FlaskBB
+
+    :copyright: (c) 2014-2018 the FlaskBB Team
+    :license: BSD, see LICENSE for more details
+"""
+
+from abc import abstractmethod
+
+from ..._compat import ABC
+
+
+class AccountActivator(ABC):
+    @abstractmethod
+    def initiate_account_activation(self, user):
+        pass
+
+    @abstractmethod
+    def activate_account(self, token):
+        pass

+ 8 - 24
flaskbb/core/auth/password.py

@@ -9,33 +9,17 @@
     :license: BSD, see LICENSE for more details
     :license: BSD, see LICENSE for more details
 """
 """
 
 
-from ..exceptions import StopValidation, ValidationError
-from ..tokens import TokenActions, TokenError
+from abc import abstractmethod
 
 
+from ..._compat import ABC
 
 
-class ResetPasswordService(object):
 
 
-    def __init__(self, token_serializer, users, token_verifiers):
-        self.token_serializer = token_serializer
-        self.users = users
-        self.token_verifiers = token_verifiers
+class ResetPasswordService(ABC):
 
 
-    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)
+    @abstractmethod
+    def initiate_password_reset(self, email):
+        pass
 
 
+    @abstractmethod
     def reset_password(self, token, email, new_password):
     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
+        pass

+ 3 - 17
flaskbb/core/auth/registration.py

@@ -41,21 +41,7 @@ class UserValidator(ABC):
         return self.validate(user_info)
         return self.validate(user_info)
 
 
 
 
-class RegistrationService(object):
-    def __init__(self, validators, user_repo):
-        self.validators = validators
-        self.user_repo = user_repo
-
+class UserRegistrationService(ABC):
+    @abstractmethod
     def register(self, user_info):
     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)
+        pass

+ 8 - 3
flaskbb/core/tokens.py

@@ -12,34 +12,37 @@
 from abc import abstractmethod
 from abc import abstractmethod
 
 
 import attr
 import attr
+from flask_babelplus import gettext as _
 
 
 from .._compat import ABC
 from .._compat import ABC
 from .exceptions import BaseFlaskBBError
 from .exceptions import BaseFlaskBBError
 
 
+
 class TokenError(BaseFlaskBBError):
 class TokenError(BaseFlaskBBError):
     """
     """
     Raised when there is an issue with deserializing
     Raised when there is an issue with deserializing
     a token. Has helper classmethods to ensure
     a token. Has helper classmethods to ensure
     consistent verbiage.
     consistent verbiage.
     """
     """
+
     def __init__(self, reason):
     def __init__(self, reason):
         self.reason = reason
         self.reason = reason
         super(TokenError, self).__init__(reason)
         super(TokenError, self).__init__(reason)
 
 
     @classmethod
     @classmethod
     def invalid(cls):
     def invalid(cls):
-        return cls('Token is invalid')
+        return cls(_('Token is invalid'))
 
 
     @classmethod
     @classmethod
     def expired(cls):
     def expired(cls):
-        return cls('Token is expired')
+        return cls(_('Token is expired'))
 
 
     # in theory this would never be raised
     # in theory this would never be raised
     # but it's provided for a generic catchall
     # but it's provided for a generic catchall
     # when processing goes horribly wrong
     # when processing goes horribly wrong
     @classmethod  # pragma: no cover
     @classmethod  # pragma: no cover
     def bad(cls):
     def bad(cls):
-        return cls('Token cannot be processed')
+        return cls(_('Token cannot be processed'))
 
 
 
 
 # holder for token actions
 # holder for token actions
@@ -65,6 +68,7 @@ class TokenSerializer(ABC):
     loads must accept a string representation of
     loads must accept a string representation of
     a JWT and produce a token instance
     a JWT and produce a token instance
     """
     """
+
     @abstractmethod
     @abstractmethod
     def dumps(self, token):
     def dumps(self, token):
         pass
         pass
@@ -83,6 +87,7 @@ class TokenVerifier(ABC):
     Should raise a flaskbb.core.exceptions.ValidationError
     Should raise a flaskbb.core.exceptions.ValidationError
     if verification fails.
     if verification fails.
     """
     """
+
     @abstractmethod
     @abstractmethod
     def verify_token(self, token, **kwargs):
     def verify_token(self, token, **kwargs):
         pass
         pass

+ 4 - 7
flaskbb/email.py

@@ -14,21 +14,19 @@ from flask_mail import Message
 from flask_babelplus import lazy_gettext as _
 from flask_babelplus import lazy_gettext as _
 
 
 from flaskbb.extensions import mail, celery
 from flaskbb.extensions import mail, celery
-from flaskbb.utils.tokens import make_token
 
 
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
 @celery.task
 @celery.task
-def send_reset_token(user_id, username, email):
+def send_reset_token(token, username, email):
     """Sends the reset token to the user's email address.
     """Sends the reset token to the user's email address.
 
 
-    :param user_id: The user id. Used to generate the reset token.
+    :param token: The token to send to the user
     :param username: The username to whom the email should be sent.
     :param username: The username to whom the email should be sent.
     :param email:  The email address of the user
     :param email:  The email address of the user
     """
     """
-    token = make_token(user_id=user_id, operation="reset_password")
     send_email(
     send_email(
         subject=_("Password Recovery Confirmation"),
         subject=_("Password Recovery Confirmation"),
         recipients=[email],
         recipients=[email],
@@ -46,14 +44,13 @@ def send_reset_token(user_id, username, email):
 
 
 
 
 @celery.task
 @celery.task
-def send_activation_token(user_id, username, email):
+def send_activation_token(token, username, email):
     """Sends the activation token to the user's email address.
     """Sends the activation token to the user's email address.
 
 
-    :param user_id: The user id. Used to generate the reset token.
+    :param token: The token to send to the user
     :param username: The username to whom the email should be sent.
     :param username: The username to whom the email should be sent.
     :param email:  The email address of the user
     :param email:  The email address of the user
     """
     """
-    token = make_token(user_id=user_id, operation="activate_account")
     send_email(
     send_email(
         subject=_("Account Activation"),
         subject=_("Account Activation"),
         recipients=[email],
         recipients=[email],

+ 0 - 75
flaskbb/utils/tokens.py

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

+ 3 - 2
tests/conftest.py

@@ -1,5 +1,6 @@
 from tests.fixtures.app import *  # noqa
 from tests.fixtures.app import *  # noqa
+from tests.fixtures.auth import *  # noqa
 from tests.fixtures.forum import *  # noqa
 from tests.fixtures.forum import *  # noqa
-from tests.fixtures.user import *  # noqa
-from tests.fixtures.settings_fixture import *  # noqa
 from tests.fixtures.plugin import *  # noqa
 from tests.fixtures.plugin import *  # noqa
+from tests.fixtures.settings_fixture import *  # noqa
+from tests.fixtures.user import *  # noqa

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

@@ -1,66 +0,0 @@
-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")

+ 22 - 0
tests/fixtures/auth.py

@@ -0,0 +1,22 @@
+import json
+
+import pytest
+
+from flaskbb.core.tokens import Token
+
+
+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'])
+
+
+@pytest.fixture(scope='session')
+def token_serializer():
+    return SimpleTokenSerializer

+ 12 - 0
tests/fixtures/user.py

@@ -65,3 +65,15 @@ def Fred(default_groups):
                 activated=True)
                 activated=True)
     fred.save()
     fred.save()
     return fred
     return fred
+
+
+@pytest.fixture
+def unactivated_user(default_groups):
+    """
+    Creates an unactivated user in the default user group
+    """
+    user = User(username='notactive', email='not@active.com',
+                password='password', primary_group=default_groups[3],
+                activated=False)
+    user.save()
+    return user

+ 85 - 0
tests/unit/auth/test_activation.py

@@ -0,0 +1,85 @@
+import pytest
+
+from flaskbb.auth.services import activation
+from flaskbb.core.exceptions import ValidationError
+from flaskbb.core.tokens import Token, TokenActions, TokenError
+from flaskbb.user.models import User
+
+pytestmark = pytest.mark.usefixtures('default_settings')
+
+
+class TestAccountActivationInitiateActivation(object):
+
+    def test_raises_if_user_doesnt_exist(self, Fred, token_serializer):
+        service = activation.AccountActivator(token_serializer, User)
+
+        with pytest.raises(ValidationError) as excinfo:
+            service.initiate_account_activation('does@not.exist')
+
+        assert excinfo.value.reason == "Entered email doesn't exist"
+
+    def test_raises_if_user_is_already_active(self, Fred, token_serializer):
+        service = activation.AccountActivator(token_serializer, User)
+
+        with pytest.raises(ValidationError) as excinfo:
+            service.initiate_account_activation(Fred.email)
+
+        assert excinfo.value.reason == "Account is already activated"
+
+    def test_calls_send_activation_token_successfully_if_user_exists(
+            self, mocker, unactivated_user, token_serializer
+    ):
+        service = activation.AccountActivator(token_serializer, User)
+        mock = mocker.MagicMock()
+        with mocker.patch(
+                'flaskbb.auth.services.activation.send_activation_token.delay',
+                mock):
+            service.initiate_account_activation(unactivated_user.email)
+
+        token = token_serializer.dumps(
+            Token(
+                user_id=unactivated_user.id,
+                operation=TokenActions.ACTIVATE_ACCOUNT
+            )
+        )
+        mock.assert_called_once_with(
+            token=token,
+            username=unactivated_user.username,
+            email=unactivated_user.email
+        )
+
+
+class TestAccountActivationActivateAccount(object):
+
+    def test_raises_if_token_operation_isnt_activate(self, token_serializer):
+        service = activation.AccountActivator(token_serializer, User)
+        token = token_serializer.dumps(
+            Token(user_id=1, operation=TokenActions.RESET_PASSWORD)
+        )
+
+        with pytest.raises(TokenError):
+            service.activate_account(token)
+
+    def test_raises_if_user_is_already_active(self, Fred, token_serializer):
+        service = activation.AccountActivator(token_serializer, User)
+        token = token_serializer.dumps(
+            Token(user_id=Fred.id, operation=TokenActions.ACTIVATE_ACCOUNT)
+        )
+
+        with pytest.raises(ValidationError) as excinfo:
+            service.activate_account(token)
+
+        assert excinfo.value.reason == 'Account is already activated'
+
+    def test_activates_user_successfully(
+            self, unactivated_user, token_serializer
+    ):
+        service = activation.AccountActivator(token_serializer, User)
+        token = token_serializer.dumps(
+            Token(
+                user_id=unactivated_user.id,
+                operation=TokenActions.ACTIVATE_ACCOUNT
+            )
+        )
+        service.activate_account(token)
+        assert unactivated_user.activated

+ 83 - 0
tests/unit/auth/test_password.py

@@ -0,0 +1,83 @@
+import pytest
+from werkzeug.security import check_password_hash
+
+from flaskbb.auth.services import password
+from flaskbb.core.exceptions import StopValidation, ValidationError
+from flaskbb.core.tokens import Token, TokenActions, TokenError
+from flaskbb.user.models import User
+
+pytestmark = pytest.mark.usefixtures('default_settings')
+
+
+class TestPasswordReset(object):
+
+    def test_raises_token_error_if_not_a_password_reset(
+            self, token_serializer
+    ):
+        service = password.ResetPasswordService(token_serializer, User, [])
+        raw_token = token_serializer.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_serializer):
+        token = token_serializer.dumps(
+            Token(user_id=1, operation=TokenActions.RESET_PASSWORD)
+        )
+
+        def verifier(*a, **k):
+            raise ValidationError('attr', 'no')
+
+        service = password.ResetPasswordService(
+            token_serializer, 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, token_serializer, Fred
+    ):
+        token = token_serializer.dumps(
+            Token(user_id=Fred.id, operation=TokenActions.RESET_PASSWORD)
+        )
+
+        service = password.ResetPasswordService(token_serializer, User, [])
+
+        service.reset_password(token, Fred.email, "newpasswordwhodis")
+        assert check_password_hash(Fred.password, "newpasswordwhodis")
+
+    # need fred to initiate Users
+    def test_initiate_raises_if_user_doesnt_exist(
+            self, token_serializer, Fred
+    ):
+        service = password.ResetPasswordService(token_serializer, User, [])
+        with pytest.raises(ValidationError) as excinfo:
+            service.initiate_password_reset('lol@doesnt.exist')
+
+        assert excinfo.value.attribute == 'email'
+        assert excinfo.value.reason == 'Invalid email'
+
+    def test_calls_send_reset_token_successfully_if_user_exists(
+            self, Fred, mocker, token_serializer
+    ):
+        service = password.ResetPasswordService(token_serializer, User, [])
+        mock = mocker.MagicMock()
+
+        with mocker.patch(
+                'flaskbb.auth.services.password.send_reset_token.delay', mock):
+            service.initiate_password_reset(Fred.email)
+
+        token = token_serializer.dumps(
+            Token(user_id=Fred.id, operation=TokenActions.RESET_PASSWORD)
+        )
+        mock.assert_called_once_with(
+            token=token, username=Fred.username, email=Fred.email
+        )

+ 10 - 10
tests/core/auth/test_registration.py → tests/unit/auth/test_registration.py

@@ -1,26 +1,26 @@
 import pytest
 import pytest
-from flaskbb.core.auth import registration
+
+from flaskbb.auth.services import registration
+from flaskbb.core.auth.registration import UserRegistrationInfo
 from flaskbb.core.exceptions import StopValidation, ValidationError
 from flaskbb.core.exceptions import StopValidation, ValidationError
 from flaskbb.core.user.repo import UserRepository
 from flaskbb.core.user.repo import UserRepository
 
 
+pytestmark = pytest.mark.usefixtures('default_settings')
+
 
 
 class RaisingValidator(registration.UserValidator):
 class RaisingValidator(registration.UserValidator):
 
 
     def validate(self, user_info):
     def validate(self, user_info):
-        raise ValidationError(
-            'test', 'just a little whoopsie-diddle'
-        )
+        raise ValidationError('test', 'just a little whoopsie-diddle')
 
 
 
 
-def test_doesnt_register_user_if_validator_fails_with_ValidationError(
-        mocker
-):
+def test_doesnt_register_user_if_validator_fails_with_ValidationError(mocker):
     repo = mocker.Mock(UserRepository)
     repo = mocker.Mock(UserRepository)
     service = registration.RegistrationService([RaisingValidator()], repo)
     service = registration.RegistrationService([RaisingValidator()], repo)
 
 
     with pytest.raises(StopValidation):
     with pytest.raises(StopValidation):
         service.register(
         service.register(
-            registration.UserRegistrationInfo(
+            UserRegistrationInfo(
                 username='fred',
                 username='fred',
                 password='lol',
                 password='lol',
                 email='fred@fred.fred',
                 email='fred@fred.fred',
@@ -40,7 +40,7 @@ def test_gathers_up_all_errors_during_registration(mocker):
 
 
     with pytest.raises(StopValidation) as excinfo:
     with pytest.raises(StopValidation) as excinfo:
         service.register(
         service.register(
-            registration.UserRegistrationInfo(
+            UserRegistrationInfo(
                 username='fred',
                 username='fred',
                 password='lol',
                 password='lol',
                 email='fred@fred.fred',
                 email='fred@fred.fred',
@@ -58,7 +58,7 @@ def test_gathers_up_all_errors_during_registration(mocker):
 def test_registers_user_if_no_errors_occurs(mocker):
 def test_registers_user_if_no_errors_occurs(mocker):
     repo = mocker.Mock(UserRepository)
     repo = mocker.Mock(UserRepository)
     service = registration.RegistrationService([], repo)
     service = registration.RegistrationService([], repo)
-    user_info = registration.UserRegistrationInfo(
+    user_info = UserRegistrationInfo(
         username='fred',
         username='fred',
         password='lol',
         password='lol',
         email='fred@fred.fred',
         email='fred@fred.fred',

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

@@ -1,4 +1,5 @@
 import pytest
 import pytest
+
 from flaskbb.auth.services.registration import (EmailUniquenessValidator,
 from flaskbb.auth.services.registration import (EmailUniquenessValidator,
                                                 UsernameRequirements,
                                                 UsernameRequirements,
                                                 UsernameUniquenessValidator,
                                                 UsernameUniquenessValidator,
@@ -7,6 +8,8 @@ from flaskbb.core.auth.registration import UserRegistrationInfo
 from flaskbb.core.exceptions import ValidationError
 from flaskbb.core.exceptions import ValidationError
 from flaskbb.user.models import User
 from flaskbb.user.models import User
 
 
+pytestmark = pytest.mark.usefixtures('default_settings')
+
 
 
 def test_raises_if_username_too_short():
 def test_raises_if_username_too_short():
     requirements = UsernameRequirements(min=4, max=100, blacklist=set())
     requirements = UsernameRequirements(min=4, max=100, blacklist=set())

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

@@ -6,6 +6,8 @@ from freezegun import freeze_time
 from flaskbb import tokens
 from flaskbb import tokens
 from flaskbb.core.tokens import Token, TokenActions, TokenError
 from flaskbb.core.tokens import Token, TokenActions, TokenError
 
 
+pytestmark = pytest.mark.usefixtures('default_settings')
+
 
 
 def test_can_round_trip_token():
 def test_can_round_trip_token():
     serializer = tokens.FlaskBBTokenSerializer(
     serializer = tokens.FlaskBBTokenSerializer(

+ 3 - 1
tests/unit/tokens/test_verifiers.py

@@ -1,10 +1,12 @@
 import pytest
 import pytest
 
 
-from flaskbb.core.tokens import Token, TokenActions
 from flaskbb.core.exceptions import ValidationError
 from flaskbb.core.exceptions import ValidationError
+from flaskbb.core.tokens import Token, TokenActions
 from flaskbb.tokens import verifiers
 from flaskbb.tokens import verifiers
 from flaskbb.user.models import User
 from flaskbb.user.models import User
 
 
+pytestmark = pytest.mark.usefixtures('default_settings')
+
 
 
 def test_raises_if_email_doesnt_match_token_user(Fred):
 def test_raises_if_email_doesnt_match_token_user(Fred):
     verifier = verifiers.EmailMatchesUserToken(User)
     verifier = verifiers.EmailMatchesUserToken(User)

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

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