From 39af0485f80752fc53348b89ae3f6d594e21f96f Mon Sep 17 00:00:00 2001 From: Spitap Date: Mon, 5 Dec 2022 16:57:38 +0100 Subject: [PATCH] Imported to main repo --- doc/configuration.rst | 15 ++ doc/upgrade.rst | 35 +++ frontend/src/api/auth.js | 12 + frontend/src/plugins/vee-validate.js | 6 +- frontend/src/router/index.js | 15 ++ frontend/src/views/Login.vue | 5 +- .../views/user/PasswordRecoveryChangeForm.vue | 139 ++++++++++ .../src/views/user/PasswordRecoveryForm.vue | 130 ++++++++++ .../user/PasswordRecoverySmsTotpForm.vue | 127 +++++++++ modoboa/core/api/v2/serializers.py | 242 +++++++++++++++++- modoboa/core/api/v2/tests.py | 45 ++++ modoboa/core/api/v2/urls.py | 10 +- modoboa/core/api/v2/views.py | 87 +++++++ .../core/commands/templates/settings.py.tpl | 4 + modoboa/core/context_processors.py | 8 + modoboa/core/templates/core/dashboard.html | 2 +- modoboa/templates/nlayout.html | 2 +- .../registration/password_reset_email_v2.html | 17 ++ test_project/test_project/settings.py | 4 + 19 files changed, 892 insertions(+), 13 deletions(-) create mode 100644 frontend/src/views/user/PasswordRecoveryChangeForm.vue create mode 100644 frontend/src/views/user/PasswordRecoveryForm.vue create mode 100644 frontend/src/views/user/PasswordRecoverySmsTotpForm.vue create mode 100644 modoboa/templates/registration/password_reset_email_v2.html diff --git a/doc/configuration.rst b/doc/configuration.rst index e41a5caf7..c21709d0e 100644 --- a/doc/configuration.rst +++ b/doc/configuration.rst @@ -183,6 +183,8 @@ To work properly, the system user which runs modoboa (``www-data``, ``apache``, whatever) must have write access to this directory. +.. _customization: + ************* Customization ************* @@ -202,6 +204,19 @@ relative URL of your logo under ``MEDIA_URL``. For example:: Then copy your logo file into the directory indicated by ``MEDIA_ROOT``. +Custom new admin sub-url +======================== + +You can edit the new admin sub-url (by default it is `/new-admin`). + +To do so, open the :file:`settings.py` file and add (or uncomment) +``NEW_ADMIN_URL`` variable. This variable must contain a relative url +without initial and last `'/'`. For example:: + + NEW_ADMIN_URL = 'test_admin' + +You will need to change you web server accordingly and rebuild the frontend. + ****************** Host configuration ****************** diff --git a/doc/upgrade.rst b/doc/upgrade.rst index 403042fca..a667c014b 100644 --- a/doc/upgrade.rst +++ b/doc/upgrade.rst @@ -91,6 +91,41 @@ Rebuild Virtual Environment Specific instructions ********************* +2.0.4 +===== + +* Add ``'modoboa.core.context_processors.new_admin_url',`` at the end of `context_processors` list: + +.. sourcecode:: python + + TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.template.context_processors.i18n', + 'django.template.context_processors.media', + 'django.template.context_processors.static', + 'django.template.context_processors.tz', + 'django.contrib.messages.context_processors.messages', + 'modoboa.core.context_processors.top_notifications', + 'modoboa.core.context_processors.new_admin_url', + ], + 'debug': False, + }, + }, + ] + + +* You now have the possibility to customize the url of the new-admin interface. +To do so please head up to :ref:`the custom configuration chapter ` (advanced user). + + 2.0.3 ===== diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js index 0fec62969..d976c71eb 100644 --- a/frontend/src/api/auth.js +++ b/frontend/src/api/auth.js @@ -3,5 +3,17 @@ import repository from './repository' export default { requestToken (data) { return repository.post('/token/', data) + }, + recoverPassword (data) { + return repository.post('/password_reset/', data) + }, + checkSmsTotp (data) { + return repository.post('/confirm_sms_totp/', data) + }, + changePassword (data) { + return repository.post('/reset_confirm/', data) + }, + resendSmsTotp (data) { + return repository.post('/reset_sms_resend/', data) } } diff --git a/frontend/src/plugins/vee-validate.js b/frontend/src/plugins/vee-validate.js index 0a0a68e61..df8416106 100644 --- a/frontend/src/plugins/vee-validate.js +++ b/frontend/src/plugins/vee-validate.js @@ -1,6 +1,6 @@ import Vue from 'vue' import { ValidationObserver, ValidationProvider, extend } from 'vee-validate' -import { confirmed, numeric, required } from 'vee-validate/dist/rules' +import { confirmed, email, numeric, required } from 'vee-validate/dist/rules' import { translate } from 'vue-gettext' import account from '@/api/account' @@ -19,6 +19,10 @@ extend('required', { ...required, message: () => $gettext('This field is required') }) +extend('email', { + ...email, + message: () => $gettext('Specify an email address') +}) // Add custom rules here extend('validPassword', { diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 6b53339c5..2f0c51e2e 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -172,6 +172,21 @@ const routes = [ requiresAuth: true, layout: 'user' } + }, + { + path: '/password_recovery', + name: 'PasswordRecoveryForm', + component: () => import('../views/user/PasswordRecoveryForm.vue') + }, + { + path: '/password_recovery/confirm/:id?/:token?/', + name: 'PasswordRecoveryChangeForm', + component: () => import('../views/user/PasswordRecoveryChangeForm.vue') + }, + { + path: '/password_recovery/sms_confirm', + name: 'PasswordRecoverySms', + component: () => import('../views/user/PasswordRecoverySmsTotpForm.vue') } ] diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 45b5c7ad4..115f4a1e4 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -34,7 +34,7 @@ outlined /> - Forgot password? + Forgot password? + + + + + + Forgot password? + + +

+ + +
+ + + +
+
+ + Submit + +
+ +
+
+ + + + + diff --git a/frontend/src/views/user/PasswordRecoveryForm.vue b/frontend/src/views/user/PasswordRecoveryForm.vue new file mode 100644 index 000000000..1e79a561a --- /dev/null +++ b/frontend/src/views/user/PasswordRecoveryForm.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/frontend/src/views/user/PasswordRecoverySmsTotpForm.vue b/frontend/src/views/user/PasswordRecoverySmsTotpForm.vue new file mode 100644 index 000000000..5c0211184 --- /dev/null +++ b/frontend/src/views/user/PasswordRecoverySmsTotpForm.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/modoboa/core/api/v2/serializers.py b/modoboa/core/api/v2/serializers.py index 470defbf2..1ce01f2ce 100644 --- a/modoboa/core/api/v2/serializers.py +++ b/modoboa/core/api/v2/serializers.py @@ -1,19 +1,56 @@ """Core API v2 serializers.""" +import django_otp +import oath -from django.utils import formats -from django.utils.translation import ugettext_lazy, ugettext as _ +from django.db.models import Q from django.contrib.auth import password_validation +from django.contrib.sites.shortcuts import get_current_site +from django.contrib.auth.tokens import default_token_generator +from django.core.mail import send_mail +from django.core.validators import validate_email +from django.core.exceptions import ValidationError as djangoValidationError + +from django.template import loader + +from django.utils import formats +from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode +from django.utils.encoding import force_bytes +from django.utils.translation import ugettext_lazy, ugettext as _ -import django_otp from rest_framework import serializers -from modoboa.lib import fields as lib_fields +from modoboa.core import constants, models, sms_backends, app_settings, context_processors +from modoboa.core.models import User +from modoboa.lib import fields as lib_fields, cryptutils -from ... import constants -from ... import models -from ... import sms_backends -from ... import app_settings + +class NoSMSAvailable(Exception): + """ Raised when no sms totp is availible for password reset (to try email).""" + pass + + +class UserNotFound(Exception): + """ Raised when no valid user has been found (to have a proper 404 http code instead of 400.""" + pass + + +class EmailSendingFailure(Exception): + """ Raised when a reset email has failed to send.""" + pass + + +class PasswordRequirementsFailure(Exception): + """ Raised when django is not happy with password provided.""" + + def __init__(self, message_list, *args: object) -> None: + super().__init__(*args) + self.message_list = message_list + + +class InvalidToken(Exception): + """ Raised during password reset if token is not valid.""" + pass class CoreGlobalParametersSerializer(serializers.Serializer): @@ -242,3 +279,192 @@ class UserAPITokenSerializer(serializers.Serializer): """Serializer used by API access routes.""" token = serializers.CharField() + + +class EmailPasswordRecoveryInitSerializer(serializers.Serializer): + """Serializer used by password recovery route.""" + + email = serializers.EmailField() + + def validate(self, data): + clean_email = data["email"] + self.context["user"] = (User.objects.filter( + email__iexact=clean_email, is_active=True) + .exclude(Q(secondary_email__isnull=True) | Q(secondary_email="")) + ).first() + if self.context["user"] is None: + raise UserNotFound() + return data + + def save(self): + user = self.context["user"] + to_email = user.secondary_email + current_site = get_current_site(self.context["request"]) + site_name = current_site.name + domain = current_site.domain + context = { + 'email': to_email, + 'domain': domain, + 'site_name': site_name, + 'uid': urlsafe_base64_encode(force_bytes(user.pk)), + 'user': user, + 'token': default_token_generator.make_token(user), + 'protocol': 'https', + } + context.update(context_processors.new_admin_url()) + subject = loader.render_to_string( + "registration/password_reset_subject.txt", context) + # Email subject *must not* contain newlines + subject = ''.join(subject.splitlines()) + body = loader.render_to_string( + "registration/password_reset_email_v2.html", context) + try: + if send_mail(subject, body, None, [to_email]) == 0: + raise EmailSendingFailure() + except: + raise EmailSendingFailure() + + +class SMSPasswordRecoveryInitSerializer(serializers.Serializer): + + email = serializers.EmailField() + + def validate(self, data, *args, **kwargs): + request = self.context["request"] + clean_email = data["email"] + + self.context["user"] = (User.objects.filter( + email__iexact=clean_email, is_active=True) + .exclude(Q(phone_number__isnull=True) | Q(phone_number="")) + ).first() + if self.context["user"] is None: + raise NoSMSAvailable() + + if not request.localconfig.parameters.get_value("sms_password_recovery"): + raise NoSMSAvailable() + + user = self.context["user"] + if not user: + raise NoSMSAvailable() + backend = sms_backends.get_active_backend( + request.localconfig.parameters) + secret = cryptutils.random_hex_key(20) + code = oath.totp(secret) + text = _( + "Please use the following code to recover your Modoboa password: {}" + .format(code) + ) + if not backend.send(text, [str(user.phone_number)]): + raise NoSMSAvailable() + request.session["totp_secret"] = secret + request.session["user_pk"] = user.pk + return data + + +class PasswordRecoverySmsSerializer(serializers.Serializer): + + sms_totp = serializers.CharField(min_lenght=6, max_length=6) + + def validate(self, data): + try: + totp_secret = self.context["request"].session["totp_secret"] + self.context["request"].session.pop("totp_secret") + except KeyError: + raise serializers.ValidationError( + "totp secret not set in session", 403) + + if not oath.accept_totp(totp_secret, data["sms_totp"]): + raise serializers.ValidationError("Wrong totp", 403) + + # Attempt to get user, will raise an error if pk is not valid + self.user = User.objects.get( + pk=self.context["user_pk"]) + + return data + + def save(self): + self.context["request"].session.pop("totp_secret") + self.context["request"].session.pop("user") + token = default_token_generator.make_token(self.user) + uid = urlsafe_base64_encode(force_bytes(self.user.pk)) + self.context["response"] = (token, uid) + return True + + +class PasswordRecoveryConfirmSerializer(serializers.Serializer): + + new_password1 = serializers.CharField() + new_password2 = serializers.CharField() + + token = serializers.CharField() + + id = serializers.CharField() + + def get_user(self, user_id): + try: + # urlsafe_base64_decode() decodes to bytestring + uid = urlsafe_base64_decode(user_id).decode() + user = User.objects.get(pk=uid) + except (TypeError, ValueError, OverflowError, User.DoesNotExist): + user = None + return user + + def validate(self, data): + + # Validate password + if data["new_password1"] == "" or data["new_password1"] != data["new_password2"]: + raise serializers.ValidationError( + "Password is empty or does not match") + + user = self.get_user(data["id"]) + + if user is None: + raise UserNotFound + + if not default_token_generator.check_token(user, data["token"]): + raise InvalidToken + + # Check that the password works with set conditions + try: + password_validation.validate_password(data["new_password1"], user) + except djangoValidationError as e: + raise PasswordRequirementsFailure(e.error_list) + + self.context["user"] = user + return super().validate(data) + + def save(self): + user = self.context["user"] + user.set_password(self.validated_data["new_password1"]) + user.save() + return True + + +class PasswordRecoverySmsResendSerializer(serializers.Serializer): + + def validate(self, data): + + try: + self.context["session"].user_pk + self.context["session"].totp_token + return data + except KeyError: + raise serializers.ValidationError( + "No previous reset attempt recorded", 403) + + def save(self): + user = User.objects.get( + pk=self.context["session"].user_pk) + request = self.context["request"] + backend = sms_backends.get_active_backend( + request.localconfig.parameters) + secret = cryptutils.random_hex_key(20) + code = oath.totp(secret) + text = _( + "Please use the following code to recover your Modoboa password: {}" + .format(code) + ) + if not backend.send(text, [str(user.phone_number)]): + return False + request.session["totp_secret"] = secret + request.session["user_pk"] = user.pk diff --git a/modoboa/core/api/v2/tests.py b/modoboa/core/api/v2/tests.py index 84d45be94..5d1b2a770 100644 --- a/modoboa/core/api/v2/tests.py +++ b/modoboa/core/api/v2/tests.py @@ -5,8 +5,10 @@ from unittest import mock from django.urls import reverse +from modoboa.admin import factories from modoboa.core import models from modoboa.lib.tests import ModoAPITestCase +from rest_framework.authtoken.models import Token CORE_SETTINGS = { "authentication_type": "local", @@ -112,6 +114,14 @@ class ParametersAPITestCase(ModoAPITestCase): class AccountViewSetTestCase(ModoAPITestCase): + @classmethod + def setUpTestData(cls): # NOQA:N802 + """Create test data.""" + super().setUpTestData() + factories.populate_database() + cls.da_token = Token.objects.create( + user=models.User.objects.get(username="admin@test.com")) + def test_me(self): url = reverse("v2:account-me") resp = self.client.get(url) @@ -199,6 +209,41 @@ class AccountViewSetTestCase(ModoAPITestCase): resp = self.client.post(url) self.assertEqual(resp.status_code, 201) + @mock.patch("ovh.Client.get") + @mock.patch("ovh.Client.post") + def test_reset_password(self, client_post, client_get): + url = reverse("v2:password_reset") + # No payload provided + response = self.client.post(url) + self.assertEqual(response.status_code, 400) + + # SMS parameter + self.set_global_parameters({ + "sms_password_recovery": True, + "sms_provider": "ovh", + "sms_ovh_application_key": "key", + "sms_ovh_application_secret": "secret", + "sms_ovh_consumer_key": "consumer" + }, app="core") + account = models.User.objects.get(username="user@test.com") + data = {"email": account.email} + # Provide a user w/o phone or secondary + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 404) + # Secondary email for reset + account.secondary_email = "toto@test.com" + account.is_active = True + account.save() + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 204) + # Phone number + account.phone_number = "+33612345678" + account.save() + client_get.return_value = ["service"] + client_post.return_value = {"totalCreditsRemoved": 1} + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 233) + class AuthenticationTestCase(ModoAPITestCase): diff --git a/modoboa/core/api/v2/urls.py b/modoboa/core/api/v2/urls.py index 5d45049a6..7f66e172f 100644 --- a/modoboa/core/api/v2/urls.py +++ b/modoboa/core/api/v2/urls.py @@ -21,5 +21,13 @@ urlpatterns += [ path('token/', views.TokenObtainPairView.as_view(), name="token_obtain_pair"), path('token/refresh/', TokenRefreshView.as_view(), - name='token_refresh'), + name="token_refresh"), + path('password_reset/', views.DefaultPasswordResetView.as_view(), + name="password_reset"), + path('reset_confirm/', views.PasswordResetConfirmView.as_view(), + name="password_reset_confirm_v2"), + path('confirm_sms_totp/', views.PasswordResetConfirmSmsCodeView.as_view(), + name="confirm_sms_totp"), + path('reset_sms_resend', views.PasswordResetResendSmsCodeView.as_view(), + name='reset_sms_resend'), ] diff --git a/modoboa/core/api/v2/views.py b/modoboa/core/api/v2/views.py index e78d71d22..cdc5dbb47 100644 --- a/modoboa/core/api/v2/views.py +++ b/modoboa/core/api/v2/views.py @@ -10,10 +10,13 @@ from django.contrib.auth import login from rest_framework import response, status from rest_framework_simplejwt import views as jwt_views from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +from rest_framework.views import APIView from modoboa.core.password_hashers import get_password_hasher from modoboa.parameters import tools as param_tools +from .serializers import * + logger = logging.getLogger("modoboa.auth") @@ -70,3 +73,87 @@ class TokenObtainPairView(jwt_views.TokenObtainPairView): return response.Response( serializer.validated_data, status=status.HTTP_200_OK) + + +class EmailPasswordResetView(APIView): + """ + An Api View which provides a method to request a password reset token based on an e-mail address. + """ + + def post(self, request, *args, **kwargs): + serializer = EmailPasswordRecoveryInitSerializer( + data=request.data, context={'request': request}) + try: + serializer.is_valid(raise_exception=True) + except UserNotFound: + return response.Response({"no valid user found"}, 404) + try: + serializer.save() + except EmailSendingFailure: + return response.Response({"Email failed to send, possibly a misconfiguration?"}, 502) + # Email response + return response.Response(status=204) + + +class DefaultPasswordResetView(EmailPasswordResetView): + """ + Works with PasswordRecoveryForm.vue. + First checks if SMS recovery is available, else switch to super (Email recovery [with seconday email]). + """ + + def post(self, request, *args, **kwargs): + """Recover password.""" + serializer = SMSPasswordRecoveryInitSerializer( + data=request.data, context={'request': request}) + try: + serializer.is_valid(raise_exception=True) + except NoSMSAvailable: + return super().post(request, *args, **kwargs) + # SMS response + return response.Response(status=233) + + +class PasswordResetConfirmSmsCodeView(APIView): + """ Check SMS Totp code. """ + + def post(self, request, *args, **kwargs): + serializer = PasswordRecoverySmsSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + + payload = serializer.context["response"] + payload = {"token": payload[0], "id": payload[1]} + + return response.Response(payload, 200) + + +class PasswordResetConfirmView(APIView): + """ Get and set new user password. """ + + def post(self, request, *args, **kwargs): + serializer = PasswordRecoveryConfirmSerializer(data=request.data) + try: + serializer.is_valid(raise_exception=True) + except (PasswordRequirementsFailure, UserNotFound, InvalidToken) as e: + if type(e) == PasswordRequirementsFailure: + errors = [] + for element in e.message_list: + errors.append(element) + return response.Response(errors, 455) + elif type(e) == UserNotFound: + return response.Response(status=404) + elif type(e) == InvalidToken: + return response.Response(status=401) + serializer.save() + return response.Response(status=200) + + +class PasswordResetResendSmsCodeView(APIView): + + def post(self, request, *args, **kwargs): + serializer = PasswordRecoverySmsResendSerializer( + data=request.data, context={"request": request}) + + serializer.is_valid(raise_exception=True) + serializer.save() + return response.Response(status=200) diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index bdbde5683..03f4a7ce2 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -141,6 +141,7 @@ TEMPLATES = [ 'django.template.context_processors.tz', 'django.contrib.messages.context_processors.messages', 'modoboa.core.context_processors.top_notifications', + 'modoboa.core.context_processors.new_admin_url', ], 'debug': {{ devmode }}, }, @@ -202,6 +203,9 @@ REST_FRAMEWORK = { } +# Uncomment if you need a custom sub url for new_admin interface +# NEW_ADMIN_URL = 'new_admin' + SPECTACULAR_SETTINGS = { 'SCHEMA_PATH_PREFIX': r'/api/v[0-9]', 'TITLE': 'Modoboa API', diff --git a/modoboa/core/context_processors.py b/modoboa/core/context_processors.py index 3bd8a3c92..2f9126ec4 100644 --- a/modoboa/core/context_processors.py +++ b/modoboa/core/context_processors.py @@ -1,6 +1,7 @@ """Custom context processors.""" from functools import reduce +from django.conf import settings from . import signals @@ -19,3 +20,10 @@ def top_notifications(request): "notifications_check_interval": interval * 1000, "top_notifications": notifications } + + +def new_admin_url(*args): + """A context processor to include new admin url.""" + return { + "new_admin": getattr(settings, "NEW_ADMIN_URL", "new_admin") + } diff --git a/modoboa/core/templates/core/dashboard.html b/modoboa/core/templates/core/dashboard.html index ae96a0ad6..d22e36ea2 100644 --- a/modoboa/core/templates/core/dashboard.html +++ b/modoboa/core/templates/core/dashboard.html @@ -8,7 +8,7 @@ {% if user.is_admin %}
Want to check the new admin interface? - Go to Modoboa v2 + Go to Modoboa v2
{% endif %} diff --git a/modoboa/templates/nlayout.html b/modoboa/templates/nlayout.html index 8a219c595..0d851d75d 100644 --- a/modoboa/templates/nlayout.html +++ b/modoboa/templates/nlayout.html @@ -53,7 +53,7 @@
  • {% if user.is_admin %}
  • - + {% trans "New admin" %}
  • diff --git a/modoboa/templates/registration/password_reset_email_v2.html b/modoboa/templates/registration/password_reset_email_v2.html new file mode 100644 index 000000000..db6213367 --- /dev/null +++ b/modoboa/templates/registration/password_reset_email_v2.html @@ -0,0 +1,17 @@ +{% load i18n %} + +{% autoescape off %} + {% blocktrans with username=user.get_username trimmed %} + To initiate the password reset process for your {{ username }} Modoboa account, click the link below: + {% endblocktrans %} + + {{ protocol }}://{{ domain }}/{{ new_admin }}/password_recovery/confirm/{{ uid }}/{{ token }} + + {% blocktrans trimmed %} + If clicking the link above doesn't work, please copy and paste the URL in a new browser window instead. + {% endblocktrans %} + + {% blocktrans trimmed %} + Sincerely, The Modoboa Team. + {% endblocktrans %} +{% endautoescape %} diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index e701cb6fb..da414290e 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -147,6 +147,7 @@ TEMPLATES = [ 'django.template.context_processors.tz', 'django.contrib.messages.context_processors.messages', 'modoboa.core.context_processors.top_notifications', + 'modoboa.core.context_processors.new_admin_url' ], 'debug': DEBUG, }, @@ -194,6 +195,9 @@ REST_FRAMEWORK = { 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning', } +# Uncomment if you need a custom sub url for new_admin interface +# NEW_ADMIN_URL = 'new_admin' + SPECTACULAR_SETTINGS = { 'SCHEMA_PATH_PREFIX': r'/api/v[0-9]', 'TITLE': 'Modoboa API',