From 4c1c64771ed569154bf59a0ab56a717f3ebf6db2 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Wed, 18 Jan 2017 13:52:33 +0100 Subject: [PATCH] SMTP authentication backend. see #989 --- doc/configuration.rst | 34 +++- doc/upgrade.rst | 14 ++ modoboa/admin/tests/test_account.py | 2 +- .../core/commands/templates/settings.py.tpl | 10 +- modoboa/core/tests/test_authentication.py | 97 +++++++++++ modoboa/core/tests/test_core.py | 141 +--------------- modoboa/core/tests/test_ldap.py | 157 ++++++++++++++++++ modoboa/lib/authbackends.py | 55 ++++-- test-requirements.txt | 3 +- 9 files changed, 357 insertions(+), 156 deletions(-) create mode 100644 modoboa/core/tests/test_authentication.py create mode 100644 modoboa/core/tests/test_ldap.py diff --git a/doc/configuration.rst b/doc/configuration.rst index abe1bbe8c..7f17c2857 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -240,14 +240,14 @@ This parameter is optional but you must ensure it is set to ``False`` The default configuration file provided by the ``modoboa-admin.py`` command is properly configured. -**** -LDAP -**** +*********************** +External authentication +*********************** .. _ldap_auth: -Authentication -============== +LDAP +==== Modoboa supports external LDAP authentication using the following extra components: @@ -381,6 +381,30 @@ Modoboa. to manually delete the Modoboa record, it will be recreated on the next user login. +.. _smtp_auth: + +SMTP +==== + +It is possible to use an existing SMTP server as an authentication +source. To enable this feature, edit the :file:`settings.py` file and +change the following setting: + +.. sourcecode:: python + + AUTHENTICATION_BACKENDS = ( + 'modoboa.lib.authbackends.SMTPBackend', + 'django.contrib.auth.backends.ModelBackend', + ) + +SMTP server location can be customized using the following settings: + +.. sourcecode:: python + + AUTH_SMTP_SERVER_ADDRESS = 'localhost' + AUTH_SMTP_SERVER_PORT = 25 + AUTH_SMTP_SECURED_MODE = None # 'ssl' or 'starttls' are accepted + ******************** Database maintenance ******************** diff --git a/doc/upgrade.rst b/doc/upgrade.rst index 4b6878436..bce5190d8 100644 --- a/doc/upgrade.rst +++ b/doc/upgrade.rst @@ -84,6 +84,20 @@ documentation. Specific instructions ********************* +1.7.0 +===== + +Modoboa used to provide a custom authentication backend +(``modoboa.lib.authbackends.SimpleBackend``) but it has been +removed. Please modify you :file:`settings.py` file as follows: + +.. sourcecode:: python + + AUTHENTICATION_BACKENDS = ( + # Other backends before... + 'django.contrib.auth.backends.ModelBackend', + ) + 1.6.1 ===== diff --git a/modoboa/admin/tests/test_account.py b/modoboa/admin/tests/test_account.py index 6266efcb1..ab7460c1c 100644 --- a/modoboa/admin/tests/test_account.py +++ b/modoboa/admin/tests/test_account.py @@ -217,7 +217,7 @@ class AccountTestCase(ModoTestCase): @skipIf(NO_LDAP, "No ldap module installed") @override_settings(AUTHENTICATION_BACKENDS=( 'modoboa.lib.authbackends.LDAPBackend', - 'modoboa.lib.authbackends.SimpleBackend', + 'django.contrib.auth.backends.ModelBackend', )) class LDAPAccountTestCase(test_core.LDAPTestCaseMixin, ModoTestCase): """Check LDAP related code.""" diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index 1ed22bc62..74a6b86fd 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -89,9 +89,17 @@ MIDDLEWARE_CLASSES = ( ) AUTHENTICATION_BACKENDS = ( - 'modoboa.lib.authbackends.SimpleBackend', + # 'modoboa.lib.authbackends.LDAPBackend', + # 'modoboa.lib.authbackends.SMTPBackend', + 'django.contrib.auth.backends.ModelBackend', ) +# SMTP authentication +# AUTH_SMTP_SERVER_ADDRESS = 'localhost' +# AUTH_SMTP_SERVER_PORT = 25 +# AUTH_SMTP_SECURED_MODE = None # 'ssl' or 'starttls' are accepted + + TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', diff --git a/modoboa/core/tests/test_authentication.py b/modoboa/core/tests/test_authentication.py new file mode 100644 index 000000000..764dadaf0 --- /dev/null +++ b/modoboa/core/tests/test_authentication.py @@ -0,0 +1,97 @@ +"""Tests for core application.""" + +import smtplib + +from mock import patch + +from django.core.urlresolvers import reverse +from django.test import override_settings + +from modoboa.lib.tests import ModoTestCase + +from .. import factories +from .. import models + + +class AuthenticationTestCase(ModoTestCase): + """Validate authentication scenarios.""" + + @classmethod + def setUpTestData(cls): + """Create test data.""" + super(AuthenticationTestCase, cls).setUpTestData() + cls.account = factories.UserFactory( + username="user@test.com", groups=('SimpleUsers',) + ) + + def test_authentication(self): + """Validate simple case.""" + self.client.logout() + data = {"username": "user@test.com", "password": "toto"} + response = self.client.post(reverse("core:login"), data) + self.assertEqual(response.status_code, 302) + self.assertTrue(response.url.endswith(reverse("core:user_index"))) + + response = self.client.post(reverse("core:logout"), {}) + self.assertEqual(response.status_code, 302) + + data = {"username": "admin", "password": "password"} + response = self.client.post(reverse("core:login"), data) + self.assertEqual(response.status_code, 302) + self.assertTrue(response.url.endswith(reverse("core:dashboard"))) + + +@override_settings(AUTHENTICATION_BACKENDS=( + "modoboa.lib.authbackends.SMTPBackend", + "django.contrib.auth.backends.ModelBackend" +)) +class SMTPAuthenticationTestCase(ModoTestCase): + """Validate SMTP authentication scenarios.""" + + def _test_smtp_authentication(self, mock_smtp): + """Common code to check authentication""" + self.client.logout() + username = "user@unknown.test" + password = "toto" + data = {"username": username, "password": password} + response = self.client.post(reverse("core:login"), data) + self.assertEqual(response.status_code, 302) + self.assertTrue(response.url.endswith(reverse("core:user_index"))) + mock_smtp.return_value.login.assert_called_once_with( + username, password) + self.assertTrue( + models.User.objects.filter(username=username).exists()) + + @patch("smtplib.SMTP") + def test_smtp_authentication(self, mock_smtp): + """Check simple SMTP authentication.""" + self._test_smtp_authentication(mock_smtp) + + @patch("smtplib.SMTP_SSL") + @override_settings(AUTH_SMTP_SECURED_MODE="ssl") + def test_smtp_authentication_over_ssl(self, mock_smtp): + """Check SMTP authentication over SSL.""" + self._test_smtp_authentication(mock_smtp) + + @patch("smtplib.SMTP") + @override_settings(AUTH_SMTP_SECURED_MODE="starttls") + def test_smtp_authentication_over_starttls(self, mock_smtp): + """Check SMTP authentication over STARTTLS.""" + self._test_smtp_authentication(mock_smtp) + + @patch("smtplib.SMTP") + def test_smtp_authentication_failure(self, mock_smtp): + """Check SMTP authentication failure.""" + instance = mock_smtp.return_value + instance.login.side_effect = smtplib.SMTPAuthenticationError( + 450, "User not found") + self.client.logout() + username = "user@unknown.test" + password = "toto" + data = {"username": username, "password": password} + response = self.client.post(reverse("core:login"), data) + self.assertEqual(response.status_code, 401) + mock_smtp.return_value.login.assert_called_once_with( + username, password) + self.assertFalse( + models.User.objects.filter(username=username).exists()) diff --git a/modoboa/core/tests/test_core.py b/modoboa/core/tests/test_core.py index 3e8de2b6f..9bc675df9 100644 --- a/modoboa/core/tests/test_core.py +++ b/modoboa/core/tests/test_core.py @@ -1,17 +1,12 @@ """Tests for core application.""" -from unittest import skipIf - import httmock from django.core import management from django.core.urlresolvers import reverse -from django.test import override_settings, TestCase -from django.utils.functional import cached_property +from django.test import TestCase -from modoboa.lib import exceptions from modoboa.lib.tests import ModoTestCase -from modoboa.lib.tests import NO_LDAP from .. import factories from .. import mocks @@ -58,120 +53,8 @@ class ChangeDefaultAdminTestCase(TestCase): self.client.login(username="modoadmin", password="password")) -@skipIf(NO_LDAP, 'No ldap module installed') -class LDAPTestCaseMixin(object): - """Set of methods used to test LDAP features.""" - - @cached_property - def ldapauthbackend(self): - """Return LDAPAuthBackend instance.""" - from modoboa.lib.ldap_utils import LDAPAuthBackend - return LDAPAuthBackend() - - def activate_ldap_authentication(self): - """Modify settings.""" - self.set_global_parameters({ - "authentication_type": "ldap", - "ldap_server_port": 3389 - }) - - def restore_user_password(self, username, new_password): - """Restore user password to its initial state.""" - for password in ["Toto1234", "test"]: - try: - self.ldapauthbackend.update_user_password( - username, password, new_password) - except exceptions.InternalError: - pass - else: - return - raise RuntimeError("Can't restore user password.") - - def authenticate(self, user, password, restore_before=True): - """Restore password and authenticate user.""" - self.client.logout() - if restore_before: - self.restore_user_password(user, password) - self.assertTrue( - self.client.login(username=user, password=password)) - - def searchbind_mode(self): - """Apply settings required by the searchbind mode.""" - self.set_global_parameters({ - "ldap_auth_method": "searchbind", - "ldap_bind_dn": "cn=admin,dc=example,dc=com", - "ldap_bind_password": "test", - "ldap_search_base": "ou=users,dc=example,dc=com" - }) - - def directbind_mode(self): - """Apply settings required by the directbind mode.""" - self.set_global_parameters({ - "ldap_auth_method": "directbind", - "ldap_user_dn_template": "cn=%(user)s,ou=users,dc=example,dc=com" - }) - - -@override_settings(AUTHENTICATION_BACKENDS=( - 'modoboa.lib.authbackends.LDAPBackend', - 'modoboa.lib.authbackends.SimpleBackend', -)) -class LDAPAuthenticationTestCase(LDAPTestCaseMixin, ModoTestCase): - """Validate LDAP authentication scenarios.""" - - def setUp(self): - """Create test data.""" - super(LDAPAuthenticationTestCase, self).setUp() - self.activate_ldap_authentication() - - def check_created_user(self, username, group="SimpleUsers", with_mb=True): - """Check that created user is valid.""" - user = models.User.objects.get(username=username) - self.assertEqual(user.role, group) - if with_mb: - self.assertEqual(user.email, username) - self.assertEqual(user.mailbox.domain.name, "example.com") - self.assertEqual(user.mailbox.full_address, username) - - @override_settings(AUTH_LDAP_USER_DN_TEMPLATE=None) - def test_searchbind_authentication(self): - """Test the bind&search method.""" - self.searchbind_mode() - username = "testuser@example.com" - self.authenticate(username, "test") - self.check_created_user(username) - - self.set_global_parameters({ - "ldap_admin_groups": "admins", - "ldap_groups_search_base": "ou=groups,dc=example,dc=com" - }) - username = "mailadmin@example.com" - self.authenticate(username, "test", False) - self.check_created_user(username, "DomainAdmins") - - def test_directbind_authentication(self): - """Test the directbind method.""" - self.client.logout() - self.directbind_mode() - - # 1: must fail because usernames of simple users must be email - # addresses - username = "testuser" - with self.assertRaises(TypeError): - self.client.login(username=username, password="test") - - # 1: must work because usernames of domain admins are not - # always email addresses - self.set_global_parameters({ - "ldap_admin_groups": "admins", - "ldap_groups_search_base": "ou=groups,dc=example,dc=com" - }) - username = "mailadmin" - self.authenticate(username, "test", False) - self.check_created_user(username, "DomainAdmins", False) - - -class ProfileTestCase(LDAPTestCaseMixin, ModoTestCase): +class ProfileTestCase(ModoTestCase): + """Profile related tests.""" @classmethod def setUpTestData(cls): @@ -217,24 +100,6 @@ class ProfileTestCase(LDAPTestCaseMixin, ModoTestCase): self.client.login(username="user@test.com", password="Toto1234") ) - @override_settings(AUTHENTICATION_BACKENDS=( - 'modoboa.lib.authbackends.LDAPBackend', - 'modoboa.lib.authbackends.SimpleBackend', - )) - def test_update_password_ldap(self): - """Update password for an LDAP user.""" - self.activate_ldap_authentication() - self.searchbind_mode() - - username = "testuser@example.com" - self.authenticate(username, "test") - self.ajax_post( - reverse("core:user_profile"), - {"language": "en", "oldpassword": "test", - "newpassword": "Toto1234", "confirmation": "Toto1234"} - ) - self.authenticate(username, "Toto1234", False) - class APIAccessFormTestCase(ModoTestCase): diff --git a/modoboa/core/tests/test_ldap.py b/modoboa/core/tests/test_ldap.py new file mode 100644 index 000000000..182fae6dd --- /dev/null +++ b/modoboa/core/tests/test_ldap.py @@ -0,0 +1,157 @@ +"""Tests for core application.""" + +from unittest import skipIf + +from django.core.urlresolvers import reverse +from django.test import override_settings +from django.utils.functional import cached_property + +from modoboa.lib import exceptions +from modoboa.lib.tests import ModoTestCase +from modoboa.lib.tests import NO_LDAP + +from .. import factories +from .. import models + + +@skipIf(NO_LDAP, 'No ldap module installed') +class LDAPTestCaseMixin(object): + """Set of methods used to test LDAP features.""" + + @cached_property + def ldapauthbackend(self): + """Return LDAPAuthBackend instance.""" + from modoboa.lib.ldap_utils import LDAPAuthBackend + return LDAPAuthBackend() + + def activate_ldap_authentication(self): + """Modify settings.""" + self.set_global_parameters({ + "authentication_type": "ldap", + "ldap_server_port": 3389 + }) + + def restore_user_password(self, username, new_password): + """Restore user password to its initial state.""" + for password in ["Toto1234", "test"]: + try: + self.ldapauthbackend.update_user_password( + username, password, new_password) + except exceptions.InternalError: + pass + else: + return + raise RuntimeError("Can't restore user password.") + + def authenticate(self, user, password, restore_before=True): + """Restore password and authenticate user.""" + self.client.logout() + if restore_before: + self.restore_user_password(user, password) + self.assertTrue( + self.client.login(username=user, password=password)) + + def searchbind_mode(self): + """Apply settings required by the searchbind mode.""" + self.set_global_parameters({ + "ldap_auth_method": "searchbind", + "ldap_bind_dn": "cn=admin,dc=example,dc=com", + "ldap_bind_password": "test", + "ldap_search_base": "ou=users,dc=example,dc=com" + }) + + def directbind_mode(self): + """Apply settings required by the directbind mode.""" + self.set_global_parameters({ + "ldap_auth_method": "directbind", + "ldap_user_dn_template": "cn=%(user)s,ou=users,dc=example,dc=com" + }) + + +@override_settings(AUTHENTICATION_BACKENDS=( + 'modoboa.lib.authbackends.LDAPBackend', + 'django.contrib.auth.backends.ModelBackend' +)) +class LDAPAuthenticationTestCase(LDAPTestCaseMixin, ModoTestCase): + """Validate LDAP authentication scenarios.""" + + def setUp(self): + """Create test data.""" + super(LDAPAuthenticationTestCase, self).setUp() + self.activate_ldap_authentication() + + def check_created_user(self, username, group="SimpleUsers", with_mb=True): + """Check that created user is valid.""" + user = models.User.objects.get(username=username) + self.assertEqual(user.role, group) + if with_mb: + self.assertEqual(user.email, username) + self.assertEqual(user.mailbox.domain.name, "example.com") + self.assertEqual(user.mailbox.full_address, username) + + @override_settings(AUTH_LDAP_USER_DN_TEMPLATE=None) + def test_searchbind_authentication(self): + """Test the bind&search method.""" + self.searchbind_mode() + username = "testuser@example.com" + self.authenticate(username, "test") + self.check_created_user(username) + + self.set_global_parameters({ + "ldap_admin_groups": "admins", + "ldap_groups_search_base": "ou=groups,dc=example,dc=com" + }) + username = "mailadmin@example.com" + self.authenticate(username, "test", False) + self.check_created_user(username, "DomainAdmins") + + def test_directbind_authentication(self): + """Test the directbind method.""" + self.client.logout() + self.directbind_mode() + + # 1: must fail because usernames of simple users must be email + # addresses + username = "testuser" + with self.assertRaises(TypeError): + self.client.login(username=username, password="test") + + # 1: must work because usernames of domain admins are not + # always email addresses + self.set_global_parameters({ + "ldap_admin_groups": "admins", + "ldap_groups_search_base": "ou=groups,dc=example,dc=com" + }) + username = "mailadmin" + self.authenticate(username, "test", False) + self.check_created_user(username, "DomainAdmins", False) + + +class ProfileTestCase(LDAPTestCaseMixin, ModoTestCase): + """Profile related tests.""" + + @classmethod + def setUpTestData(cls): + """Create test data.""" + super(ProfileTestCase, cls).setUpTestData() + cls.account = factories.UserFactory( + username="user@test.com", groups=('SimpleUsers',) + ) + + @override_settings(AUTHENTICATION_BACKENDS=( + 'modoboa.lib.authbackends.LDAPBackend', + 'django.contrib.auth.backends.ModelBackend' + )) + def test_update_password_ldap(self): + """Update password for an LDAP user.""" + self.activate_ldap_authentication() + self.searchbind_mode() + + username = "testuser@example.com" + self.authenticate(username, "test") + self.ajax_post( + reverse("core:user_profile"), + {"language": "en", "oldpassword": "test", + "newpassword": "Toto1234", "confirmation": "Toto1234"} + ) + self.authenticate(username, "Toto1234", False) diff --git a/modoboa/lib/authbackends.py b/modoboa/lib/authbackends.py index b603855f3..354e1d37d 100644 --- a/modoboa/lib/authbackends.py +++ b/modoboa/lib/authbackends.py @@ -1,29 +1,64 @@ """Custom authentication backends.""" -from django.contrib.auth.backends import ModelBackend +import smtplib + +from django.conf import settings from modoboa.core.models import User from modoboa.lib.email_utils import split_mailbox from modoboa.parameters import tools as param_tools -class SimpleBackend(ModelBackend): - """Simple authentication backend.""" +class SMTPBackend(object): + """A backend to authenticate against an SMTP server.""" def authenticate(self, username=None, password=None): + """Check the username/password and return a User.""" + if type(username) is unicode: + username = username.encode("utf-8") + if type(password) is unicode: + password = password.encode("utf-8") + + host = getattr(settings, "AUTH_SMTP_SERVER_ADDRESS", "localhost") + port = getattr(settings, "AUTH_SMTP_SERVER_PORT", 25) + secured_mode = getattr(settings, "AUTH_SMTP_SECURED_MODE", None) + if secured_mode == "ssl": + smtp = smtplib.SMTP_SSL(host, port) + else: + smtp = smtplib.SMTP(host, port) + if secured_mode == "starttls": + smtp.starttls() try: - user = User.objects.get(username=username) - except User.DoesNotExist: - return None - if not user.check_password(password): + smtp.login(username, password) + except smtplib.SMTPException: return None + return self.get_or_create_user(username) + + def get_or_create_user(self, username): + """Get a user or create it the first time. + + .. note:: + + We assume the username is a valid email address. + """ + user, created = User.objects.get_or_create( + username__iexact=username, defaults={ + "username": username.lower(), "email": username.lower() + } + ) + if created: + populate_callback(user) return user - def get_user(self, user_id): + def get_user(self, user_pk): + """Retrieve a User instance.""" + user = None try: - return User.objects.get(pk=user_id) + user = User.objects.get(pk=user_pk) except User.DoesNotExist: - return None + pass + return user + try: from django_auth_ldap.backend import ( diff --git a/test-requirements.txt b/test-requirements.txt index 554f0a065..7de52b6e7 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,4 +1,5 @@ factory-boy>=2.4 -testfixtures==4.7.0 +mock==2.0.0 httmock==1.2.5 +testfixtures==4.7.0 tox