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_load_translations
 .. autofunction:: flaskbb_additional_setup
 .. autofunction:: flaskbb_additional_setup
 
 
+
 FlaskBB CLI Hooks
 FlaskBB CLI Hooks
 ~~~~~~~~~~~~~~~~~
 ~~~~~~~~~~~~~~~~~
 
 
@@ -63,6 +64,12 @@ FlaskBB Event Hooks
 .. autofunction:: flaskbb_event_topic_save_before
 .. autofunction:: flaskbb_event_topic_save_before
 .. autofunction:: flaskbb_event_topic_save_after
 .. autofunction:: flaskbb_event_topic_save_after
 .. autofunction:: flaskbb_event_user_registered
 .. 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
 FlaskBB Form Hooks

+ 52 - 2
flaskbb/auth/plugins.py

@@ -7,14 +7,21 @@
     :copyright: (c) 2014-2018 the FlaskBB Team.
     :copyright: (c) 2014-2018 the FlaskBB Team.
     :license: BSD, see LICENSE for more details
     :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_babelplus import gettext as _
-from flask_login import login_user
+from flask_login import current_user, login_user, logout_user
 
 
 from . import impl
 from . import impl
+from ..core.auth.authentication import ForceLogout
 from ..user.models import User
 from ..user.models import User
 from ..utils.settings import flaskbb_config
 from ..utils.settings import flaskbb_config
+from .services.authentication import (BlockUnactivatedUser, ClearFailedLogins,
+                                      DefaultFlaskBBAuthProvider,
+                                      MarkFailedLogin)
 from .services.factories import account_activator_factory
 from .services.factories import account_activator_factory
+from .services.reauthentication import (ClearFailedLoginsOnReauth,
+                                        DefaultFlaskBBReauthProvider,
+                                        MarkFailedReauth)
 
 
 
 
 @impl
 @impl
@@ -34,3 +41,46 @@ def flaskbb_event_user_registered(username):
     else:
     else:
         login_user(user)
         login_user(user)
         flash(_("Thanks for registering."), "success")
         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 .activation import AccountActivator
+from .authentication import (BlockTooManyFailedLogins, BlockUnactivatedUser,
+                             DefaultFlaskBBAuthProvider,
+                             FailedLoginConfiguration, MarkFailedLogin,
+                             PluginAuthenticationManager)
 from .factories import (account_activator_factory,
 from .factories import (account_activator_factory,
+                        authentication_manager_factory,
+                        reauthentication_manager_factory,
                         registration_service_factory, reset_service_factory)
                         registration_service_factory, reset_service_factory)
 from .password import ResetPasswordService
 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 ...user.repo import UserRepository
 from ...utils.settings import flaskbb_config
 from ...utils.settings import flaskbb_config
 from .activation import AccountActivator
 from .activation import AccountActivator
+from .authentication import PluginAuthenticationManager
 from .password import ResetPasswordService
 from .password import ResetPasswordService
+from .reauthentication import PluginReauthenticationManager
 from .registration import (EmailUniquenessValidator, RegistrationService,
 from .registration import (EmailUniquenessValidator, RegistrationService,
                            UsernameRequirements, UsernameUniquenessValidator,
                            UsernameRequirements, UsernameUniquenessValidator,
                            UsernameValidator)
                            UsernameValidator)
@@ -62,3 +64,10 @@ def account_activator_factory():
         current_app.config['SECRET_KEY'], expiry=timedelta(hours=1)
         current_app.config['SECRET_KEY'], expiry=timedelta(hours=1)
     )
     )
     return AccountActivator(token_serializer, User)
     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
     :license: BSD, see LICENSE for more details
 """
 """
 
 
-from collections import namedtuple
-
+import attr
 from flask_babelplus import gettext as _
 from flask_babelplus import gettext as _
 from sqlalchemy import func
 from sqlalchemy import func
 
 
@@ -22,9 +21,12 @@ __all__ = (
     "UsernameUniquenessValidator"
     "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):
 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_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 (ForgotPasswordForm, LoginForm,
 from flaskbb.auth.forms import (ForgotPasswordForm, LoginForm,
                                 LoginRecaptchaForm, ReauthForm, RegisterForm,
                                 LoginRecaptchaForm, ReauthForm, RegisterForm,
                                 RequestActivationForm, ResetPasswordForm)
                                 RequestActivationForm, ResetPasswordForm)
-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.utils.helpers import (anonymous_required, enforce_recaptcha,
 from flaskbb.utils.helpers import (anonymous_required, enforce_recaptcha,
                                    format_timedelta, get_available_languages,
                                    format_timedelta, get_available_languages,
                                    redirect_or_next, register_view,
                                    redirect_or_next, register_view,
@@ -31,12 +28,15 @@ from flaskbb.utils.helpers import (anonymous_required, enforce_recaptcha,
                                    requires_unactivated)
                                    requires_unactivated)
 from flaskbb.utils.settings import flaskbb_config
 from flaskbb.utils.settings import flaskbb_config
 
 
+from ..core.auth.authentication import StopAuthentication
 from ..core.auth.registration import UserRegistrationInfo
 from ..core.auth.registration import UserRegistrationInfo
 from ..core.exceptions import StopValidation, ValidationError
 from ..core.exceptions import StopValidation, ValidationError
 from ..core.tokens import TokenError
 from ..core.tokens import TokenError
 from .plugins import impl
 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__)
 logger = logging.getLogger(__name__)
 
 
@@ -53,6 +53,9 @@ class Logout(MethodView):
 class Login(MethodView):
 class Login(MethodView):
     decorators = [anonymous_required]
     decorators = [anonymous_required]
 
 
+    def __init__(self, authentication_manager_factory):
+        self.authentication_manager_factory = authentication_manager_factory
+
     def form(self):
     def form(self):
         if enforce_recaptcha(limiter):
         if enforce_recaptcha(limiter):
             return LoginRecaptchaForm()
             return LoginRecaptchaForm()
@@ -64,19 +67,17 @@ class Login(MethodView):
     def post(self):
     def post(self):
         form = self.form()
         form = self.form()
         if form.validate_on_submit():
         if form.validate_on_submit():
+            auth_manager = self.authentication_manager_factory()
             try:
             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"))
                 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)
         return render_template("auth/login.html", form=form)
 
 
@@ -85,6 +86,9 @@ class Reauth(MethodView):
     decorators = [login_required, limiter.exempt]
     decorators = [login_required, limiter.exempt]
     form = ReauthForm
     form = ReauthForm
 
 
+    def __init__(self, reauthentication_factory):
+        self.reauthentication_factory = reauthentication_factory
+
     def get(self):
     def get(self):
         if not login_fresh():
         if not login_fresh():
             return render_template("auth/reauth.html", form=self.form())
             return render_template("auth/reauth.html", form=self.form())
@@ -93,12 +97,21 @@ class Reauth(MethodView):
     def post(self):
     def post(self):
         form = self.form()
         form = self.form()
         if form.validate_on_submit():
         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()
                 confirm_login()
                 flash(_("Reauthenticated."), "success")
                 flash(_("Reauthenticated."), "success")
                 return redirect_or_next(current_user.url)
                 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)
         return render_template("auth/reauth.html", form=form)
 
 
 
 
@@ -142,6 +155,7 @@ class Register(MethodView):
                     db.session.commit()
                     db.session.commit()
                 except Exception:  # noqa
                 except Exception:  # noqa
                     logger.exception("Database error while resetting password")
                     logger.exception("Database error while resetting password")
+                    db.session.rollback()
                     flash(
                     flash(
                         _(
                         _(
                             "Could not process registration due"
                             "Could not process registration due"
@@ -210,7 +224,6 @@ class ResetPassword(MethodView):
                 service.reset_password(
                 service.reset_password(
                     token, form.email.data, form.password.data
                     token, form.email.data, form.password.data
                 )
                 )
-                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'))
@@ -222,6 +235,14 @@ class ResetPassword(MethodView):
                 logger.exception("Error when resetting password")
                 logger.exception("Error when resetting password")
                 flash(_('Error when resetting password'))
                 flash(_('Error when resetting password'))
                 return redirect(url_for('auth.forgot_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")
             flash(_("Your password has been updated."), "success")
             return redirect(url_for("auth.login"))
             return redirect(url_for("auth.login"))
@@ -285,6 +306,7 @@ class ActivateAccount(MethodView):
                 db.session.commit()
                 db.session.commit()
             except Exception:  # noqa
             except Exception:  # noqa
                 logger.exception("Database error while activating account")
                 logger.exception("Database error while activating account")
+                db.session.rollback()
                 flash(
                 flash(
                     _(
                     _(
                         "Could not activate account due to an unrecoverable error"
                         "Could not activate account due to an unrecoverable error"
@@ -345,8 +367,22 @@ def flaskbb_load_blueprints(app):
     )(auth)
     )(auth)
 
 
     register_view(auth, routes=['/logout'], view_func=Logout.as_view('logout'))
     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(
     register_view(
         auth,
         auth,
         routes=['/register'],
         routes=['/register'],
@@ -382,7 +418,6 @@ def flaskbb_load_blueprints(app):
             account_activator_factory=account_activator_factory
             account_activator_factory=account_activator_factory
         )
         )
     )
     )
-
     register_view(
     register_view(
         auth,
         auth,
         routes=['/activate/confirm', '/activate/confirm/<token>'],
         routes=['/activate/confirm', '/activate/confirm/<token>'],
@@ -391,4 +426,5 @@ def flaskbb_load_blueprints(app):
             account_activator_factory=account_activator_factory
             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'])

+ 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
 import attr
 
 
 from ..._compat import ABC
 from ..._compat import ABC
-from ..exceptions import ValidationError, StopValidation
 
 
 
 
 @attr.s(hash=True, cmp=False, repr=True, frozen=True)
 @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
 # Form hooks
 @spec
 @spec
 def flaskbb_form_new_post(form):
 def flaskbb_form_new_post(form):
@@ -484,6 +652,5 @@ def flaskbb_tpl_form_new_topic_after(form):
     rendered (but before the submit button).
     rendered (but before the submit button).
 
 
     in :file:`templates/forum/new_topic.html`
     in :file:`templates/forum/new_topic.html`
-
     :param form: The form object.
     :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()