diff --git a/modoboa/core/app_settings.py b/modoboa/core/app_settings.py index 788983144..0cf69528a 100644 --- a/modoboa/core/app_settings.py +++ b/modoboa/core/app_settings.py @@ -7,6 +7,8 @@ from django.conf import settings from django.contrib.auth import password_validation from django.utils.translation import ugettext as _, ugettext_lazy +from modoboa.core.password_hashers import get_dovecot_schemes +from modoboa.core.password_hashers.base import PasswordHasher from modoboa.lib import fields as lib_fields from modoboa.lib.form_utils import ( HorizontalRadioSelect, SeparatorField, YesNoField @@ -45,14 +47,9 @@ class GeneralParametersForm(param_forms.AdminParametersForm): password_scheme = forms.ChoiceField( label=ugettext_lazy("Default password scheme"), - choices=[("sha512crypt", "sha512crypt"), - ("sha256crypt", "sha256crypt"), - ("blfcrypt", "bcrypt"), - ("md5crypt", ugettext_lazy("md5crypt (weak)")), - ("sha256", ugettext_lazy("sha256 (weak)")), - ("md5", ugettext_lazy("md5 (weak)")), - ("crypt", ugettext_lazy("crypt (weak)")), - ("plain", ugettext_lazy("plain (weak)"))], + choices=[(hasher.name, ugettext_lazy(hasher.label)) + for hasher in PasswordHasher.get_password_hashers() + if hasher().scheme in get_dovecot_schemes()], initial="sha512crypt", help_text=ugettext_lazy("Scheme used to crypt mailbox passwords"), widget=forms.Select(attrs={"class": "form-control"}) diff --git a/modoboa/core/password_hashers/__init__.py b/modoboa/core/password_hashers/__init__.py index 0eb35c6fa..661251784 100644 --- a/modoboa/core/password_hashers/__init__.py +++ b/modoboa/core/password_hashers/__init__.py @@ -12,6 +12,7 @@ from modoboa.core.password_hashers.advanced import ( # NOQA:F401 from modoboa.core.password_hashers.base import ( # NOQA:F401 CRYPTHasher, MD5Hasher, PLAINHasher, SHA256Hasher ) +from modoboa.lib.sysutils import doveadm_cmd def get_password_hasher(scheme): @@ -29,3 +30,20 @@ def get_password_hasher(scheme): except KeyError: hasher = PLAINHasher return hasher + + +def get_dovecot_schemes(): + """Return schemes supported by dovecot""" + supported_schemes = None + try: + _, schemes = doveadm_cmd("pw -l") + except OSError: + pass + else: + supported_schemes = ["{{{}}}".format(scheme) + for scheme in schemes.split()] + + if not supported_schemes: + supported_schemes = ['{PLAIN}'] + + return supported_schemes diff --git a/modoboa/core/password_hashers/advanced.py b/modoboa/core/password_hashers/advanced.py index 854cfb9ad..8aaaf506c 100644 --- a/modoboa/core/password_hashers/advanced.py +++ b/modoboa/core/password_hashers/advanced.py @@ -21,6 +21,7 @@ class BLFCRYPTHasher(PasswordHasher): Supports rounds and provides compatibility with dovecot on *BSD systems. """ + @property def scheme(self): return "{BLF-CRYPT}" if self._target == "local" else "{CRYPT}" @@ -43,12 +44,13 @@ class BLFCRYPTHasher(PasswordHasher): class MD5CRYPTHasher(PasswordHasher): - """ MD5-CRYPT password hasher. This scheme can't be considered as secure anymore. """ + _weak = True + @property def scheme(self): return "{MD5-CRYPT}" if self._target == "local" else "{CRYPT}" @@ -64,13 +66,13 @@ class MD5CRYPTHasher(PasswordHasher): class SHA256CRYPTHasher(PasswordHasher): - """ SHA256-CRYPT password hasher. Supports rounds and is a good compromise between security and performance. """ + @property def scheme(self): return "{SHA256-CRYPT}" if self._target == "local" else "{CRYPT}" @@ -88,13 +90,13 @@ class SHA256CRYPTHasher(PasswordHasher): class SHA512CRYPTHasher(PasswordHasher): - """ SHA512-CRYPT password hasher. Supports rounds and is the strongest scheme provided by Modoboa. Requires more resource than SHA256-CRYPT. """ + @property def scheme(self): return "{SHA512-CRYPT}" if self._target == "local" else "{CRYPT}" diff --git a/modoboa/core/password_hashers/base.py b/modoboa/core/password_hashers/base.py index 93b316919..ce05f4e72 100644 --- a/modoboa/core/password_hashers/base.py +++ b/modoboa/core/password_hashers/base.py @@ -17,12 +17,30 @@ from random import Random from django.utils.crypto import constant_time_compare from django.utils.encoding import force_bytes, force_text +from six import with_metaclass -class PasswordHasher(object): +class MetaHasher(type): + """ + PasswordHasher Metaclass + Allow classmethod to be properties + """ + @property + def name(cls): + """Returns the name of the hasher""" + return cls.__name__.rstrip('Hasher').lower() + + @property + def label(cls): + """Returns the label of the hasher""" + return cls.name if not cls._weak else "{} (weak)".format(cls.name) + + +class PasswordHasher(with_metaclass(MetaHasher, object)): """ Base class of all hashers. """ + _weak = False def __init__(self, target="local"): self._target = target @@ -68,12 +86,18 @@ class PasswordHasher(object): hashed_value ) + @classmethod + def get_password_hashers(cls): + """Return all the PasswordHasher supported by Modoboa""" + return cls.__subclasses__() + class PLAINHasher(PasswordHasher): - """ Plain (ie. clear) password hasher. """ + _weak = True + @property def scheme(self): return "{PLAIN}" @@ -83,12 +107,13 @@ class PLAINHasher(PasswordHasher): class CRYPTHasher(PasswordHasher): - """ crypt password hasher. Uses python `crypt` standard module. """ + _weak = True + @property def scheme(self): return "{CRYPT}" @@ -106,12 +131,13 @@ class CRYPTHasher(PasswordHasher): class MD5Hasher(PasswordHasher): - """ MD5 password hasher. Uses python `hashlib` standard module. """ + _weak = True + @property def scheme(self): return "{MD5}" @@ -122,12 +148,13 @@ class MD5Hasher(PasswordHasher): class SHA256Hasher(PasswordHasher): - """ SHA256 password hasher. Uses python `hashlib` and `base64` standard modules. """ + _weak = True + @property def scheme(self): return "{SHA256}" diff --git a/modoboa/core/tests/doveadm b/modoboa/core/tests/doveadm new file mode 100755 index 000000000..03474050b --- /dev/null +++ b/modoboa/core/tests/doveadm @@ -0,0 +1,3 @@ +#!/bin/sh +echo -n "MD5 MD5-CRYPT SHA SHA1 SHA256 SHA512 SMD5 SSHA SSHA256 SSHA512 PLAIN CLEAR CLEARTEXT PLAIN-TRUNC CRAM-MD5 SCRAM-SHA-1 HMAC-MD5 DIGEST-MD5 PLAIN-MD4 PLAIN-MD5 LDAP-MD5 LANMAN NTLM OTP SKEY RPA PBKDF2 CRYPT SHA256-CRYPT SHA512-CRYPT" +exit 0 diff --git a/modoboa/core/tests/test_authentication.py b/modoboa/core/tests/test_authentication.py index 509425503..8ac351f0b 100644 --- a/modoboa/core/tests/test_authentication.py +++ b/modoboa/core/tests/test_authentication.py @@ -4,6 +4,7 @@ from __future__ import unicode_literals +import os import smtplib from unittest import skipIf @@ -23,6 +24,8 @@ except ImportError: import mock +@override_settings( + DOVEADM_LOOKUP_PATH=["{}/doveadm".format(os.path.dirname(__file__))]) class AuthenticationTestCase(ModoTestCase): """Validate authentication scenarios.""" diff --git a/modoboa/core/tests/test_views.py b/modoboa/core/tests/test_views.py index 69d1c285e..20d730a0a 100644 --- a/modoboa/core/tests/test_views.py +++ b/modoboa/core/tests/test_views.py @@ -35,7 +35,7 @@ SETTINGS_SAMPLE = { "limits-enable_domain_limits": "False", "csrfmiddlewaretoken": "SGgMVZsA4TPqoiV786TMST6xgOlhAf4F", "limits-deflt_user_mailboxes_limit": "0", - "core-password_scheme": "sha512crypt", + "core-password_scheme": "plain", "core-update_scheme": True, "core-items_per_page": "30", "limits-deflt_user_mailbox_aliases_limit": "0", @@ -175,6 +175,15 @@ class SettingsTestCase(ModoTestCase): settings = SETTINGS_SAMPLE.copy() response = self.client.post(url, settings, format="json") self.assertEqual(response.status_code, 200) + settings["core-password_scheme"] = "sha512crypt" + response = self.client.post(url, settings, format="json") + self.assertEqual(response.status_code, 400) + compare(response.json(), { + "form_errors": {"password_scheme": ["Select a valid choice. \ +sha512crypt is not one of the available choices."]}, + "prefix": "core" + }) + settings["core-password_scheme"] = "plain" settings["core-rounds_number"] = "" response = self.client.post(url, settings, format="json") self.assertEqual(response.status_code, 400) diff --git a/modoboa/lib/sysutils.py b/modoboa/lib/sysutils.py index 3d21445bf..eb0b8c92f 100644 --- a/modoboa/lib/sysutils.py +++ b/modoboa/lib/sysutils.py @@ -8,9 +8,13 @@ This module extra functions/shortcuts to communicate with the system from __future__ import unicode_literals import inspect +import os import re import subprocess +from django.conf import settings +from django.utils.encoding import force_text + def exec_cmd(cmd, sudo_user=None, pinput=None, capture_output=True, **kwargs): """Execute a shell command. @@ -42,6 +46,43 @@ def exec_cmd(cmd, sudo_user=None, pinput=None, capture_output=True, **kwargs): return process.returncode, output +def doveadm_cmd(params, sudo_user=None, pinput=None, + capture_output=True, **kwargs): + """Execute doveadm command. + + Run doveadm command using the current user. Set :keyword:`sudo_user` if + you need different privileges. + + :param str params: the parameters to give to doveadm + :param str sudo_user: a valid system username + :param str pinput: data to send to process's stdin + :param bool capture_output: capture process output or not + :rtype: tuple + :return: return code, command output + """ + dpath = None + code, output = exec_cmd("which doveadm") + if not code: + dpath = force_text(output).strip() + else: + known_paths = getattr( + settings, "DOVEADM_LOOKUP_PATH", + ("/usr/bin/doveadm", "/usr/local/bin/doveadm") + ) + for fpath in known_paths: + if os.path.isfile(fpath) and os.access(fpath, os.X_OK): + dpath = fpath + break + if dpath: + return exec_cmd("{} {}".format(dpath, params), + sudo_user=sudo_user, + pinput=pinput, + capture_output=capture_output, + **kwargs) + else: + raise OSError("doveadm command not found") + + def guess_extension_name(): """Tries to guess the application's name by inspecting the stack.