|
@@ -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()
|