First attempt to deal with password scheme deprecation.

see #3350
This commit is contained in:
Antoine Nguyen
2024-11-28 15:55:04 +01:00
parent e3c113849e
commit a076766f83
7 changed files with 97 additions and 16 deletions
+27 -1
View File
@@ -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
+12 -3
View File
@@ -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
+1 -1
View File
@@ -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.
+32 -8
View File
@@ -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)
+13
View File
@@ -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)
+11 -2
View File
@@ -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])
+1 -1
View File
@@ -30,7 +30,7 @@ except ImportError:
NO_LDAP = True
class ParametersMixin(object):
class ParametersMixin:
"""Add tools to manage parameters."""
@classmethod