diff --git a/.gitignore b/.gitignore index 744a42bf3..09ebf86ce 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,5 @@ test_project/modoboa.db .ash_history modoboa.kdev4 .kateproject +test_project/cert.crt +test_project/cert.key diff --git a/docker-compose.yml b/docker-compose.yml index ca6c9b327..b0f3ef359 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,7 @@ services: python3 manage.py migrate python3 manage.py load_initial_data django-admin compilemessages - python3 manage.py runserver 0:8000" + python3 manage.py runserver_plus 0.0.0.0:8000 --cert-file cert.crt" volumes: - .:/code diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index cd426dca6..cc5258784 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -15,6 +15,7 @@ COPY requirements.txt /tmp COPY test-requirements.txt /tmp RUN python -m pip install -U pip RUN pip install -r requirements.txt -r test-requirements.txt +RUN pip install django-extensions Werkzeug pyOpenSSL FROM base as run COPY docker/doveadm /usr/local/bin diff --git a/frontend/package.json b/frontend/package.json index ce4e699f9..ce01e1e7d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "vuetify": "^3.5.4" }, "devDependencies": { + "@vitejs/plugin-basic-ssl": "^1.1.0", "@vitejs/plugin-vue": "^5.0.4", "eslint": "^8.56.0", "eslint-plugin-vue": "^9.21.1", diff --git a/frontend/public/config.json b/frontend/public/config.json index 29d53a3df..59e06a1dc 100644 --- a/frontend/public/config.json +++ b/frontend/public/config.json @@ -1,7 +1,7 @@ { "API_BASE_URL": "/api/v2/", "API_DOC_URL": "/api/schema-v2/swagger/", - "OAUTH_AUTHORITY_URL": "http://localhost:8000/api/o", + "OAUTH_AUTHORITY_URL": "https://localhost:8000/api/o", "OAUTH_CLIENT_ID": "LVQbfIIX3khWR3nDvix1u9yEGHZUxcx53bhJ7FlD", - "OAUTH_REDIRECT_URI": "http://localhost:3000/login/logged" + "OAUTH_REDIRECT_URI": "https://localhost:3000/login/logged" } diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js index f9fade5dc..8a661346c 100644 --- a/frontend/src/api/auth.js +++ b/frontend/src/api/auth.js @@ -13,4 +13,7 @@ export default { changePassword(data) { return repository.post('/reset_confirm/', data) }, + beginFidoRegistration() { + return repository.post('/fido/registration/begin/') + }, } diff --git a/frontend/src/components/account/FidoAuthForm.vue b/frontend/src/components/account/FidoAuthForm.vue new file mode 100644 index 000000000..f0224a55a --- /dev/null +++ b/frontend/src/components/account/FidoAuthForm.vue @@ -0,0 +1,206 @@ + + + diff --git a/frontend/src/components/account/FidoRegistrationDialog.vue b/frontend/src/components/account/FidoRegistrationDialog.vue new file mode 100644 index 000000000..aefab4c0d --- /dev/null +++ b/frontend/src/components/account/FidoRegistrationDialog.vue @@ -0,0 +1,54 @@ + + + diff --git a/frontend/src/views/account/APISetupView.vue b/frontend/src/views/account/APISetupView.vue deleted file mode 100644 index c1cc75d1e..000000000 --- a/frontend/src/views/account/APISetupView.vue +++ /dev/null @@ -1,131 +0,0 @@ - - - - - diff --git a/frontend/src/views/account/ForwardView.vue b/frontend/src/views/account/ForwardView.vue deleted file mode 100644 index 0c34331c0..000000000 --- a/frontend/src/views/account/ForwardView.vue +++ /dev/null @@ -1,18 +0,0 @@ - - - - - diff --git a/frontend/src/views/account/ProfileView.vue b/frontend/src/views/account/ProfileView.vue deleted file mode 100644 index 89b5bdd5c..000000000 --- a/frontend/src/views/account/ProfileView.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - - - diff --git a/frontend/src/views/account/SecurityView.vue b/frontend/src/views/account/SecurityView.vue deleted file mode 100644 index dddfd6085..000000000 --- a/frontend/src/views/account/SecurityView.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - - - diff --git a/frontend/src/views/account/SettingsView.vue b/frontend/src/views/account/SettingsView.vue index 6180c83e9..a33203059 100644 --- a/frontend/src/views/account/SettingsView.vue +++ b/frontend/src/views/account/SettingsView.vue @@ -30,6 +30,7 @@ + @@ -51,6 +52,7 @@ import TwoFactorAuthForm from '@/components/account/TwoFactorAuthForm.vue' import ForwardForm from '@/components/account/ForwardForm.vue' import AutoReplyForm from '@/components/account/AutoReplyForm.vue' import APISetupForm from '@/components/account/APISetupForm.vue' +import FidoAuthForm from '@/components/account/FidoAuthForm.vue' import { useAuthStore } from '@/stores' const authStore = useAuthStore() diff --git a/frontend/vite.config.js b/frontend/vite.config.js index b5d3d6fb1..004c0e2e7 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -2,6 +2,7 @@ import vue from '@vitejs/plugin-vue' import vuetify, { transformAssetUrls } from 'vite-plugin-vuetify' import ViteFonts from 'unplugin-fonts/vite' +import basicSsl from '@vitejs/plugin-basic-ssl' // Utilities import { defineConfig } from 'vite' @@ -10,6 +11,7 @@ import { fileURLToPath, URL } from 'node:url' // https://vitejs.dev/config/ export default defineConfig({ plugins: [ + basicSsl(), vue({ template: { transformAssetUrls }, }), @@ -42,10 +44,14 @@ export default defineConfig({ server: { port: 3000, proxy: { - '/api': - process.env.DOCKER == 'yes' - ? 'http://api:8000' - : 'http://127.0.0.1:8000', + '/api': { + target: + process.env.DOCKER == 'yes' + ? 'https://api:8000' + : 'http://127.0.0.1:8000', + secure: false, + }, }, + https: true, }, }) diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 0c6420e74..0ff26f5a3 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -331,6 +331,11 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== +"@vitejs/plugin-basic-ssl@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.1.0.tgz#8b840305a6b48e8764803435ec0c716fa27d3802" + integrity sha512-wO4Dk/rm8u7RNhOf95ZzcEmC9rYOncYgvq4z3duaJrCgjN8BxAnDVyndanfcJZ0O6XZzHz6Q0hTimxTg8Y9g/A== + "@vitejs/plugin-vue@^5.0.4": version "5.0.4" resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.0.4.tgz#508d6a0f2440f86945835d903fcc0d95d1bb8a37" diff --git a/modoboa/core/api/v2/serializers.py b/modoboa/core/api/v2/serializers.py index d52d86acd..2ab2ae9d4 100644 --- a/modoboa/core/api/v2/serializers.py +++ b/modoboa/core/api/v2/serializers.py @@ -238,6 +238,22 @@ class CoreGlobalParametersSerializer(serializers.Serializer): return data +class FIDOSerializer(serializers.ModelSerializer): + class Meta: + model = models.UserFidoKeys + fields = ["id", "name", "enabled", "added_on", "last_used"] + + +class FidoRegistrationSerializer(serializers.Serializer): + """Serializer used to finish the fido key registration.""" + + type = serializers.CharField() + id = serializers.CharField() + raw_id = serializers.CharField() + authenticatorAttachment = serializers.CharField() + response = serializers.JSONField() + + class LogSerializer(serializers.ModelSerializer): """Log serializer.""" diff --git a/modoboa/core/api/v2/urls.py b/modoboa/core/api/v2/urls.py index 48993e231..dd6f47cd5 100644 --- a/modoboa/core/api/v2/urls.py +++ b/modoboa/core/api/v2/urls.py @@ -12,6 +12,7 @@ router = routers.SimpleRouter() router.register(r"account", viewsets.AccountViewSet, basename="account") router.register(r"languages", viewsets.LanguageViewSet, basename="language") router.register(r"logs/audit-trail", viewsets.LogViewSet) +router.register(r"fido", viewsets.FIDOViewSet, basename="fido") urlpatterns = router.urls urlpatterns += [ diff --git a/modoboa/core/api/v2/views.py b/modoboa/core/api/v2/views.py index 7f3b5ac92..70899b56c 100644 --- a/modoboa/core/api/v2/views.py +++ b/modoboa/core/api/v2/views.py @@ -27,7 +27,7 @@ def delete_cache_key(class_target, throttles, request): """Attempt to delete cache key from throttling on login/password reset success.""" for throttle in throttles: - if type(throttle) == class_target: + if type(throttle) is class_target: throttle.reset_cache(request) return diff --git a/modoboa/core/api/v2/viewsets.py b/modoboa/core/api/v2/viewsets.py index a0b469016..a86173453 100644 --- a/modoboa/core/api/v2/viewsets.py +++ b/modoboa/core/api/v2/viewsets.py @@ -2,7 +2,7 @@ from django_otp.plugins.otp_static.models import StaticDevice, StaticToken from drf_spectacular.utils import extend_schema -from rest_framework import filters, permissions, response, viewsets +from rest_framework import filters, permissions, response, viewsets, mixins from rest_framework.authtoken.models import Token from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied @@ -16,6 +16,7 @@ from modoboa.lib import pagination from modoboa.lib.throttle import GetThrottleViewsetMixin from ... import constants +from ... import fido2_auth as f2_auth from ... import models from . import serializers @@ -167,3 +168,33 @@ class LanguageViewSet(GetThrottleViewsetMixin, viewsets.ViewSet): {"code": lang[0], "label": lang[1]} for lang in constants.LANGUAGES ] return response.Response(languages) + + +class FIDOViewSet(GetThrottleViewsetMixin, + mixins.ListModelMixin, + mixins.DestroyModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet): + """Fido management viewset. """ + + permission_classes = (permissions.IsAuthenticated,) + serializer_class = serializers.FIDOSerializer + + def get_queryset(self): + return models.UserFidoKeys.objects.filter(user=self.request.user) + + @action(methods=["post"], detail=False, url_path="registration/begin") + def registration_begin(self, request): + """An Api View which starts the registration process of a fido key.""" + options = f2_auth.begin_registration(request) + return response.Response(dict(options)) + + @action(methods=["post"], detail=False, url_path="registration/end") + def registration_end(self, request): + """An Api View to complete the registration process of a fido key.""" + serializer = serializers.FidoRegistrationSerializer( + data=request.data) + serializer.is_valid(raise_exception=True) + f2_auth.end_registration(request.data, + request.session.pop("fido2_state"), + request.user.id) diff --git a/modoboa/core/fido2_auth.py b/modoboa/core/fido2_auth.py new file mode 100644 index 000000000..d57e3436b --- /dev/null +++ b/modoboa/core/fido2_auth.py @@ -0,0 +1,49 @@ + +from .models import UserFidoKeys + +from fido2.webauthn import PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity, AttestedCredentialData, UserVerificationRequirement +from fido2.server import Fido2Server +from fido2.utils import websafe_decode +import fido2.features + + +def set_json_mapping(): + try: + fido2.features.webauthn_json_mapping.enabled = True + except ValueError: + pass + + +def create_fido2_server(): + rp = PublicKeyCredentialRpEntity(name="Demo server", id="localhost") + return Fido2Server(rp) + + +def get_creds_from_user(user_id): + [AttestedCredentialData(websafe_decode(uf.token)) for uf in UserFidoKeys.objects.filter(user=user_id)] + + +def begin_registration(request): + set_json_mapping() + server = create_fido2_server() + options, state = server.register_begin( + PublicKeyCredentialUserEntity( + id=request.user.pk, + name=request.user.username.encode('utf-8'), + display_name=request.user.username, + ), + get_creds_from_user(request.user.pk), + user_verification=UserVerificationRequirement.DISCOURAGED + ) + request.session['fido2_state'] = state + return options + + +def end_registration(data, fido2_state, user_id): + set_json_mapping() + server = create_fido2_server() + server.authenticate_complete( + fido2_state, + get_creds_from_user(user_id), + data["response"], + ) diff --git a/modoboa/core/migrations/0028_userfidokeys.py b/modoboa/core/migrations/0028_userfidokeys.py new file mode 100644 index 000000000..6f16f657c --- /dev/null +++ b/modoboa/core/migrations/0028_userfidokeys.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.11 on 2024-04-18 15:18 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0027_alter_user_language'), + ] + + operations = [ + migrations.CreateModel( + name='UserFidoKeys', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('enabled', models.BooleanField(default=True)), + ('platform', models.CharField(default='', max_length=255)), + ('added_on', models.DateTimeField(auto_now_add=True)), + ('last_used', models.DateTimeField(default=None, null=True)), + ('credential_id', models.CharField(max_length=255, unique=True)), + ('token', models.CharField(max_length=255)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/modoboa/core/models.py b/modoboa/core/models.py index fabcca31f..5d8a55c92 100644 --- a/modoboa/core/models.py +++ b/modoboa/core/models.py @@ -412,6 +412,17 @@ class User(AbstractUser): reversion.register(User) +class UserFidoKeys(models.Model): + user = models.ForeignKey(User ,on_delete=models.CASCADE) + name = models.CharField(max_length=255) + enabled = models.BooleanField(default=True) + platform = models.CharField(max_length=255,default='') + added_on = models.DateTimeField(auto_now_add=True) + last_used = models.DateTimeField(null=True,default=None) + credential_id = models.CharField(max_length=255, unique=True) + token = models.CharField(max_length=255, null=False) + + def populate_callback(user, group="SimpleUsers"): """Populate callback diff --git a/requirements.txt b/requirements.txt index 9b42637be..8857dfe39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,7 @@ bcrypt # Requires libffi-dev and python-dev asgiref dnspython==2.6.1 feedparser==6.0.11 +fido2==1.1.3 gevent==24.2.1 Pillow # Optional dependency for django-ckeditor progressbar33==2.4 diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 6bbf33ab2..c80457f14 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -76,6 +76,7 @@ INSTALLED_APPS = ( "django_otp.plugins.otp_static", "django_rename_app", "django_rq", + "django_extensions", # Just for docker (SSL support) ) # A dedicated place to register Modoboa applications