Browse Source

Merge pull request #426 from justanr/ISS-382-auth-hooks

Issue 382: Add auth hooks
Peter Justin 7 years ago
parent
commit
7fd8bbc5a2

+ 7 - 0
docs/hooks.rst

@@ -45,6 +45,7 @@ The hooks below are listed in the order they are called.
 .. autofunction:: flaskbb_load_translations
 .. autofunction:: flaskbb_additional_setup
 
+
 FlaskBB CLI Hooks
 ~~~~~~~~~~~~~~~~~
 
@@ -63,6 +64,12 @@ FlaskBB Event Hooks
 .. autofunction:: flaskbb_event_topic_save_before
 .. autofunction:: flaskbb_event_topic_save_after
 .. autofunction:: flaskbb_event_user_registered
+.. autofunction:: flaskbb_authenticate
+.. autofunction:: flaskbb_post_authenticate
+.. autofunction:: flaskbb_authentication_failed
+.. autofunction:: flaskbb_reauth_attempt
+.. autofunction:: flaskbb_post_reauth
+.. autofunction:: flaskbb_reauth_failed
 
 
 FlaskBB Form Hooks

+ 52 - 2
flaskbb/auth/plugins.py

@@ -7,14 +7,21 @@
     :copyright: (c) 2014-2018 the FlaskBB Team.
     :license: BSD, see LICENSE for more details
 """
-from flask import flash
+from flask import flash, redirect, url_for
 from flask_babelplus import gettext as _
-from flask_login import login_user
+from flask_login import current_user, login_user, logout_user
 
 from . import impl
+from ..core.auth.authentication import ForceLogout
 from ..user.models import User
 from ..utils.settings import flaskbb_config
+from .services.authentication import (BlockUnactivatedUser, ClearFailedLogins,
+                                      DefaultFlaskBBAuthProvider,
+                                      MarkFailedLogin)
 from .services.factories import account_activator_factory
+from .services.reauthentication import (ClearFailedLoginsOnReauth,
+                                        DefaultFlaskBBReauthProvider,
+                                        MarkFailedReauth)
 
 
 @impl
@@ -34,3 +41,46 @@ def flaskbb_event_user_registered(username):
     else:
         login_user(user)
         flash(_("Thanks for registering."), "success")
+
+
+@impl(trylast=True)
+def flaskbb_authenticate(identifier, secret):
+    return DefaultFlaskBBAuthProvider().authenticate(identifier, secret)
+
+
+@impl(tryfirst=True)
+def flaskbb_post_authenticate(user):
+    ClearFailedLogins().handle_post_auth(user)
+    BlockUnactivatedUser().handle_post_auth(user)
+
+
+@impl
+def flaskbb_authentication_failed(identifier):
+    MarkFailedLogin().handle_authentication_failure(identifier)
+
+
+@impl(trylast=True)
+def flaskbb_reauth_attempt(user, secret):
+    return DefaultFlaskBBReauthProvider().reauthenticate(user, secret)
+
+
+@impl
+def flaskbb_reauth_failed(user):
+    MarkFailedReauth().handle_reauth_failure(user)
+
+
+@impl
+def flaskbb_post_reauth(user):
+    ClearFailedLoginsOnReauth().handle_post_reauth(user)
+
+
+@impl
+def flaskbb_errorhandlers(app):
+
+    @app.errorhandler(ForceLogout)
+    def handle_force_logout(error):
+        if current_user:
+            logout_user()
+            if error.reason:
+                flash(error.reason, 'danger')
+        return redirect(url_for('forum.index'))

+ 10 - 2
flaskbb/auth/services/__init__.py

@@ -12,8 +12,16 @@
 """
 
 from .activation import AccountActivator
+from .authentication import (BlockTooManyFailedLogins, BlockUnactivatedUser,
+                             DefaultFlaskBBAuthProvider,
+                             FailedLoginConfiguration, MarkFailedLogin,
+                             PluginAuthenticationManager)
 from .factories import (account_activator_factory,
+                        authentication_manager_factory,
+                        reauthentication_manager_factory,
                         registration_service_factory, reset_service_factory)
 from .password import ResetPasswordService
-from .registration import (EmailUniquenessValidator, UsernameRequirements,
-                           UsernameUniquenessValidator, UsernameValidator)
+from .registration import (
+    EmailUniquenessValidator, UsernameRequirements,
+    UsernameUniquenessValidator, UsernameValidator
+)

+ 140 - 0
flaskbb/auth/services/authentication.py

@@ -0,0 +1,140 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.auth.services.authentication
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+    Authentication providers, handlers and post-processors
+    in FlaskBB
+
+    :copyright: (c) 2014-2018 the FlaskBB Team.
+    :license: BSD, see LICENSE for more details
+"""
+import logging
+from datetime import datetime
+
+import attr
+from flask_babelplus import gettext as _
+from pytz import UTC
+from werkzeug import check_password_hash
+
+from ...core.auth.authentication import (AuthenticationFailureHandler,
+                                         AuthenticationManager,
+                                         AuthenticationProvider,
+                                         PostAuthenticationHandler,
+                                         StopAuthentication)
+from ...extensions import db
+from ...user.models import User
+from ...utils.helpers import time_utcnow
+
+logger = logging.getLogger(__name__)
+
+
+@attr.s(frozen=True)
+class FailedLoginConfiguration(object):
+    limit = attr.ib()
+    lockout_window = attr.ib()
+
+
+class BlockTooManyFailedLogins(AuthenticationProvider):
+
+    def __init__(self, configuration):
+        self.configuration = configuration
+
+    def authenticate(self, identifier, secret):
+        user = User.query.filter(
+            db.or_(User.username == identifier, User.email == identifier)
+        ).first()
+
+        if user is not None:
+            attempts = user.login_attempts
+            last_attempt = user.last_failed_login or datetime.min.replace(
+                tzinfo=UTC
+            )
+            reached_attempt_limit = attempts >= self.configuration.limit
+            inside_lockout = (
+                last_attempt + self.configuration.lockout_window
+            ) >= time_utcnow()
+
+            if reached_attempt_limit and inside_lockout:
+                raise StopAuthentication(
+                    _(
+                        "Your account is currently locked out due to too many "
+                        "failed login attempts"
+                    )
+                )
+
+
+class DefaultFlaskBBAuthProvider(AuthenticationProvider):
+
+    def authenticate(self, identifier, secret):
+        user = User.query.filter(
+            db.or_(User.username == identifier, User.email == identifier)
+        ).first()
+
+        if user is not None:
+            if check_password_hash(user.password, secret):
+                return user
+            return None
+
+        check_password_hash("dummy password", secret)
+        return None
+
+
+class MarkFailedLogin(AuthenticationFailureHandler):
+
+    def handle_authentication_failure(self, identifier):
+        user = User.query.filter(
+            db.or_(User.username == identifier, User.email == identifier)
+        ).first()
+
+        if user is not None:
+            user.login_attempts += 1
+            user.last_failed_login = time_utcnow()
+
+
+class BlockUnactivatedUser(PostAuthenticationHandler):
+
+    def handle_post_auth(self, user):
+        if not user.activated:  # pragma: no branch
+            raise StopAuthentication(
+                _(
+                    "In order to use your account you have to "
+                    "activate it through the link we have sent to "
+                    "your email address."
+                )
+            )
+
+
+class ClearFailedLogins(PostAuthenticationHandler):
+
+    def handle_post_auth(self, user):
+        user.login_attempts = 0
+
+
+class PluginAuthenticationManager(AuthenticationManager):
+
+    def __init__(self, plugin_manager, session):
+        self.plugin_manager = plugin_manager
+        self.session = session
+
+    def authenticate(self, identifier, secret):
+        try:
+            user = self.plugin_manager.hook.flaskbb_authenticate(
+                identifier=identifier, secret=secret
+            )
+            if user is None:
+                raise StopAuthentication(_("Wrong username or password."))
+            self.plugin_manager.hook.flaskbb_post_authenticate(user=user)
+            return user
+        except StopAuthentication as e:
+            self.plugin_manager.hook.flaskbb_authentication_failed(
+                identifier=identifier
+            )
+            raise
+        finally:
+            try:
+                self.session.commit()
+            except Exception:
+                logger.exception("Exception while processing login")
+                self.session.rollback()
+                raise

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

@@ -20,7 +20,9 @@ from ...user.models import User
 from ...user.repo import UserRepository
 from ...utils.settings import flaskbb_config
 from .activation import AccountActivator
+from .authentication import PluginAuthenticationManager
 from .password import ResetPasswordService
+from .reauthentication import PluginReauthenticationManager
 from .registration import (EmailUniquenessValidator, RegistrationService,
                            UsernameRequirements, UsernameUniquenessValidator,
                            UsernameValidator)
@@ -62,3 +64,10 @@ def account_activator_factory():
         current_app.config['SECRET_KEY'], expiry=timedelta(hours=1)
     )
     return AccountActivator(token_serializer, User)
+
+
+def authentication_manager_factory():
+    return PluginAuthenticationManager(current_app.pluggy, db.session)
+
+def reauthentication_manager_factory():
+    return PluginReauthenticationManager(current_app.pluggy, db.session)

+ 69 - 0
flaskbb/auth/services/reauthentication.py

@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.auth.services.reauthentication
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+    Tools for handling reauthentication needs inside FlaskBB.
+
+    :copyright: (c) 2014-2018 the FlaskBB Team
+    :license: BSD, see LICENSE for more details
+"""
+
+import logging
+
+from flask_babelplus import gettext as _
+from werkzeug.security import check_password_hash
+
+from ...core.auth.authentication import (PostReauthenticateHandler,
+                                         ReauthenticateFailureHandler,
+                                         ReauthenticateManager,
+                                         ReauthenticateProvider,
+                                         StopAuthentication)
+from ...utils.helpers import time_utcnow
+
+logger = logging.getLogger(__name__)
+
+
+class DefaultFlaskBBReauthProvider(ReauthenticateProvider):
+
+    def reauthenticate(self, user, secret):
+        if check_password_hash(user.password, secret):  # pragma: no branch
+            return True
+
+
+class ClearFailedLoginsOnReauth(PostReauthenticateHandler):
+
+    def handle_post_reauth(self, user):
+        user.login_attempts = 0
+
+
+class MarkFailedReauth(ReauthenticateFailureHandler):
+
+    def handle_reauth_failure(self, user):
+        user.login_attempts += 1
+        user.last_failed_login = time_utcnow()
+
+
+class PluginReauthenticationManager(ReauthenticateManager):
+
+    def __init__(self, plugin_manager, session):
+        self.plugin_manager = plugin_manager
+        self.session = session
+
+    def reauthenticate(self, user, secret):
+        try:
+            result = self.plugin_manager.hook.flaskbb_reauth_attempt(
+                user=user, secret=secret
+            )
+            if not result:
+                raise StopAuthentication(_("Wrong password."))
+            self.plugin_manager.hook.flaskbb_post_reauth(user=user)
+        except StopAuthentication as e:
+            self.plugin_manager.hook.flaskbb_reauth_failed(user=user)
+            raise
+        finally:
+            try:
+                self.session.commit()
+            except Exception:
+                logger.exception("Exception while processing login")
+                self.session.rollback()
+                raise

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

@@ -9,8 +9,7 @@
     :license: BSD, see LICENSE for more details
 """
 
-from collections import namedtuple
-
+import attr
 from flask_babelplus import gettext as _
 from sqlalchemy import func
 
@@ -22,9 +21,12 @@ __all__ = (
     "UsernameUniquenessValidator"
 )
 
-UsernameRequirements = namedtuple(
-    'UsernameRequirements', ['min', 'max', 'blacklist']
-)
+
+@attr.s(hash=False, repr=True, frozen=True, cmp=False)
+class UsernameRequirements(object):
+    min = attr.ib()
+    max = attr.ib()
+    blacklist = attr.ib()
 
 
 class UsernameValidator(UserValidator):

+ 58 - 22
flaskbb/auth/views.py

@@ -17,13 +17,10 @@ from flask.views import MethodView
 from flask_babelplus import gettext as _
 from flask_login import (confirm_login, current_user, login_fresh,
                          login_required, login_user, logout_user)
-
 from flaskbb.auth.forms import (ForgotPasswordForm, LoginForm,
                                 LoginRecaptchaForm, ReauthForm, RegisterForm,
                                 RequestActivationForm, ResetPasswordForm)
-from flaskbb.exceptions import AuthenticationError
 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,
                                    redirect_or_next, register_view,
@@ -31,12 +28,15 @@ from flaskbb.utils.helpers import (anonymous_required, enforce_recaptcha,
                                    requires_unactivated)
 from flaskbb.utils.settings import flaskbb_config
 
+from ..core.auth.authentication import StopAuthentication
 from ..core.auth.registration import UserRegistrationInfo
 from ..core.exceptions import StopValidation, ValidationError
 from ..core.tokens import TokenError
 from .plugins import impl
-from .services import (account_activator_factory, registration_service_factory,
-                       reset_service_factory)
+from .services import (account_activator_factory,
+                       authentication_manager_factory,
+                       reauthentication_manager_factory,
+                       registration_service_factory, reset_service_factory)
 
 logger = logging.getLogger(__name__)
 
@@ -53,6 +53,9 @@ class Logout(MethodView):
 class Login(MethodView):
     decorators = [anonymous_required]
 
+    def __init__(self, authentication_manager_factory):
+        self.authentication_manager_factory = authentication_manager_factory
+
     def form(self):
         if enforce_recaptcha(limiter):
             return LoginRecaptchaForm()
@@ -64,19 +67,17 @@ class Login(MethodView):
     def post(self):
         form = self.form()
         if form.validate_on_submit():
+            auth_manager = self.authentication_manager_factory()
             try:
-                user = User.authenticate(form.login.data, form.password.data)
-                if not login_user(user, remember=form.remember_me.data):
-                    flash(
-                        _(
-                            "In order to use your account you have to "
-                            "activate it through the link we have sent to "
-                            "your email address."
-                        ), "danger"
-                    )
+                user = auth_manager.authenticate(
+                    identifier=form.login.data, secret=form.password.data
+                )
+                login_user(user, remember=form.remember_me.data)
                 return redirect_or_next(url_for("forum.index"))
-            except AuthenticationError:
-                flash(_("Wrong username or password."), "danger")
+            except StopAuthentication as e:
+                flash(e.reason, "danger")
+            except Exception:
+                flash(_("Unrecoverable error while handling login"))
 
         return render_template("auth/login.html", form=form)
 
@@ -85,6 +86,9 @@ class Reauth(MethodView):
     decorators = [login_required, limiter.exempt]
     form = ReauthForm
 
+    def __init__(self, reauthentication_factory):
+        self.reauthentication_factory = reauthentication_factory
+
     def get(self):
         if not login_fresh():
             return render_template("auth/reauth.html", form=self.form())
@@ -93,12 +97,21 @@ class Reauth(MethodView):
     def post(self):
         form = self.form()
         if form.validate_on_submit():
-            if current_user.check_password(form.password.data):
+
+            reauth_manager = self.reauthentication_factory()
+            try:
+                user = reauth_manager.reauthenticate(
+                    user=current_user, secret=form.password.data
+                )
                 confirm_login()
                 flash(_("Reauthenticated."), "success")
                 return redirect_or_next(current_user.url)
+            except StopAuthentication as e:
+                flash(e.reason, "danger")
+            except Exception:
+                flash(_("Unrecoverable error while handling reauthentication"))
+                raise
 
-            flash(_("Wrong password."), "danger")
         return render_template("auth/reauth.html", form=form)
 
 
@@ -142,6 +155,7 @@ class Register(MethodView):
                     db.session.commit()
                 except Exception:  # noqa
                     logger.exception("Database error while resetting password")
+                    db.session.rollback()
                     flash(
                         _(
                             "Could not process registration due"
@@ -210,7 +224,6 @@ class ResetPassword(MethodView):
                 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'))
@@ -222,6 +235,14 @@ class ResetPassword(MethodView):
                 logger.exception("Error when resetting password")
                 flash(_('Error when resetting password'))
                 return redirect(url_for('auth.forgot_password'))
+            finally:
+                try:
+                    db.session.commit()
+                except Exception:
+                    logger.exception(
+                        "Error while finalizing database when resetting password"
+                    )
+                    db.session.rollback()
 
             flash(_("Your password has been updated."), "success")
             return redirect(url_for("auth.login"))
@@ -285,6 +306,7 @@ class ActivateAccount(MethodView):
                 db.session.commit()
             except Exception:  # noqa
                 logger.exception("Database error while activating account")
+                db.session.rollback()
                 flash(
                     _(
                         "Could not activate account due to an unrecoverable error"
@@ -345,8 +367,22 @@ def flaskbb_load_blueprints(app):
     )(auth)
 
     register_view(auth, routes=['/logout'], view_func=Logout.as_view('logout'))
-    register_view(auth, routes=['/login'], view_func=Login.as_view('login'))
-    register_view(auth, routes=['/reauth'], view_func=Reauth.as_view('reauth'))
+    register_view(
+        auth,
+        routes=['/login'],
+        view_func=Login.as_view(
+            'login',
+            authentication_manager_factory=authentication_manager_factory
+        )
+    )
+    register_view(
+        auth,
+        routes=['/reauth'],
+        view_func=Reauth.as_view(
+            'reauth',
+            reauthentication_factory=reauthentication_manager_factory
+        )
+    )
     register_view(
         auth,
         routes=['/register'],
@@ -382,7 +418,6 @@ def flaskbb_load_blueprints(app):
             account_activator_factory=account_activator_factory
         )
     )
-
     register_view(
         auth,
         routes=['/activate/confirm', '/activate/confirm/<token>'],
@@ -391,4 +426,5 @@ def flaskbb_load_blueprints(app):
             account_activator_factory=account_activator_factory
         )
     )
+
     app.register_blueprint(auth, url_prefix=app.config['AUTH_URL_PREFIX'])

+ 116 - 0
flaskbb/core/auth/authentication.py

@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+"""
+    flaskbb.core.auth.authentication
+    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+    :copyright: (c) 2014-2018 the FlaskBB Team
+    :license: BSD, see LICENSE for more details
+"""
+
+from abc import abstractmethod
+
+from ..._compat import ABC
+from ..exceptions import BaseFlaskBBError
+
+
+class StopAuthentication(BaseFlaskBBError):
+    """
+    Used by Authentication providers to halt any further
+    attempts to authenticate a user.
+    """
+
+    def __init__(self, reason):
+        super(StopAuthentication, self).__init__(reason)
+        self.reason = reason
+
+
+class ForceLogout(BaseFlaskBBError):
+    """
+    Used to forcefully log a user out.
+    """
+
+    def __init__(self, reason):
+        super(ForceLogout, self).__init__(reason)
+        self.reason = reason
+
+
+class AuthenticationManager(ABC):
+
+    @abstractmethod
+    def authenticate(self, identifier, secret):
+        """
+        Manages the entire authentication process in FlaskBB.
+
+        If a user is successfully authenticated, it is returned
+        from this method.
+        """
+        pass
+
+
+class AuthenticationProvider(ABC):
+
+    @abstractmethod
+    def authenticate(self, identifier, secret):
+        pass
+
+    def __call__(self, identifier, secret):
+        return self.authenticate(identifier, secret)
+
+
+class AuthenticationFailureHandler(ABC):
+
+    @abstractmethod
+    def handle_authentication_failure(self, identifier):
+        pass
+
+    def __call__(self, identifier):
+        self.handle_authentication_failure(identifier)
+
+
+class PostAuthenticationHandler(ABC):
+
+    @abstractmethod
+    def handle_post_auth(self, user):
+        pass
+
+    def __call__(self, user):
+        self.handle_post_auth(user)
+
+
+class ReauthenticateManager(ABC):
+
+    @abstractmethod
+    def reauthenticate(self, user, secret):
+        pass
+
+    def __call__(self, user, secret):
+        pass
+
+
+class ReauthenticateProvider(ABC):
+
+    @abstractmethod
+    def reauthenticate(self, user, secret):
+        pass
+
+    def __call__(self, user, secret):
+        self.handle_reauth(user, secret)
+
+
+class ReauthenticateFailureHandler(ABC):
+
+    @abstractmethod
+    def handle_reauth_failure(self, user):
+        pass
+
+    def __call__(self, user):
+        self.handle_reauth_failure(user)
+
+
+class PostReauthenticateHandler(ABC):
+
+    @abstractmethod
+    def handle_post_reauth(self, user):
+        pass
+
+    def __call__(self, user):
+        self.handle_post_reauth(user)

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

@@ -15,7 +15,6 @@ from abc import abstractmethod
 import attr
 
 from ..._compat import ABC
-from ..exceptions import ValidationError, StopValidation
 
 
 @attr.s(hash=True, cmp=False, repr=True, frozen=True)

+ 168 - 1
flaskbb/plugins/spec.py

@@ -150,6 +150,174 @@ def flaskbb_event_user_registered(username):
     """
 
 
+@spec(firstresult=True)
+def flaskbb_authenticate(identifier, secret):
+    """Hook for authenticating users in FlaskBB.
+    This hook should return either an instance of
+    :class:`flaskbb.user.models.User` or None.
+
+    If a hook decides that all attempts for authentication
+    should end, it may raise a
+    :class:`flaskbb.core.exceptions.StopAuthentication`
+    and include a reason why authentication was stopped.
+
+
+    Only the first User result will used and the default FlaskBB
+    authentication is tried last to give others an attempt to
+    authenticate the user instead.
+
+    Example of alternative auth::
+
+        def ldap_auth(identifier, secret):
+            "basic ldap example with imaginary ldap library"
+            user_dn = "uid={},ou=flaskbb,dc=flaskbb,dc=org"
+            try:
+                ldap.bind(user_dn, secret)
+                return User.query.join(
+                    UserLDAP
+                ).filter(
+                    UserLDAP.dn==user_dn
+                ).with_entities(User).one()
+            except:
+                return None
+
+        @impl
+        def flaskbb_authenticate(identifier, secret):
+            return ldap_auth(identifier, secret)
+
+    Example of ending authentication::
+
+        def prevent_login_with_too_many_failed_attempts(identifier):
+            user = User.query.filter(
+                db.or_(
+                    User.username == identifier,
+                    User.email == identifier
+                )
+            ).first()
+
+            if user is not None:
+                if has_too_many_failed_logins(user):
+                    raise StopAuthentication(_(
+                        "Your account is temporarily locked due to too many login attempts"
+                    ))
+
+        @impl(tryfirst=True)
+        def flaskbb_authenticate(user, identifier):
+            prevent_login_with_too_many_failed_attempts(identifier)
+
+    """
+
+
+@spec
+def flaskbb_post_authenticate(user):
+    """Hook for handling actions that occur after a user is
+    authenticated but before setting them as the current user.
+
+    This could be used to handle MFA. However, these calls will
+    be blocking and should be taken into account.
+
+    Responses from this hook are not considered at all. If a hook
+    should need to prevent the user from logging in, it should
+    register itself as tryfirst and raise a
+    :class:`flaskbb.core.exceptions.StopAuthentication`
+    and include why the login was prevented.
+
+    Example::
+
+        def post_auth(user):
+            today = utcnow()
+            if is_anniversary(today, user.date_joined):
+                flash(_("Happy registerversary!"))
+
+        @impl
+        def flaskbb_post_authenticate(user):
+            post_auth(user)
+    """
+
+
+@spec
+def flaskbb_authentication_failed(identifier):
+    """Hook for handling authentication failure events.
+    This hook will only be called when no authentication
+    providers successfully return a user or a
+    :class:`flaskbb.core.exceptions.StopAuthentication`
+    is raised during the login process.
+
+    Example::
+
+        def mark_failed_logins(identifier):
+            user = User.query.filter(
+                db.or_(
+                    User.username == identifier,
+                    User.email == identifier
+                )
+            ).first()
+
+            if user is not None:
+                if user.login_attempts is None:
+                    user.login_attempts = 1
+                else:
+                    user.login_attempts += 1
+                user.last_failed_login = utcnow()
+    """
+
+
+@spec(firstresult=True)
+def flaskbb_reauth_attempt(user, secret):
+    """Hook for handling reauth in FlaskBB
+
+    These hooks receive the currently authenticated user
+    and the entered secret. Only the first response from
+    this hook is considered -- similar to the authenticate
+    hooks. A successful attempt should return True, otherwise
+    None for an unsuccessful or untried reauth from an
+    implementation. Reauth will be considered a failure if
+    no implementation return True.
+
+    If a hook decides that a reauthenticate attempt should
+    cease, it may raise StopAuthentication.
+
+    Example of checking secret or passing to the next implementer::
+
+        @impl
+        def flaskbb_reauth_attempt(user, secret):
+            if check_password(user.password, secret):
+                return True
+
+
+    Example of forcefully ending reauth::
+
+        @impl
+        def flaskbb_reauth_attempt(user, secret):
+            if user.login_attempts > 5:
+                raise StopAuthentication(_("Too many failed authentication attempts"))
+    """
+
+
+@spec
+def flaskbb_post_reauth(user):
+    """Hook called after successfully reauthenticating.
+
+    These hooks are called a user has passed the flaskbb_reauth_attempt
+    hooks but before their reauth is confirmed so a post reauth implementer
+    may still force a reauth to fail by raising StopAuthentication.
+
+    Results from these hooks are not considered.
+    """
+
+@spec
+def flaskbb_reauth_failed(user):
+    """Hook called if a reauth fails.
+
+    These hooks will only be called if no implementation
+    for flaskbb_reauth_attempt returns a True result or if
+    an implementation raises StopAuthentication.
+
+    If an implementation raises ForceLogout it should register
+    itself as trylast to give other reauth failed handlers an
+    opprotunity to run first.
+    """
+
 # Form hooks
 @spec
 def flaskbb_form_new_post(form):
@@ -484,6 +652,5 @@ def flaskbb_tpl_form_new_topic_after(form):
     rendered (but before the submit button).
 
     in :file:`templates/forum/new_topic.html`
-
     :param form: The form object.
     """

+ 199 - 0
tests/unit/auth/test_authentication.py

@@ -0,0 +1,199 @@
+from datetime import datetime, timedelta
+
+import pytest
+from flaskbb.auth.services import authentication as auth
+from flaskbb.core.auth.authentication import (AuthenticationFailureHandler,
+                                              AuthenticationProvider,
+                                              PostAuthenticationHandler,
+                                              StopAuthentication)
+from flaskbb.plugins import spec
+from freezegun import freeze_time
+from pluggy import HookimplMarker
+from pytz import UTC
+
+pytestmark = pytest.mark.usefixtures('default_settings')
+
+
+class TestBlockTooManyFailedLogins(object):
+    provider = auth.BlockTooManyFailedLogins(
+        auth.
+        FailedLoginConfiguration(limit=1, lockout_window=timedelta(hours=1))
+    )
+
+    @freeze_time(datetime(2018, 1, 1, 13, 30))
+    def test_raises_StopAuthentication_if_user_is_at_limit_and_inside_window(
+            self, Fred
+    ):
+        Fred.last_failed_login = datetime(2018, 1, 1, 14, tzinfo=UTC)
+        Fred.login_attempts = 1
+
+        with pytest.raises(StopAuthentication) as excinfo:
+            self.provider.authenticate(Fred.email, 'not considered')
+
+        assert 'too many failed login attempts' in excinfo.value.reason
+
+    @freeze_time(datetime(2018, 1, 1, 14))
+    def test_doesnt_raise_if_user_is_at_limit_but_outside_window(self, Fred):
+        Fred.last_failed_login = datetime(2018, 1, 1, 12, tzinfo=UTC)
+        Fred.login_attempts = 1
+
+        self.provider.authenticate(Fred.email, 'not considered')
+
+    def test_doesnt_raise_if_user_is_below_limit_but_inside_window(self, Fred):
+        Fred.last_failed_login = datetime(2018, 1, 1, 12, tzinfo=UTC)
+        Fred.login_attempts = 0
+        self.provider.authenticate(Fred.email, 'not considered')
+
+    def test_handles_if_user_has_no_failed_login_attempts(self, Fred):
+        Fred.login_attempts = 0
+        Fred.last_failed_login = None
+
+        self.provider.authenticate(Fred.email, 'not considered')
+
+    def test_handles_if_user_doesnt_exist(self, Fred):
+        self.provider.authenticate('completely@made.up', 'not considered')
+
+
+class TestDefaultFlaskBBAuthProvider(object):
+    provider = auth.DefaultFlaskBBAuthProvider()
+
+    def test_returns_None_if_user_doesnt_exist(self, Fred):
+        result = self.provider.authenticate('completely@made.up', 'lolnope')
+
+        assert result is None
+
+    def test_returns_None_if_password_doesnt_match(self, Fred):
+        result = self.provider.authenticate(Fred.email, 'stillnotit')
+
+        assert result is None
+
+    def test_returns_user_if_identifer_and_password_match(self, Fred):
+        result = self.provider.authenticate(Fred.email, 'fred')
+
+        assert result.username == Fred.username
+
+
+class TestMarkFailedLoginAttempt(object):
+    handler = auth.MarkFailedLogin()
+
+    @freeze_time(datetime(2018, 1, 1, 12))
+    def test_increments_users_failed_logins_and_sets_last_fail_date(
+            self, Fred
+    ):
+        Fred.login_attempts = 0
+        Fred.last_failed_login = datetime.min.replace(tzinfo=UTC)
+        self.handler.handle_authentication_failure(Fred.email)
+        assert Fred.login_attempts == 1
+        assert Fred.last_failed_login == datetime.now(UTC)
+
+    def test_handles_if_user_doesnt_exist(self, Fred):
+        self.handler.handle_authentication_failure('completely@made.up')
+
+
+class TestClearFailedLogins(object):
+    handler = auth.ClearFailedLogins()
+
+    def test_clears_failed_logins_attempts(self, Fred):
+        Fred.login_attempts = 1000
+        self.handler.handle_post_auth(Fred)
+        assert Fred.login_attempts == 0
+
+
+class TestBlockUnactivatedUser(object):
+    handler = auth.BlockUnactivatedUser()
+
+    def test_raises_StopAuthentication_if_user_is_unactivated(
+            self, unactivated_user
+    ):
+        with pytest.raises(StopAuthentication) as excinfo:
+            self.handler.handle_post_auth(unactivated_user)
+
+        assert 'In order to use your account' in excinfo.value.reason
+
+
+class TestPluginAuthenticationManager(object):
+
+    def raises_stop_authentication_if_user_isnt_authenticated(
+            self, plugin_manager, mocker, database
+    ):
+        service = self._get_auth_manager(plugin_manager, database)
+        auth = mocker.MagicMock(spec=AuthenticationProvider)
+        plugin_manager.register(self.impls(auth=auth))
+
+        with pytest.raises(StopAuthentication) as excinfo:
+            service.authenticate('doesnt exist', 'nope')
+
+        auth.assert_called_once_with(identifier='doesnt exist', secret='nope')
+        assert excinfo.value.reason == "Wrong username or password."
+
+    def test_runs_failed_hooks_when_stopauthentication_is_raised(
+            self, plugin_manager, mocker, database
+    ):
+        service = self._get_auth_manager(plugin_manager, database)
+        failure = mocker.MagicMock(spec=AuthenticationFailureHandler)
+        plugin_manager.register(self.impls(failure=failure))
+
+        with pytest.raises(StopAuthentication):
+            service.authenticate('doesnt exist', 'nope')
+
+        failure.assert_called_once_with(identifier='doesnt exist')
+
+    def test_runs_post_auth_handler_if_user_authenticates(
+            self, plugin_manager, mocker, Fred, database
+    ):
+        service = self._get_auth_manager(plugin_manager, database)
+        auth = mocker.MagicMock(spec=AuthenticationProvider, return_value=Fred)
+        post_auth = mocker.MagicMock(spec=PostAuthenticationHandler)
+        plugin_manager.register(self.impls(auth=auth, post_auth=post_auth))
+
+        service.authenticate(Fred.email, 'fred')
+
+        auth.assert_called_once_with(identifier=Fred.email, secret='fred')
+        post_auth.assert_called_once_with(user=Fred)
+
+    def test_reraises_if_session_commit_fails(
+            self, mocker, plugin_manager, Fred
+    ):
+
+        class NotAnActualException(Exception):
+            pass
+
+        db = mocker.Mock()
+        db.session.commit.side_effect = NotAnActualException
+        service = self._get_auth_manager(plugin_manager, db)
+
+        with pytest.raises(NotAnActualException):
+            service.authenticate('doesnt exist', 'nope')
+
+        db.session.rollback.assert_called_once_with()
+
+    def _get_auth_manager(self, plugin_manager, db):
+        plugin_manager.add_hookspecs(spec)
+        return auth.PluginAuthenticationManager(
+            plugin_manager, session=db.session
+        )
+
+    @staticmethod
+    def impls(auth=None, post_auth=None, failure=None):
+        impl = HookimplMarker('flaskbb')
+
+        class Impls:
+            if auth is not None:
+
+                @impl
+                def flaskbb_authenticate(self, identifier, secret):
+                    return auth(identifier=identifier, secret=secret)
+
+            if post_auth is not None:
+
+                @impl
+                def flaskbb_post_authenticate(self, user):
+                    post_auth(user=user)
+
+            if failure is not None:
+
+                @impl
+                def flaskbb_authentication_failed(self, identifier):
+                    failure(identifier=identifier)
+
+        return Impls()

+ 131 - 0
tests/unit/auth/test_reauthentication.py

@@ -0,0 +1,131 @@
+from datetime import datetime
+
+import pytest
+from flaskbb.auth.services import reauthentication as reauth
+from flaskbb.core.auth.authentication import (PostReauthenticateHandler,
+                                              ReauthenticateFailureHandler,
+                                              ReauthenticateProvider,
+                                              StopAuthentication)
+from flaskbb.plugins import spec
+from freezegun import freeze_time
+from pluggy import HookimplMarker
+from pytz import UTC
+
+pytestmark = pytest.mark.usefixtures('default_settings')
+
+
+def test_default_reauth_returns_true_if_secret_matches_user(Fred):
+    service = reauth.DefaultFlaskBBReauthProvider()
+
+    assert service.reauthenticate(Fred, 'fred')
+
+
+def test_clears_failed_logins_attempts(Fred):
+    service = reauth.ClearFailedLoginsOnReauth()
+    Fred.login_attempts = 1000
+    service.handle_post_reauth(Fred)
+    assert Fred.login_attempts == 0
+
+
+@freeze_time(datetime(2018, 1, 1, 13, 30))
+def test_marks_failed_login_attempt(Fred):
+    service = reauth.MarkFailedReauth()
+    Fred.login_attempts = 1
+    Fred.last_failed_login = datetime.min.replace(tzinfo=UTC)
+
+    service.handle_reauth_failure(Fred)
+
+    assert Fred.login_attempts == 2
+    assert Fred.last_failed_login == datetime(2018, 1, 1, 13, 30, tzinfo=UTC)
+
+
+class TestPluginAuthenticationManager(object):
+
+    def raises_stop_authentication_if_user_isnt_reauthenticated(
+            self, plugin_manager, mocker, database, Fred
+    ):
+        service = self._get_auth_manager(plugin_manager, database)
+        reauth = mocker.MagicMock(spec=ReauthenticateProvider)
+        plugin_manager.register(self.impls(reauth=reauth))
+
+        with pytest.raises(StopAuthentication) as excinfo:
+            service.reauthenticate(Fred, 'nope')
+
+        reauth.assert_called_once_with(user=Fred, secret='nope')
+        assert excinfo.value.reason == "Wrong password."
+
+    def test_runs_failed_hooks_when_stopauthentication_is_raised(
+            self, plugin_manager, mocker, database, Fred
+    ):
+        service = self._get_auth_manager(plugin_manager, database)
+        failure = mocker.MagicMock(spec=ReauthenticateFailureHandler)
+        plugin_manager.register(self.impls(failure=failure))
+
+        with pytest.raises(StopAuthentication):
+            service.reauthenticate(Fred, 'nope')
+
+        failure.assert_called_once_with(user=Fred)
+
+    def test_runs_post_reauth_handler_if_user_authenticates(
+            self, plugin_manager, mocker, Fred, database
+    ):
+        service = self._get_auth_manager(plugin_manager, database)
+        reauth = mocker.MagicMock(
+            spec=ReauthenticateProvider, return_value=Fred
+        )
+        post_reauth = mocker.MagicMock(spec=PostReauthenticateHandler)
+        plugin_manager.register(
+            self.impls(reauth=reauth, post_reauth=post_reauth)
+        )
+
+        service.reauthenticate(Fred, 'fred')
+
+        reauth.assert_called_once_with(user=Fred, secret='fred')
+        post_reauth.assert_called_once_with(user=Fred)
+
+    def test_reraises_if_session_commit_fails(
+            self, mocker, plugin_manager, Fred
+    ):
+
+        class NotAnActualException(Exception):
+            pass
+
+        db = mocker.Mock()
+        db.session.commit.side_effect = NotAnActualException
+        service = self._get_auth_manager(plugin_manager, db)
+
+        with pytest.raises(NotAnActualException):
+            service.reauthenticate('doesnt exist', 'nope')
+
+        db.session.rollback.assert_called_once_with()
+
+    def _get_auth_manager(self, plugin_manager, db):
+        plugin_manager.add_hookspecs(spec)
+        return reauth.PluginReauthenticationManager(
+            plugin_manager, session=db.session
+        )
+
+    @staticmethod
+    def impls(reauth=None, post_reauth=None, failure=None):
+        impl = HookimplMarker('flaskbb')
+
+        class Impls:
+            if reauth is not None:
+
+                @impl
+                def flaskbb_reauth_attempt(self, user, secret):
+                    return reauth(user=user, secret=secret)
+
+            if post_reauth is not None:
+
+                @impl
+                def flaskbb_post_reauth(self, user):
+                    post_reauth(user=user)
+
+            if failure is not None:
+
+                @impl
+                def flaskbb_reauth_failed(self, user):
+                    failure(user=user)
+
+        return Impls()