diff --git a/modoboa/core/password_hashers/__init__.py b/modoboa/core/password_hashers/__init__.py index b03b5e3d0..2af1b766f 100644 --- a/modoboa/core/password_hashers/__init__.py +++ b/modoboa/core/password_hashers/__init__.py @@ -9,7 +9,8 @@ from __future__ import unicode_literals from django.utils.encoding import smart_text from modoboa.core.password_hashers.advanced import ( # NOQA:F401 - BLFCRYPTHasher, MD5CRYPTHasher, SHA256CRYPTHasher, SHA512CRYPTHasher + BLFCRYPTHasher, MD5CRYPTHasher, SHA256CRYPTHasher, SHA512CRYPTHasher, + ARGON2IDHasher ) from modoboa.core.password_hashers.base import ( # NOQA:F401 CRYPTHasher, MD5Hasher, PLAINHasher, SHA256Hasher diff --git a/modoboa/core/password_hashers/advanced.py b/modoboa/core/password_hashers/advanced.py index 3024924fc..e58c5ba83 100644 --- a/modoboa/core/password_hashers/advanced.py +++ b/modoboa/core/password_hashers/advanced.py @@ -9,6 +9,9 @@ This module relies on `passlib` to provide more secure hashers. from __future__ import unicode_literals from passlib.hash import bcrypt, md5_crypt, sha256_crypt, sha512_crypt +from argon2 import PasswordHasher as argon2_hasher + +from django.conf import settings from modoboa.parameters import tools as param_tools from .base import PasswordHasher @@ -91,8 +94,7 @@ class SHA512CRYPTHasher(PasswordHasher): """ SHA512-CRYPT password hasher. - Supports rounds and is the strongest scheme provided by - Modoboa. Requires more resource than SHA256-CRYPT. + Supports rounds. Requires more resource than SHA256-CRYPT. """ @property @@ -108,3 +110,48 @@ class SHA512CRYPTHasher(PasswordHasher): def verify(self, clearvalue, hashed_value): return sha512_crypt.verify(clearvalue, hashed_value) + + +class ARGON2IDHasher(PasswordHasher): + """ + argon2 password hasher. + + Supports rounds, memory and number of threads. To be set in settings.py. + It is the strongest scheme provided by modoboa + but can only be used with dovecot >= 2.3 and libsodium >= 1.0.13 + """ + + def __init__(self,): + super(ARGON2IDHasher, self).__init__() + + parameters = dict() + + if hasattr(settings, "MODOBOA_ARGON2_TIME_COST"): + parameters["time_cost"] = settings.MODOBOA_ARGON2_TIME_COST + + if hasattr(settings, "MODOBOA_ARGON2_MEMORY_COST"): + parameters["memory_cost"] = settings.MODOBOA_ARGON2_MEMORY_COST + + if hasattr(settings, "MODOBOA_ARGON2_PARALLELISM"): + parameters["parallelism"] = settings.MODOBOA_ARGON2_PARALLELISM + + self.hasher = argon2_hasher(**parameters) + + @property + def scheme(self): + return "{ARGON2ID}" if self._target == "local" else "{CRYPT}" + + def _b64encode(self, pwhash): + return pwhash + + def _encrypt(self, clearvalue, salt=None): + return self.hasher.hash(clearvalue) + + def verify(self, clearvalue, hashed_value): + try: + return self.hasher.verify(hashed_value, clearvalue) + except argon2_hasher.exceptions.VerifyMismatchError: + return False + + def needs_rehash(self, hashed_value): + return self.hasher.check_needs_rehash(hashed_value.strip(self.scheme)) diff --git a/modoboa/core/password_hashers/base.py b/modoboa/core/password_hashers/base.py index ce05f4e72..8b354466f 100644 --- a/modoboa/core/password_hashers/base.py +++ b/modoboa/core/password_hashers/base.py @@ -86,6 +86,16 @@ class PasswordHasher(with_metaclass(MetaHasher, object)): hashed_value ) + def needs_rehash(self, hashed_value): + """Check if the provided hash needs rehasing accoring to the current + parameters + + :param str hashed_value: hased password + :rtype bool + :return: True if the password needs rehash, false otherwise + """ + return False + @classmethod def get_password_hashers(cls): """Return all the PasswordHasher supported by Modoboa""" diff --git a/modoboa/core/tests/test_authentication.py b/modoboa/core/tests/test_authentication.py index f50fa7161..41a3b75f0 100644 --- a/modoboa/core/tests/test_authentication.py +++ b/modoboa/core/tests/test_authentication.py @@ -12,6 +12,8 @@ from django.core import mail from django.test import override_settings from django.urls import reverse +import argon2 + from modoboa.core.password_hashers import get_password_hasher, get_dovecot_schemes from modoboa.lib.tests import NO_SMTP, ModoTestCase from .. import factories, models @@ -91,6 +93,12 @@ class AuthenticationTestCase(ModoTestCase): user.refresh_from_db() self.assertTrue(user.password.startswith("{SHA256}")) + self.client.logout() + self.set_global_parameter("password_scheme", "argon2id") + self.client.post(reverse("core:login"), data) + user.refresh_from_db() + self.assertTrue(user.password.startswith("{ARGON2ID}")) + self.client.logout() self.set_global_parameter("password_scheme", "fallback_scheme") self.client.post(reverse("core:login"), data) @@ -104,6 +112,41 @@ class AuthenticationTestCase(ModoTestCase): user.refresh_from_db() self.assertTrue(user.password.startswith(pw_hash.scheme)) + def test_password_parameter_change(self): + """Validate hash parameter update on login works""" + username = "user@test.com" + password = "toto" + data = {"username": username, "password": password} + user = models.User.objects.get(username=username) + self.set_global_parameter("password_scheme", "argon2id") + + self.client.logout() + with self.settings( + MODOBOA_ARGON2_TIME_COST=4, + MODOBOA_ARGON2_MEMORY_COST=10000, + MODOBOA_ARGON2_PARALLELISM=4): + self.client.post(reverse("core:login"), data) + user.refresh_from_db() + self.assertTrue(user.password.startswith("{ARGON2ID}")) + parameters = argon2.extract_parameters(user.password.lstrip("{ARGON2ID}")) + self.assertEqual(parameters.time_cost, 4) + self.assertEqual(parameters.memory_cost, 10000) + self.assertEqual(parameters.parallelism, 4) + + self.client.logout() + with self.settings( + MODOBOA_ARGON2_TIME_COST=3, + MODOBOA_ARGON2_MEMORY_COST=1000, + MODOBOA_ARGON2_PARALLELISM=2): + self.client.post(reverse("core:login"), data) + user.refresh_from_db() + self.assertTrue(user.password.startswith("{ARGON2ID}")) + parameters = argon2.extract_parameters( + user.password.lstrip("{ARGON2ID}")) + self.assertEqual(parameters.time_cost, 3) + self.assertEqual(parameters.memory_cost, 1000) + self.assertEqual(parameters.parallelism, 2) + def test_supported_schemes(self): """Validate dovecot supported schemes.""" supported_schemes = get_dovecot_schemes() diff --git a/modoboa/core/views/auth.py b/modoboa/core/views/auth.py index 77de63417..8945f7bf4 100644 --- a/modoboa/core/views/auth.py +++ b/modoboa/core/views/auth.py @@ -52,6 +52,14 @@ def dologin(request): ) user.set_password(form.cleaned_data["password"]) user.save() + if pwhash.needs_rehash(user.password): + logging.info( + _("Password hash parameter missmatch. " + "Updating %s password"), + user.username + ) + user.set_password(form.cleaned_data["password"]) + user.save() login(request, user) if not form.cleaned_data["rememberme"]: diff --git a/requirements.txt b/requirements.txt index eb80cd838..b96c62c70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,3 +29,4 @@ rfc6266 lxml backports.csv; python_version < '3' chardet +argon2_cffi