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 9c8b310d1..6433b3b80 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..f496844fd 100644 --- a/frontend/src/api/auth.js +++ b/frontend/src/api/auth.js @@ -3,5 +3,14 @@ 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('/sms_totp/', data) + }, + changePassword (data) { + return repository.post('/reset_confirm/', 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..24fb2d5fe 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -34,7 +34,9 @@ outlined /> - Forgot password? + + Forgot password? + - - diff --git a/frontend/src/views/user/PasswordRecoveryChangeForm.vue b/frontend/src/views/user/PasswordRecoveryChangeForm.vue new file mode 100644 index 000000000..3055b9d13 --- /dev/null +++ b/frontend/src/views/user/PasswordRecoveryChangeForm.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/frontend/src/views/user/PasswordRecoveryForm.vue b/frontend/src/views/user/PasswordRecoveryForm.vue new file mode 100644 index 000000000..2aecb0607 --- /dev/null +++ b/frontend/src/views/user/PasswordRecoveryForm.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/frontend/src/views/user/PasswordRecoverySmsTotpForm.vue b/frontend/src/views/user/PasswordRecoverySmsTotpForm.vue new file mode 100644 index 000000000..2bcc55089 --- /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..d38123777 100644 --- a/modoboa/core/api/v2/serializers.py +++ b/modoboa/core/api/v2/serializers.py @@ -1,19 +1,55 @@ """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.exceptions import ValidationError as djangoValidationError -import django_otp -from rest_framework import serializers +from django.template import loader -from modoboa.lib import fields as lib_fields +from django.utils import formats +from django.utils.encoding import force_text +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 _ -from ... import constants -from ... import models -from ... import sms_backends -from ... import app_settings +from rest_framework import serializers, status +from rest_framework.exceptions import APIException + +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 + + +class CustomValidationError(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = 'A server error occurred.' + + def __init__(self, detail, status_code): + if status_code is not None: + self.status_code = status_code + if detail is not None: + self.detail = detail + else: + self.detail = {'detail': force_text(self.default_detail)} + + +class NoSMSAvailable(Exception): + """ Raised when no sms totp is available for password reset (to try email).""" + 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 CoreGlobalParametersSerializer(serializers.Serializer): @@ -242,3 +278,193 @@ class UserAPITokenSerializer(serializers.Serializer): """Serializer used by API access routes.""" token = serializers.CharField() + + +class PasswordRecoveryEmailSerializer(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 CustomValidationError({"type": "email", "reason": "No valid user found."}, 404) + 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) + send_mail(subject, body, None, [to_email], fail_silently=False) + + +def send_sms_code(secret, backend, user): + """ Send a sms containing a TOTP code for password reset. """ + 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() + + +class PasswordRecoverySmsSerializer(serializers.Serializer): + + email = serializers.EmailField() + + def validate(self, data, *args, **kwargs): + request = self.context["request"] + clean_email = data["email"] + + user = User.objects.filter(email__iexact=clean_email, is_active=True).first() + if user is None: + raise CustomValidationError({ + "type": "sms", "reason": "No valid user found." + }, 404) + + 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) + send_sms_code(secret, backend, user) + request.session["totp_secret"] = secret + request.session["user_pk"] = user.pk + return data + + +class PasswordRecoverySmsConfirmSerializer(serializers.Serializer): + + sms_totp = serializers.CharField(min_length=6, max_length=6) + + def validate(self, data): + try: + self.context["totp_secret"] = self.context["request"].session["totp_secret"] + except KeyError: + raise CustomValidationError( + {"reason": "totp secret not set in session"}, 403) + if not oath.accept_totp(self.context["totp_secret"], data["sms_totp"])[0]: + raise CustomValidationError({"reason": "Wrong totp"}, 400) + + # Attempt to get user, will raise an error if pk is not valid + self.context["user"] = User.objects.filter( + pk=self.context["request"].session["user_pk"]).first() + if self.context["user"] is None: + raise CustomValidationError({"reason": "Invalid user"}, 400) + + return data + + def save(self): + self.context["request"].session.pop("totp_secret") + user_pk = self.context["request"].session.pop("user_pk") + token = default_token_generator.make_token(self.context["user"]) + uid = urlsafe_base64_encode(force_bytes(user_pk)) + self.context["response"] = (token, uid) + return True + + +class PasswordRecoverySmsResendSerializer(serializers.Serializer): + + def validate(self, data): + try: + self.context["totp_secret"] = self.context["request"].session["totp_secret"] + except KeyError: + raise CustomValidationError( + {"reason": "totp secret not set in session"}, status.HTTP_403_FORBIDDEN) + + # Attempt to get user, will raise an error if pk is not valid + self.context["user"] = User.objects.filter( + pk=self.context["request"].session["user_pk"]).first() + if self.context["user"] is None: + raise CustomValidationError({"reason": "Invalid user"}, 400) + + return data + + def save(self): + backend = sms_backends.get_active_backend( + self.context["request"].localconfig.parameters) + send_sms_code(self.context["totp_secret"], backend, self.context["user"]) + 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 CustomValidationError({"reason": "User not found."}, 404) + + if not default_token_generator.check_token(user, data["token"]): + raise CustomValidationError({"reason": "Wrong reset token"}, 403) + + # Check that the password works with set conditions + try: + password_validation.validate_password(data["new_password1"], user) + except djangoValidationError as e: + raise PasswordRequirementsFailure(e.messages) + 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 diff --git a/modoboa/core/api/v2/tests.py b/modoboa/core/api/v2/tests.py index 84d45be94..6c72b102f 100644 --- a/modoboa/core/api/v2/tests.py +++ b/modoboa/core/api/v2/tests.py @@ -1,12 +1,17 @@ """Core API related tests.""" import copy +import oath from unittest import mock from django.urls import reverse +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode +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", @@ -119,11 +124,11 @@ class AccountViewSetTestCase(ModoAPITestCase): me = resp.json() self.assertEqual(me["username"], "admin") - def test_me_password(self): + def test_me_password(self, password_ko="Toto1234", password_ok="password"): url = reverse("v2:account-check-me-password") - resp = self.client.post(url, {"password": "Toto1234"}, format="json") + resp = self.client.post(url, {"password": password_ko}, format="json") self.assertEqual(resp.status_code, 400) - resp = self.client.post(url, {"password": "password"}, format="json") + resp = self.client.post(url, {"password": password_ok}, format="json") self.assertEqual(resp.status_code, 200) @mock.patch("django_otp.match_token") @@ -200,6 +205,197 @@ class AccountViewSetTestCase(ModoAPITestCase): self.assertEqual(resp.status_code, 201) +class PasswordResetTestCase(AccountViewSetTestCase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.id = 0 + self.sms_token = 0 + + @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")) + + @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) + + # Invalid user + data = {"email": "doesntexist@whoami.com"} + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["type"], "sms") + + account = models.User.objects.get(username="user@test.com") + + # Email reset test + # No secondary email + data = {"email": account.email} + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["type"], "email") + # With secondary email + 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, 200) + self.assertEqual(response.json()["type"], "email") + + # SMS reset test + 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") + + # 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, 200) + self.assertEqual(response.json()["type"], "sms") + + @mock.patch("ovh.Client.get") + @mock.patch("ovh.Client.post") + def test_password_reset_sms_totp(self, client_post, client_get): + """Test reset password by SMS.""" + url_create_sms = reverse("v2:password_reset") + url_sms_totp = reverse("v2:sms_totp") + + # Prepare account + account = models.User.objects.get(username="user@test.com") + account.phone_number = "+33612345678" + account.secondary_email = "toto@test.com" + account.is_active = True + account.save() + + # Send sms + client_get.return_value = ["service"] + client_post.return_value = {"totalCreditsRemoved": 1} + 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" + }) + self.client.logout() + self.create_session() + data = {"email": account.email} + response = self.client.post(url_create_sms, data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["type"], "sms") + self.assertIn("totp_secret", self.client.session) + + # Get the secret + session = self.client.session + secret = session["totp_secret"] + + # Fail to provide type + data = {"sms_totp": "123456"} + response = self.client.post(url_sms_totp, data, format="json") + self.assertEqual(response.status_code, 400) + + # Resend sms + data = {"type": "resend"} + response = self.client.post(url_sms_totp, data, format="json") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["type"], "resend") + + # False totp code + data = {"sms_totp": "123456", "type": "confirm"} + response = self.client.post(url_sms_totp, data, format="json") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["reason"], "Wrong totp") + + # Good totp code + data = {"sms_totp": oath.totp(secret), "type": "confirm"} + response = self.client.post(url_sms_totp, data, format="json") + self.assertEqual(response.status_code, 200) + self.assertIn("token", response.json()) + self.assertIn("id", response.json()) + + @mock.patch("ovh.Client.get") + @mock.patch("ovh.Client.post") + def test_password_change(self, client_post, client_get): + url = reverse("v2:password_reset_confirm_v2") + url_create_sms = reverse("v2:password_reset") + url_sms_totp = reverse("v2:sms_totp") + + client_get.return_value = ["service"] + client_post.return_value = {"totalCreditsRemoved": 1} + 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" + }) + + # Prepare account + account = models.User.objects.get(username="user@test.com") + account.phone_number = "+33612345678" + account.secondary_email = "toto@test.com" + account.is_active = True + account.save() + + # Send SMS + self.create_session() + data = {"email": account.email} + response = self.client.post(url_create_sms, data, format="json") + self.assertEqual(response.status_code, 200) + session = self.client.session + secret = session["totp_secret"] + # Get token and ID + data = {"sms_totp": oath.totp(secret), "type": "confirm"} + response = self.client.post(url_sms_totp, data, format="json") + self.assertEqual(response.status_code, 200) + id_ok = response.json()["id"] + token_ok = response.json()["token"] + + # Password differs + data = {"new_password1": "123456", "new_password2": "123457", "token": token_ok, "id": id_ok} + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 400) + + # Wrong ID + id_ko = urlsafe_base64_encode(force_bytes(-1)) + data = {"new_password1": "123456", "new_password2": "123456", "token": token_ok, "id": id_ko} + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 404) + + # Wrong Token + token_ko = "123456" + data = {"new_password1": "123456", "new_password2": "123456", "token": token_ko, "id": id_ok} + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 403) + + # Failed password requirements + data = {"new_password1": "123456", "new_password2": "123456", "token": token_ok, "id": id_ok} + response = self.client.post(url, data, format="json") + self.assertEqual(response.json()["type"], "password_requirement") + + # All good + data = {"new_password1": "MyHardenedPass1!", "new_password2": "MyHardenedPass1!", "token": token_ok, + "id": id_ok} + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 200) + # 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") diff --git a/modoboa/core/api/v2/urls.py b/modoboa/core/api/v2/urls.py index 5d45049a6..3435412ad 100644 --- a/modoboa/core/api/v2/urls.py +++ b/modoboa/core/api/v2/urls.py @@ -21,5 +21,11 @@ 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('sms_totp/', views.PasswordResetSmsTOTP.as_view(), + name="sms_totp"), ] diff --git a/modoboa/core/api/v2/views.py b/modoboa/core/api/v2/views.py index e78d71d22..ef07211af 100644 --- a/modoboa/core/api/v2/views.py +++ b/modoboa/core/api/v2/views.py @@ -4,16 +4,22 @@ import logging from django.utils.html import escape from django.utils.translation import ugettext as _ +from django.utils.datastructures import MultiValueDictKeyError 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 smtplib import SMTPException + +from . import serializers + logger = logging.getLogger("modoboa.auth") @@ -70,3 +76,84 @@ 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 = serializers.PasswordRecoveryEmailSerializer( + data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + try: + serializer.save() + except SMTPException: + return response.Response({ + "type": "email", + "reason": "Error while sending the email. Please contact an administrator." + }, 503) + # Email response + return response.Response({"type": "email"}, 200) + + +class DefaultPasswordResetView(EmailPasswordResetView): + """ + Works with PasswordRecoveryForm.vue. + First checks if SMS recovery is available, else switch to super (Email recovery [with secondary email]). + """ + + def post(self, request, *args, **kwargs): + """Recover password.""" + serializer = serializers.PasswordRecoverySmsSerializer( + data=request.data, context={'request': request}) + try: + serializer.is_valid(raise_exception=True) + except serializers.NoSMSAvailable: + return super().post(request, *args, **kwargs) + # SMS response + return response.Response({"type": "sms"}, 200) + + +class PasswordResetSmsTOTP(APIView): + """ Check SMS Totp code. """ + + def post(self, request, *args, **kwargs): + try: + if request.data["type"] == "confirm": + klass = serializers.PasswordRecoverySmsConfirmSerializer + elif request.data["type"] == "resend": + klass = serializers.PasswordRecoverySmsResendSerializer + serializer = klass(data=request.data, context={'request': request}) + except (MultiValueDictKeyError, KeyError): + return response.Response({"reason": "No type provided."}, 400) + serializer.is_valid(raise_exception=True) + serializer.save() + payload = {"type": "resend"} + if request.data["type"] == "confirm": + serializer_response = serializer.context["response"] + payload.update({ + "token": serializer_response[0], + "id": serializer_response[1], + "type": "confirm" + }) + return response.Response(payload, 200) + + +class PasswordResetConfirmView(APIView): + """ Get and set new user password. """ + + def post(self, request, *args, **kwargs): + serializer = serializers.PasswordRecoveryConfirmSerializer(data=request.data) + try: + serializer.is_valid(raise_exception=True) + except serializers.PasswordRequirementsFailure as e: + data = {"type": "password_requirement"} + errors = [] + for element in e.message_list: + errors.append(element) + data.update({"errors": errors}) + return response.Response(data, 400) + 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/lib/tests/__init__.py b/modoboa/lib/tests/__init__.py index 0441301d5..608197a41 100644 --- a/modoboa/lib/tests/__init__.py +++ b/modoboa/lib/tests/__init__.py @@ -2,9 +2,12 @@ import socket +from django.conf import settings from django.core import management from django.test import TestCase +from importlib import import_module + from rest_framework.authtoken.models import Token from rest_framework.test import APITestCase @@ -99,3 +102,10 @@ class ModoAPITestCase(ParametersMixin, APITestCase): """Setup.""" super(ModoAPITestCase, self).setUp() self.client.credentials(HTTP_AUTHORIZATION="Token " + self.token.key) + + def create_session(self): + """Enable session storage across requests.""" + session_engine = import_module(settings.SESSION_ENGINE) + store = session_engine.SessionStore() + store.save() + self.client.cookies[settings.SESSION_COOKIE_NAME] = store.session_key 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',