From beae88dab7cf24d410b79b915ea4a3a104e9a0d5 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Wed, 17 Jul 2024 11:22:37 +0200 Subject: [PATCH] Added tests for FIDO auth --- modoboa/core/api/v2/tests.py | 115 ++++++---------------- modoboa/core/factories.py | 12 +++ modoboa/core/models.py | 2 + modoboa/core/tests/test_authentication.py | 60 +++++++++++ modoboa/sievefilters/imaputils.py | 2 - 5 files changed, 102 insertions(+), 89 deletions(-) diff --git a/modoboa/core/api/v2/tests.py b/modoboa/core/api/v2/tests.py index d04b8b03d..6723b52c6 100644 --- a/modoboa/core/api/v2/tests.py +++ b/modoboa/core/api/v2/tests.py @@ -197,20 +197,6 @@ class AccountViewSetTestCase(ModoAPITestCase): resp = self.client.post(url, {"password": password_ok}, format="json") self.assertEqual(resp.status_code, 200) - # @mock.patch("django_otp.match_token") - # def test_tfa_verify_code(self, match_mock): - # user = models.User.objects.get(username="admin") - # user.totpdevice_set.create(name="Device") - # user.tfa_enabled = True - # user.save() - - # url = reverse("v2:account-tfa-verify-code") - # match_mock.side_effect = [user.totpdevice_set.first()] - # data = {"code": "1234"} - # resp = self.client.post(url, data, format="json") - # self.assertEqual(resp.status_code, 200) - # self.assertIn("access", resp.json()) - def test_tfa_setup(self): # Setup TFA url = reverse("v2:account-tfa-setup-get-key") @@ -275,41 +261,6 @@ class AccountViewSetTestCase(ModoAPITestCase): user.refresh_from_db() self.assertEqual(user.totp_enabled, False) - # def test_api_token(self): - # # 1. Obtain a JWT token so we can safely play with basic token - # url = reverse("v2:token_obtain_pair") - # data = {"username": "admin", "password": "password"} - # resp = self.client.post(url, data, format="json") - # self.assertEqual(resp.status_code, 200) - # self.client.credentials( - # HTTP_AUTHORIZATION="Bearer {}".format(resp.json()["access"]) - # ) - - # url = reverse("v2:account-manage-api-token") - # resp = self.client.get(url) - # self.assertEqual(resp.status_code, 200) - # self.assertEqual(resp.json()["token"], self.token.key) - # resp = self.client.delete(url) - # self.assertEqual(resp.status_code, 204) - # resp = self.client.post(url) - # self.assertEqual(resp.status_code, 201) - - # def test_failed_api_token(self): - # """Simulate a failed login attempt and check that it is logged.""" - - # url = reverse("v2:token_obtain_pair") - # data = {"username": "clearly_non_existent_user", "password": "password"} - - # with self.assertLogs(logger="modoboa.auth", level="WARNING") as log: - - # resp = self.client.post(url, data, format="json") - # self.assertEqual(resp.status_code, 401) - # self.assertIn( - # "WARNING:modoboa.auth:Failed connection attempt from '127.0.0.1'" - # " as user 'clearly_non_existent_user'", - # log.output, - # ) - class PasswordResetTestCase(AccountViewSetTestCase): def __init__(self, *args, **kwargs): @@ -533,44 +484,6 @@ class PasswordResetTestCase(AccountViewSetTestCase): # TODO: See why user doesn't update it's password --> self.test_me_password(password_ok="MyHardenedPass1!") -# class AuthenticationTestCase(ModoAPITestCase): - -# @mock.patch("django_otp.match_token") -# def test_2fa(self, match_mock): -# url = reverse("v2:token_obtain_pair") -# me_url = reverse("v2:account-me") -# data = {"username": "admin", "password": "password"} -# resp = self.client.post(url, data, format="json") -# self.assertEqual(resp.status_code, 200) - -# self.client.credentials( -# HTTP_AUTHORIZATION="Bearer {}".format(resp.json()["access"]) -# ) -# resp = self.client.get(me_url) -# self.assertEqual(resp.status_code, 200) - -# # Now we enable 2FA -# user = models.User.objects.get(username="admin") -# user.totpdevice_set.create(name="Device") -# user.tfa_enabled = True -# user.save() -# resp = self.client.get(me_url) -# self.assertEqual(resp.status_code, 418) - -# # Verify code -# url = reverse("v2:account-tfa-verify-code") -# match_mock.side_effect = [user.totpdevice_set.first()] -# data = {"code": "1234"} -# resp = self.client.post(url, data, format="json") -# self.assertEqual(resp.status_code, 200) - -# self.client.credentials( -# HTTP_AUTHORIZATION="Bearer {}".format(resp.json()["access"]) -# ) -# resp = self.client.get(me_url) -# self.assertEqual(resp.status_code, 200) - - class LanguageViewSetTestCase(ModoAPITestCase): def test_list(self): url = reverse("v2:language-list") @@ -583,3 +496,31 @@ class ComponentAPITestCase(ModoAPITestCase): url = reverse("v2:components_information") resp = self.client.get(url) self.assertEqual(resp.status_code, 200) + + +class AuthenticatorData(bytes): + + credential_data = b"RESPONSE" + + +class FIDOViewSetTestCase(ModoAPITestCase): + + @mock.patch("fido2.server.Fido2Server.register_complete") + def test_registration(self, register_complete_mock): + url = reverse("v2:fido-registration-begin") + resp = self.client.post(url) + self.assertEqual(resp.status_code, 200) + + register_complete_mock.side_effect = [AuthenticatorData()] + data = { + "type": "type", + "id": "XX", + "rawId": "XX", + "authenticatorAttachment": "attachment", + "response": {"key": "value"}, + "name": "Name", + } + url = reverse("v2:fido-registration-end") + resp = self.client.post(url, data, format="json") + self.assertEqual(resp.status_code, 200) + self.assertIn("tokens", resp.json()) diff --git a/modoboa/core/factories.py b/modoboa/core/factories.py index 92a2e697f..7c77d9feb 100644 --- a/modoboa/core/factories.py +++ b/modoboa/core/factories.py @@ -1,5 +1,7 @@ """Factories for core application.""" +import base64 + import factory from django.contrib.auth.models import Group @@ -58,3 +60,13 @@ class LogFactory(factory.django.DjangoModelFactory): logger = "modoboa.admin" message = "A message" level = "info" + + +class UserFidoKeyFactory(factory.django.DjangoModelFactory): + """Factory for UserFidoKey.""" + + class Meta: + model = models.UserFidoKey + + name = factory.Sequence(lambda n: f"Name {n}") + credential_data = "data" diff --git a/modoboa/core/models.py b/modoboa/core/models.py index 1f7a838f8..25d3d4e0f 100644 --- a/modoboa/core/models.py +++ b/modoboa/core/models.py @@ -418,6 +418,8 @@ reversion.register(User) class UserFidoKey(models.Model): + """Model to store user fido keys.""" + user = models.ForeignKey(User, on_delete=models.CASCADE) name = models.CharField(max_length=255) added_on = models.DateTimeField(auto_now_add=True) diff --git a/modoboa/core/tests/test_authentication.py b/modoboa/core/tests/test_authentication.py index ab689472f..621650e77 100644 --- a/modoboa/core/tests/test_authentication.py +++ b/modoboa/core/tests/test_authentication.py @@ -13,6 +13,7 @@ try: except ImportError: argon2 = None +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.tests import NO_SMTP, ModoTestCase @@ -21,6 +22,16 @@ from .. import factories, models DOVEADM_TEST_PATH = "{}/doveadm".format(os.path.dirname(__file__)) +class MockedAttestedCredentialData: + + def __init__(self, _): + pass + + @staticmethod + def _parse(data): + return "1", "XX", "3", None + + class AuthenticationTestCase(ModoTestCase): """Validate authentication scenarios.""" @@ -229,6 +240,55 @@ class AuthenticationTestCase(ModoTestCase): supported_schemes = get_dovecot_schemes()[0] self.assertEqual(supported_schemes, ["{MD5-CRYPT}", "{PLAIN}"]) + def test_fido_auth_begin(self): + url = reverse("core:fido_auth_begin") + resp = self.client.post(url) + self.assertEqual(resp.status_code, 403) + session = self.client.session + session[constants.TFA_PRE_VERIFY_USER_PK] = self.account.pk + session.save() + resp = self.client.post(url) + self.assertEqual(resp.status_code, 200) + + @mock.patch("fido2.webauthn.AttestedCredentialData", MockedAttestedCredentialData) + @mock.patch("fido2.server.Fido2Server.authenticate_complete") + @mock.patch("fido2.utils.websafe_decode") + def test_fido_auth_end(self, websafe_decode_mock, authenticate_complete_mock): + data = { + "authenticatorAttachment": "attachment", + "clientExtensionResults": '{"key": "value"}', + "id": "XX", + "rawId": "XX", + "response": '{"key": "value"}', + "type": "type", + } + url = reverse("core:fido_auth_end") + resp = self.client.post(url, data, format="json") + self.assertEqual(resp.status_code, 403) + session = self.client.session + session[constants.TFA_PRE_VERIFY_USER_PK] = self.account.pk + session[constants.TFA_PRE_VERIFY_USER_BACKEND] = ( + "django.contrib.auth.backends.ModelBackend" + ) + session["fido_state"] = "state" + session.save() + + class AuthenticateMockResult: + credential_id = "XX" + + key = factories.UserFidoKeyFactory(user=self.account) + authenticate_complete_mock.side_effect = [AuthenticateMockResult()] + websafe_decode_mock.side_effect = [ + bytes.fromhex( + "f8a011f38c0a4d15800617111f9edc7d0040fe3aac036d14c1e1c65518b698dd1da8f596bc33e11072813466c6bf3845691509b80fb76d59309b8d39e0a93452688f6ca3a39a76f3fc52744fb73948b15783a5010203262001215820643566c206dd00227005fa5de69320616ca268043a38f08bde2e9dc45a5cafaf225820171353b2932434703726aae579fa6542432861fe591e481ea22d63997e1a5290" # noqa E501 + ) + ] + + resp = self.client.post(url, data, format="json") + self.assertEqual(resp.status_code, 200) + key.refresh_from_db() + self.assertEqual(key.use_count, 1) + class PasswordResetTestCase(ModoTestCase): """Test password reset service.""" diff --git a/modoboa/sievefilters/imaputils.py b/modoboa/sievefilters/imaputils.py index a8ebf8964..505a0be5b 100644 --- a/modoboa/sievefilters/imaputils.py +++ b/modoboa/sievefilters/imaputils.py @@ -7,11 +7,9 @@ from operator import itemgetter import socket import ssl -from django.utils.encoding import smart_bytes from django.utils.translation import gettext as _ from modoboa.lib import imap_utf7 # NOQA -from modoboa.lib.connections import ConnectionsManager from modoboa.lib.exceptions import ModoboaException, InternalError from modoboa.parameters import tools as param_tools