SMTP authentication backend.

see #989
This commit is contained in:
Antoine Nguyen
2017-01-18 13:52:33 +01:00
parent afc64e23db
commit 4c1c64771e
9 changed files with 357 additions and 156 deletions
+29 -5
View File
@@ -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
********************
+14
View File
@@ -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
=====
+1 -1
View File
@@ -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."""
@@ -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',
+97
View File
@@ -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())
+3 -138
View File
@@ -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):
+157
View File
@@ -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)
+45 -10
View File
@@ -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 (
+2 -1
View File
@@ -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