diff --git a/modoboa/core/checks.py b/modoboa/core/checks.py index fb445a9e7..cf601a442 100644 --- a/modoboa/core/checks.py +++ b/modoboa/core/checks.py @@ -1,9 +1,18 @@ -from django.core.checks import register, Info, Warning +from django.core.checks import register, Error, Info, Warning from django.conf import settings from django.utils.translation import gettext as _ from modoboa.core.utils import generate_rsa_private_key +from modoboa.parameters.tools import get_global_parameter +from modoboa.core.password_hashers import get_password_hasher +EOO1 = Error( + _("The password scheme you use is not available anymore."), + hint=_( + "The password scheme you were using has been removed, please upgrade to a stronger one" + ), + id="modoboa.EOO1", +) W001 = Warning( _( @@ -14,6 +23,11 @@ W001 = Warning( hint=_("Set `USE_TZ = True` in settings.py"), id="modoboa.W001", ) +W002 = Warning( + _("The password scheme you are using is deprecated."), + hint=_("Upgrade your password scheme to a stronger one"), + id="modoboa.W002", +) @register(deploy=True) @@ -38,3 +52,15 @@ def check_rsa_private_key_exists(app_configs, **kwargs): if generate_rsa_private_key(settings.BASE_DIR): msgs.append(Info("An RSA private key has been generated for OIDC.")) return msgs + + +@register() +def check_password_hasher(app_configs, **kwargs): + msgs = [] + scheme_in_use = get_global_parameter("password_scheme", app="core") + hasher = get_password_hasher(scheme_in_use) + if not hasher.available: + msgs.append(EOO1) + elif hasher.deprecated: + msgs.append(W002) + return msgs diff --git a/modoboa/core/models.py b/modoboa/core/models.py index 25d3d4e0f..b947d8bce 100644 --- a/modoboa/core/models.py +++ b/modoboa/core/models.py @@ -2,6 +2,7 @@ import re from email.header import Header +from typing import Optional from django.core.serializers.json import DjangoJSONEncoder from django.conf import settings @@ -96,7 +97,7 @@ class User(AbstractUser): super().__init__(*args, **kwargs) self.parameters = param_tools.Manager("user", self._parameters) - def _crypt_password(self, raw_value): + def _crypt_password(self, raw_value: str) -> str: """Crypt the local password using the appropriate scheme. In case we don't find the scheme (for example when the @@ -115,9 +116,17 @@ class User(AbstractUser): "password_scheme", raise_exception=False ) raw_value = smart_bytes(raw_value) - return get_password_hasher(scheme.upper())().encrypt(raw_value) + hasher = get_password_hasher(scheme.upper())() + if not hasher.available: + raise InternalError( + _( + "The configured password scheme (%s) is not available anymore. Please update your configuration" + ) + % (scheme) + ) + return hasher.encrypt(raw_value) - def set_password(self, raw_value, curvalue=None): + def set_password(self, raw_value: str, curvalue: Optional[str] = None) -> None: """Password update. Update the current mailbox's password with the given clear diff --git a/modoboa/core/password_hashers/__init__.py b/modoboa/core/password_hashers/__init__.py index 5bc8bd086..560c43ef4 100644 --- a/modoboa/core/password_hashers/__init__.py +++ b/modoboa/core/password_hashers/__init__.py @@ -19,7 +19,7 @@ from modoboa.core.password_hashers.base import ( # NOQA:F401 ) -def get_password_hasher(scheme): +def get_password_hasher(scheme: str) -> PasswordHasher: """Retrieve the hasher corresponding to :keyword:`scheme`. If no class is found, `PLAINHasher` is returned. diff --git a/modoboa/core/password_hashers/base.py b/modoboa/core/password_hashers/base.py index 0d355ac63..e56f2ba2e 100644 --- a/modoboa/core/password_hashers/base.py +++ b/modoboa/core/password_hashers/base.py @@ -5,10 +5,15 @@ Contains weak hashers (the original ones) available with Modoboa. """ import base64 -import crypt + +try: + import crypt +except ImportError: + crypt = None import hashlib -import string from random import Random +import string +from typing import Optional from django.utils.crypto import constant_time_compare from django.utils.encoding import force_bytes, force_str @@ -28,7 +33,15 @@ class MetaHasher(type): @property def label(cls): """Returns the label of the hasher""" - return cls.name if not cls._weak else "{} (weak)".format(cls.name) + result = cls.name + tags = [] + if cls._weak: + tags.append("weak") + if cls.deprecated: + tags.append("deprecated") + if tags: + result += f" ({', '.join(tags)})" + return result class PasswordHasher(metaclass=MetaHasher): @@ -36,12 +49,14 @@ class PasswordHasher(metaclass=MetaHasher): Base class of all hashers. """ - _weak = False + _weak: bool = False + deprecated: bool = False + scheme: str def __init__(self, target="local"): self._target = target - def _encrypt(self, clearvalue, salt=None): + def _encrypt(self, clearvalue: str, salt: Optional[str] = None) -> str: raise NotImplementedError def _b64encode(self, pwhash): @@ -54,7 +69,11 @@ class PasswordHasher(metaclass=MetaHasher): return base64.b64encode(pwhash) return pwhash - def encrypt(self, clearvalue): + @property + def available(self) -> bool: + return True + + def encrypt(self, clearvalue: str) -> str: """Encrypt a password. The format used to store passwords is the same than dovecot's one:: @@ -94,7 +113,7 @@ class PasswordHasher(metaclass=MetaHasher): @classmethod def get_password_hashers(cls): """Return all the PasswordHasher supported by Modoboa""" - return cls.__subclasses__() + return [hasher for hasher in cls.__subclasses__() if hasher().available] class PLAINHasher(PasswordHasher): @@ -120,12 +139,17 @@ class CRYPTHasher(PasswordHasher): """ _weak = True + deprecated = True + + @property + def available(self) -> bool: + return crypt is not None @property def scheme(self): return "{CRYPT}" - def _encrypt(self, clearvalue, salt=None): + def _encrypt(self, clearvalue: str, salt: Optional[str] = None) -> str: if salt is None: salt = "".join(Random().sample(string.ascii_letters + string.digits, 2)) return crypt.crypt(clearvalue, salt) diff --git a/modoboa/core/tests/test_authentication.py b/modoboa/core/tests/test_authentication.py index 9b0e4e306..7901fe37b 100644 --- a/modoboa/core/tests/test_authentication.py +++ b/modoboa/core/tests/test_authentication.py @@ -16,6 +16,7 @@ except ImportError: from modoboa.core import constants from modoboa.core.password_hashers import get_password_hasher from modoboa.core.password_hashers.utils import get_dovecot_schemes +from modoboa.lib.exceptions import InternalError from modoboa.lib.tests import NO_SMTP, ModoTestCase from .. import factories, models @@ -252,6 +253,18 @@ class AuthenticationTestCase(ModoTestCase): supported_schemes = get_dovecot_schemes()[0] self.assertEqual(supported_schemes, ["{MD5-CRYPT}", "{PLAIN}"]) + def test_unavailable_password_scheme(self): + self.set_global_parameter("password_scheme", "crypt") + hasher = get_password_hasher("crypt") + self.assertEqual(hasher.label, "crypt (weak, deprecated)") + if hasher().available: + return + username = "user@test.com" + password = "toto" + user = models.User.objects.get(username=username) + with self.assertRaises(InternalError): + user.set_password(password) + def test_fido_auth_begin(self): url = reverse("core:fido_auth_begin") resp = self.client.post(url) diff --git a/modoboa/core/tests/test_checks.py b/modoboa/core/tests/test_checks.py index b3beda5dd..dab27949b 100644 --- a/modoboa/core/tests/test_checks.py +++ b/modoboa/core/tests/test_checks.py @@ -2,13 +2,14 @@ import os import shutil import tempfile -from django.test import SimpleTestCase from django.test.utils import override_settings from modoboa.core import checks +from modoboa.lib.tests import SimpleModoTestCase +from modoboa.core.password_scheme.base import CRYPTHasher -class CheckSessionCookieSecureTest(SimpleTestCase): +class CheckSessionCookieSecureTest(SimpleModoTestCase): def setUp(self): self.workdir = tempfile.mkdtemp() @@ -35,3 +36,11 @@ class CheckSessionCookieSecureTest(SimpleTestCase): os.environ["OIDC_RSA_PRIVATE_KEY"] = "KEY" msgs = checks.check_rsa_private_key_exists(None) self.assertEqual(len(msgs), 0) + + def test_password_hasher_checks(self): + self.set_global_parameter("password_scheme", "crypt") + msgs = checks.check_password_hasher(None) + if CRYPTHasher().available: + self.assertEqual(msgs, [checks.W002]) + else: + self.assertEqual(msgs, [checks.E001]) diff --git a/modoboa/lib/tests/__init__.py b/modoboa/lib/tests/__init__.py index 1c5322920..23425ef51 100644 --- a/modoboa/lib/tests/__init__.py +++ b/modoboa/lib/tests/__init__.py @@ -30,7 +30,7 @@ except ImportError: NO_LDAP = True -class ParametersMixin(object): +class ParametersMixin: """Add tools to manage parameters.""" @classmethod