diff --git a/frontend/src/components/account/FidoAuthForm.vue b/frontend/src/components/account/FidoAuthForm.vue index 33a1e80ed..6568d1bdc 100644 --- a/frontend/src/components/account/FidoAuthForm.vue +++ b/frontend/src/components/account/FidoAuthForm.vue @@ -8,25 +8,14 @@ - +
{{ $gettext( - 'You must enable two-factor authentication using one-time passwords first.' + "You don't have any WebAuthN registered as a second authentication method." ) }}
-
- - diff --git a/modoboa/admin/api/v1/serializers.py b/modoboa/admin/api/v1/serializers.py index 5c8e11283..c2cd1b7f4 100644 --- a/modoboa/admin/api/v1/serializers.py +++ b/modoboa/admin/api/v1/serializers.py @@ -253,6 +253,8 @@ class AccountSerializer(serializers.ModelSerializer): "secondary_email", "domains", "tfa_enabled", + "totp_enabled", + "webauthn_enabled", "date_joined", "last_login", ) diff --git a/modoboa/core/api/v1/serializers.py b/modoboa/core/api/v1/serializers.py index 50af8729c..79496b209 100644 --- a/modoboa/core/api/v1/serializers.py +++ b/modoboa/core/api/v1/serializers.py @@ -23,7 +23,7 @@ class CheckTFASetupSerializer(serializers.Serializer): def validate(self, data): user = self.context["user"] - if user.tfa_enabled or user.staticdevice_set.exists(): + if user.totp_enabled or user.staticdevice_set.exists(): raise serializers.ValidationError(_("2FA is already enabled")) return data diff --git a/modoboa/core/api/v1/viewsets.py b/modoboa/core/api/v1/viewsets.py index da8c8b634..85bbaafcd 100644 --- a/modoboa/core/api/v1/viewsets.py +++ b/modoboa/core/api/v1/viewsets.py @@ -59,12 +59,12 @@ class AccountViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): context={"user": request.user, "remote_addr": request.META["REMOTE_ADDR"]}, ) serializer.is_valid(raise_exception=True) - if not request.user.tfa_enabled: + if not request.user.totp_enabled: # We include it as "password" to display the error return response.Response({"error": _("2FA is not enabled")}, status=403) request.user.totpdevice_set.all().delete() request.user.staticdevice_set.all().delete() - request.user.tfa_enabled = False + request.user.totp_enabled = False request.user.save() return response.Response() diff --git a/modoboa/core/api/v2/tests.py b/modoboa/core/api/v2/tests.py index 20cef99c8..d04b8b03d 100644 --- a/modoboa/core/api/v2/tests.py +++ b/modoboa/core/api/v2/tests.py @@ -80,7 +80,6 @@ CORE_SETTINGS = { class ParametersAPITestCase(ModoAPITestCase): - def setUp(self): super().setUp() cache.delete("password_scheme_choice") @@ -184,7 +183,6 @@ class ParametersAPITestCase(ModoAPITestCase): class AccountViewSetTestCase(ModoAPITestCase): - def test_me(self): url = reverse("v2:account-me") resp = self.client.get(url) @@ -226,7 +224,7 @@ class AccountViewSetTestCase(ModoAPITestCase): self.assertEqual(resp.status_code, 200) self.assertEqual(resp.content_type, "application/json") - user.tfa_enabled = True + user.totp_enabled = True user.save() resp = self.client.get(url) self.assertEqual(resp.status_code, 404) @@ -275,7 +273,7 @@ class AccountViewSetTestCase(ModoAPITestCase): self.assertEqual(resp.status_code, 200) user.refresh_from_db() - self.assertEqual(user.tfa_enabled, False) + self.assertEqual(user.totp_enabled, False) # def test_api_token(self): # # 1. Obtain a JWT token so we can safely play with basic token @@ -574,7 +572,6 @@ class PasswordResetTestCase(AccountViewSetTestCase): class LanguageViewSetTestCase(ModoAPITestCase): - def test_list(self): url = reverse("v2:language-list") resp = self.client.get(url) @@ -582,7 +579,6 @@ class LanguageViewSetTestCase(ModoAPITestCase): class ComponentAPITestCase(ModoAPITestCase): - def test_information(self): url = reverse("v2:components_information") resp = self.client.get(url) diff --git a/modoboa/core/api/v2/viewsets.py b/modoboa/core/api/v2/viewsets.py index acfa1c298..d77412a69 100644 --- a/modoboa/core/api/v2/viewsets.py +++ b/modoboa/core/api/v2/viewsets.py @@ -82,7 +82,7 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet): @action(methods=["get"], detail=False, url_path="tfa/setup/key") def tfa_setup_get_key(self, request): """Get a key and url to finalize the setup process.""" - if request.user.tfa_enabled: + if request.user.totp_enabled: return response.Response(status=404) device = request.user.totpdevice_set.first() if not device: @@ -110,7 +110,7 @@ class AccountViewSet(core_v1_viewsets.AccountViewSet): device.token_set.create(token=token) tokens.append(token) # Set enable flag to True so we can't go here anymore - request.user.tfa_enabled = True + request.user.totp_enabled = True request.user.save() # Generate new tokens device = request.user.totpdevice_set.first() @@ -166,7 +166,17 @@ class FIDOViewSet( serializer_class = serializers.FIDOSerializer def get_queryset(self): - return models.UserFidoKeys.objects.filter(user=self.request.user) + if self.request.user.webautn_enabled: + return models.UserFidoKeys.objects.filter(user=self.request.user) + return None + + def destroy(self, request, *args, **kwargs): + super().destroy(request, *args, **kwargs) + device = request.user.totpdevice_set.first() + if device is None: + request.user.webautn_enabled = False + request.user.staticdevice_set.all().delete() + request.user.save() @action(methods=["post"], detail=False, url_path="registration/begin") def registration_begin(self, request): @@ -187,7 +197,20 @@ class FIDOViewSet( credential_data=credential_data, user=request.user, ) - if not request.user.tfa_enabled: - request.user.tfa_enabled = True + if not request.user.webautn_enabled: + request.user.webautn_enabled = True request.user.save() + + # create static device for recovery purposes if needed + device = request.user.staticdevice_set.first() + if device is None: + device = StaticDevice.objects.create( + user=request.user, name="{} static device".format(request.user) + ) + for cpt in range(10): + token = StaticToken.random_token() + device.token_set.create(token=token) + return response.Response( + {"tokens": device.token_set.all().values_list("token", flat=True)} + ) return response.Response(status=204) diff --git a/modoboa/core/management/commands/load_initial_data.py b/modoboa/core/management/commands/load_initial_data.py index 8f22d5806..8e7218a80 100644 --- a/modoboa/core/management/commands/load_initial_data.py +++ b/modoboa/core/management/commands/load_initial_data.py @@ -9,8 +9,11 @@ A management command to load Modoboa initial data. from functools import reduce from django.contrib.auth.models import Group +from django.core.management import call_command from django.core.management.base import BaseCommand +from oauth2_provider.models import get_application_model + from modoboa.lib.permissions import add_permissions_to_group from ... import constants, extensions, models, signals @@ -27,11 +30,19 @@ class Command(BaseCommand): default="admin", help="Username of the initial super administrator.", ) + ( + parser.add_argument( + "--extra-fixtures", + action="store_true", + default=False, + help="Also load some fixtures from the admin application.", + ), + ) parser.add_argument( - "--extra-fixtures", + "--dev", action="store_true", default=False, - help="Also load some fixtures from the admin application.", + help="Setup dev environment. DO NOT USE IN PRODUCTION", ) def handle(self, *args, **options): @@ -76,3 +87,16 @@ class Command(BaseCommand): from modoboa.admin import factories factories.populate_database() + + if options["dev"]: + app_model = get_application_model() + if not app_model.objects.filter(name="frontend").exists(): + call_command( + "createapplication", + "--algorithm=RS256", + "--redirect-uris=https://localhost:3000/login/logged", + "--name=frontend", + "--client-id=LVQbfIIX3khWR3nDvix1u9yEGHZUxcx53bhJ7FlD", + "public", + "authorization-code", + ) diff --git a/modoboa/core/migrations/0029_rename_tfa_enabled_user_totp_enabled_and_more.py b/modoboa/core/migrations/0029_rename_tfa_enabled_user_totp_enabled_and_more.py new file mode 100644 index 000000000..ff6c44df1 --- /dev/null +++ b/modoboa/core/migrations/0029_rename_tfa_enabled_user_totp_enabled_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.13 on 2024-06-21 15:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0028_userfidokeys"), + ] + + operations = [ + migrations.RenameField( + model_name="user", + old_name="tfa_enabled", + new_name="totp_enabled", + ), + migrations.AddField( + model_name="user", + name="webauthn_enabled", + field=models.BooleanField(default=False), + ), + ] diff --git a/modoboa/core/models.py b/modoboa/core/models.py index 28782b523..c509cc150 100644 --- a/modoboa/core/models.py +++ b/modoboa/core/models.py @@ -58,8 +58,7 @@ class User(AbstractUser): master_user = models.BooleanField( gettext_lazy("Allow mailboxes access"), default=False, - help_text=gettext_lazy( - "Allow this administrator to access user mailboxes"), + help_text=gettext_lazy("Allow this administrator to access user mailboxes"), ) password = models.CharField(gettext_lazy("password"), max_length=256) @@ -70,8 +69,7 @@ class User(AbstractUser): choices=constants.LANGUAGES, help_text=gettext_lazy("Prefered language to display pages."), ) - phone_number = PhoneNumberField(gettext_lazy( - "Phone number"), blank=True, null=True) + phone_number = PhoneNumberField(gettext_lazy("Phone number"), blank=True, null=True) secondary_email = models.EmailField( gettext_lazy("Secondary email"), max_length=254, @@ -82,7 +80,8 @@ class User(AbstractUser): ), ) - tfa_enabled = models.BooleanField(default=False) + totp_enabled = models.BooleanField(default=False) + webauthn_enabled = models.BooleanField(default=False) _parameters = models.JSONField(default=dict, encoder=DjangoJSONEncoder) @@ -206,6 +205,10 @@ class User(AbstractUser): ) return self.email + @property + def tfa_enabled(self): + return self.totp_enabled or self.webauthn_enabled + def is_owner(self, obj): """Tell is the user is the unique owner of this object. @@ -214,8 +217,7 @@ class User(AbstractUser): """ ct = ContentType.objects.get_for_model(obj) try: - ooentry = self.objectaccess_set.get( - content_type=ct, object_id=obj.id) + ooentry = self.objectaccess_set.get(content_type=ct, object_id=obj.id) except ObjectAccess.DoesNotExist: return False return ooentry.is_owner @@ -236,8 +238,7 @@ class User(AbstractUser): ct = ContentType.objects.get_for_model(obj) try: - ooentry = self.objectaccess_set.get( - content_type=ct, object_id=obj.id) + ooentry = self.objectaccess_set.get(content_type=ct, object_id=obj.id) except ObjectAccess.DoesNotExist: pass else: @@ -398,8 +399,7 @@ class User(AbstractUser): smart_str(self.role), smart_str(self.email), ] - results = signals.account_exported.send( - sender=self.__class__, user=self) + results = signals.account_exported.send(sender=self.__class__, user=self) for result in results: row += result[1] return row diff --git a/modoboa/core/tests/test_authentication.py b/modoboa/core/tests/test_authentication.py index 80d0ba416..f1897a491 100644 --- a/modoboa/core/tests/test_authentication.py +++ b/modoboa/core/tests/test_authentication.py @@ -53,7 +53,7 @@ class AuthenticationTestCase(ModoTestCase): def test_authentication_with_2fa(self, login_mock, match_mock): user = models.User.objects.get(username="user@test.com") user.totpdevice_set.create(name="Device") - user.tfa_enabled = True + user.totp_enabled = True user.save() data = {"username": "user@test.com", "password": "toto"} response = self.client.post(reverse("core:login"), data) diff --git a/modoboa/core/tests/test_viewsets.py b/modoboa/core/tests/test_viewsets.py index c2c6553e2..cacf8e2ce 100644 --- a/modoboa/core/tests/test_viewsets.py +++ b/modoboa/core/tests/test_viewsets.py @@ -51,6 +51,7 @@ class AccountViewSetTestCase(ModoAPITestCase): self.assertIn("recovery codes", response.json()["content"]) admin.refresh_from_db() + self.assertTrue(admin.totp_enabled) self.assertTrue(admin.tfa_enabled) device = admin.staticdevice_set.first() self.assertIsNot(device, None) diff --git a/modoboa/core/views/auth.py b/modoboa/core/views/auth.py index 7cee93009..d79e2df32 100644 --- a/modoboa/core/views/auth.py +++ b/modoboa/core/views/auth.py @@ -39,7 +39,6 @@ logger = logging.getLogger("modoboa.auth") class LoginViewMixin: - @cached_property def logger(self): return logging.getLogger("modoboa.auth") @@ -122,7 +121,7 @@ class LoginView(LoginViewMixin, auth_views.LoginView): username=user.username, password=form.cleaned_data["password"], ) - if user.tfa_enabled: + if user.totp_enabled: self.request.session[constants.TFA_PRE_VERIFY_USER_PK] = user.pk self.request.session[constants.TFA_PRE_VERIFY_USER_BACKEND] = user.backend self.request.session["rememberme"] = form.cleaned_data["rememberme"] @@ -276,10 +275,7 @@ class TwoFactorCodeVerifyView(LoginViewMixin, generic.FormView): context = super().get_context_data(**kwargs) has_totp_device = django_otp.user_has_device(self.user) context["totp_device"] = has_totp_device - has_webauthn_device = models.UserFidoKeys.objects.filter( - user=self.user - ).exists() - context["webauthn_device"] = has_webauthn_device + context["webauthn_device"] = self.user.webauthn_enabled context["nextlocation"] = self.request.POST.get( "next", self.request.GET.get("next") ) diff --git a/modoboa/core/views/user.py b/modoboa/core/views/user.py index ce97e1698..a8105f301 100644 --- a/modoboa/core/views/user.py +++ b/modoboa/core/views/user.py @@ -127,13 +127,13 @@ def api_access(request): def security(request): """View to manage security settings.""" context = {"user_has_device": django_otp.user_has_device(request.user)} - if not request.user.tfa_enabled: + if not request.user.totp_enabled: device = request.user.staticdevice_set.first() if device: tokens = device.token_set.all().values_list("token", flat=True) context.update({"tokens": tokens}) # Set enable flag to True so we can't go here anymore - request.user.tfa_enabled = True + request.user.totp_enabled = True request.user.save() else: device = request.user.totpdevice_set.first()