From a40d46db948b55ffc8e664fb70c2b556fae84cad Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Wed, 21 Oct 2020 10:42:56 +0200 Subject: [PATCH] Feature/tfa (#2037) * WIP: two-factor authentication. * Added support for 2FA using TOTP devices. see #1760 #1672 #1455 * Added documentation. * Updated documentation. * Added missing qrcode requirement. * Fixed unit tests. --- doc/upgrade.rst | 65 +++++++++++ .../core/commands/templates/settings.py.tpl | 9 ++ modoboa/core/forms.py | 17 ++- modoboa/core/middleware.py | 27 +++++ .../core/migrations/0022_user_tfa_enabled.py | 18 +++ modoboa/core/models.py | 3 + modoboa/core/serializers.py | 22 ++++ modoboa/core/static/core/js/settings.js | 2 +- .../core/templates/core/user_security.html | 103 ++++++++++++++++++ modoboa/core/templatetags/core_tags.py | 4 + modoboa/core/tests/test_authentication.py | 22 ++++ modoboa/core/tests/test_viewsets.py | 74 +++++++++++++ modoboa/core/urls.py | 4 + modoboa/core/urls_api.py | 11 ++ modoboa/core/views/__init__.py | 7 +- modoboa/core/views/auth.py | 20 +++- modoboa/core/views/user.py | 37 ++++++- modoboa/core/viewsets.py | 72 ++++++++++++ modoboa/static/js/global.js | 35 +++--- modoboa/static/js/twocols_nav.js | 66 +++++++++++ .../registration/twofactor_code_verify.html | 30 +++++ modoboa/urls_api.py | 1 + requirements.txt | 2 + test_project/test_project/settings.py | 5 + 24 files changed, 635 insertions(+), 21 deletions(-) create mode 100644 modoboa/core/migrations/0022_user_tfa_enabled.py create mode 100644 modoboa/core/serializers.py create mode 100644 modoboa/core/templates/core/user_security.html create mode 100644 modoboa/core/tests/test_viewsets.py create mode 100644 modoboa/core/urls_api.py create mode 100644 modoboa/core/viewsets.py create mode 100644 modoboa/templates/registration/twofactor_code_verify.html diff --git a/doc/upgrade.rst b/doc/upgrade.rst index fde220ebd..6b0f616f0 100644 --- a/doc/upgrade.rst +++ b/doc/upgrade.rst @@ -91,6 +91,71 @@ Rebuild Virtual Environment Specific instructions ********************* +1.17.0 +====== + +Modoboa now supports Two-Factor authentication using TOTP +applications. + +To enable it, install the following new requirement in your +virtualenv: + +.. sourcecode:: bash + + (env)> pip install django-otp qrcode + +Then, open the :file:`settings.py` file and add ``django_otp`` +packages to ``INSTALLED_APPS``: + +.. sourcecode:: python + + INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.sites', + 'django.contrib.staticfiles', + 'reversion', + 'ckeditor', + 'ckeditor_uploader', + 'rest_framework', + 'rest_framework.authtoken', + 'django_otp', + 'django_otp.plugins.otp_totp', + 'django_otp.plugins.otp_static', + ) + +Add new middlewares to ``MIDDLEWARE``: + +.. sourcecode:: python + + MIDDLEWARE = ( + 'x_forwarded_for.middleware.XForwardedForMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django_otp.middleware.OTPMiddleware', + 'modoboa.core.middleware.TwoFAMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'modoboa.core.middleware.LocalConfigMiddleware', + 'modoboa.lib.middleware.AjaxLoginRedirect', + 'modoboa.lib.middleware.CommonExceptionCatcher', + 'modoboa.lib.middleware.RequestCatcherMiddleware', + ) + +And add the following new setting: + +.. sourcecode:: python + + # 2FA + + OTP_TOTP_ISSUER = "" + + 1.16.0 ====== diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index 169f5d8f9..c604ca92b 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -65,6 +65,9 @@ INSTALLED_APPS = ( 'rest_framework', 'rest_framework.authtoken', 'phonenumber_field', + 'django_otp', + 'django_otp.plugins.otp_totp', + 'django_otp.plugins.otp_static', {% if devmode %} 'djangobower',{% endif %} ) @@ -98,6 +101,8 @@ MIDDLEWARE = ( 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django_otp.middleware.OTPMiddleware', + 'modoboa.core.middleware.TwoFAMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.locale.LocaleMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', @@ -230,6 +235,10 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] +# 2FA + +OTP_TOTP_ISSUER = "{{ allowed_host }}" + # CKeditor CKEDITOR_UPLOAD_PATH = "uploads/" diff --git a/modoboa/core/forms.py b/modoboa/core/forms.py index 96873a861..f06118530 100644 --- a/modoboa/core/forms.py +++ b/modoboa/core/forms.py @@ -1,6 +1,5 @@ """Core forms.""" -# import pyotp import oath from django import forms @@ -10,6 +9,9 @@ from django.contrib.auth import ( from django.db.models import Q from django.utils.translation import ugettext as _, ugettext_lazy +from braces.forms import UserKwargModelFormMixin +import django_otp + from modoboa.core.models import User from modoboa.parameters import tools as param_tools @@ -147,3 +149,16 @@ class VerifySMSCodeForm(forms.Form): if not oath.accept_totp(self.totp_secret, code)[0]: raise forms.ValidationError(_("Invalid code")) return code + + +class Verify2FACodeForm(UserKwargModelFormMixin, forms.Form): + """A form to verify 2FA codes validity.""" + + tfa_code = forms.CharField() + + def clean_tfa_code(self): + code = self.cleaned_data["tfa_code"] + device = django_otp.match_token(self.user, code) + if device is None: + raise forms.ValidationError(_("This code is invalid")) + return device diff --git a/modoboa/core/middleware.py b/modoboa/core/middleware.py index 042bd1a7e..cfd3c43d2 100644 --- a/modoboa/core/middleware.py +++ b/modoboa/core/middleware.py @@ -1,5 +1,7 @@ """Core middlewares.""" +from django import http +from django.urls import reverse from django.utils.deprecation import MiddlewareMixin from . import models @@ -11,3 +13,28 @@ class LocalConfigMiddleware(MiddlewareMixin): def process_request(self, request): """Inject LocalConfig instance to request.""" request.localconfig = models.LocalConfig.objects.first() + + +class TwoFAMiddleware: + """Custom 2FA middleware to enforce verification is used has TFA enabled.""" + + def __init__(self, get_response=None): + self.get_response = get_response + + def __call__(self, request): + user = getattr(request, "user", None) + redirect_url = reverse("core:2fa_verify") + url_exceptions = ( + redirect_url, + "/jsi18n/" + ) + condition = ( + user and + not user.is_anonymous and + user.tfa_enabled and + request.path not in url_exceptions and + not user.is_verified() + ) + if condition: + return http.HttpResponseRedirect(reverse("core:2fa_verify")) + return self.get_response(request) diff --git a/modoboa/core/migrations/0022_user_tfa_enabled.py b/modoboa/core/migrations/0022_user_tfa_enabled.py new file mode 100644 index 000000000..c97496498 --- /dev/null +++ b/modoboa/core/migrations/0022_user_tfa_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.12 on 2020-10-16 09:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0021_localconfig_need_dovecot_update'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='tfa_enabled', + field=models.BooleanField(default=False), + ), + ] diff --git a/modoboa/core/models.py b/modoboa/core/models.py index 970d049cf..cdfc22c3d 100644 --- a/modoboa/core/models.py +++ b/modoboa/core/models.py @@ -71,6 +71,9 @@ class User(AbstractUser): help_text=ugettext_lazy( "An alternative e-mail address, can be used for recovery needs.") ) + + tfa_enabled = models.BooleanField(default=False) + _parameters = jsonfield.JSONField(default={}) class Meta(object): diff --git a/modoboa/core/serializers.py b/modoboa/core/serializers.py new file mode 100644 index 000000000..c4fbdba8d --- /dev/null +++ b/modoboa/core/serializers.py @@ -0,0 +1,22 @@ +"""Core API serializers.""" + +from django.utils.translation import ugettext as _ +from rest_framework import serializers + + +class CheckTFASetupSerializer(serializers.Serializer): + """Serializer used to finalize 2FA setup.""" + + pin_code = serializers.CharField() + + def validate_pin_code(self, value): + device = self.context["user"].totpdevice_set.first() + if not device.verify_token(value): + raise serializers.ValidationError(_("Invalid PIN code")) + return value + + def validate(self, data): + user = self.context["user"] + if user.tfa_enabled or user.staticdevice_set.exists(): + raise serializers.ValidationError(_("2FA is already enabled")) + return data diff --git a/modoboa/core/static/core/js/settings.js b/modoboa/core/static/core/js/settings.js index 0e926fbcc..7322ef28d 100644 --- a/modoboa/core/static/core/js/settings.js +++ b/modoboa/core/static/core/js/settings.js @@ -51,7 +51,7 @@ Settings.prototype = { $("#toggle_extensions_sel").prop("checked", true); } }, - + /** * Select or unselect all extensions in on click. */ diff --git a/modoboa/core/templates/core/user_security.html b/modoboa/core/templates/core/user_security.html new file mode 100644 index 000000000..5a31cc418 --- /dev/null +++ b/modoboa/core/templates/core/user_security.html @@ -0,0 +1,103 @@ +{% load i18n static %} +

{% trans "Security" %}

+
+
+
+

{% trans "Two-Factor authentication" %}

+
+ +
+ {% if qrcode %} +
+
+ {{ qrcode|safe }} +
+
+
+ {% blocktrans trimmed %} + Install a soft token authenticator like FreeOTP or Google + Authenticator from your application repository and use that + app to scan this QR code. + {% endblocktrans %} +
+
+
+ + +
+ +
+
+ {% elif tokens %} +
+ {% blocktrans trimmed %} + Congratulations! Two-Factor Authentication is now enabled for your account. + {% endblocktrans %} +
+

+ {% blocktrans trimmed %} + The following recovery codes can be used one time each to + let you regain access to your account, in case you lose your + phone for example. Make sure to save them in a safe place, + otherwise you won't be able to access your account anymore. + {% endblocktrans %} +

+
+
    + {% for token in tokens %} +
  • {{ token }}
  • + {% endfor %} +
+ {% elif user.tfa_enabled %} +
+ {% blocktrans trimmed %} + Two-Factor Authentication is enabled for your account. + {% endblocktrans %} +
+ + {% trans "Disable 2FA" %} + + + {% trans "Reset recovery codes" %} + + {% else %} +
+ {% blocktrans trimmed %} + Two-Factor Authentication (2FA) is not yet activated for + your account. Enabling this feature will increase your + account's security. + {% endblocktrans %} +
+
+ +
+ {% endif %} +
+
+ diff --git a/modoboa/core/templatetags/core_tags.py b/modoboa/core/templatetags/core_tags.py index bc829031e..04bc8ae79 100644 --- a/modoboa/core/templatetags/core_tags.py +++ b/modoboa/core/templatetags/core_tags.py @@ -118,6 +118,10 @@ def uprefs_menu(selection, user): "class": "ajaxnav", "url": "preferences/", "label": _("Preferences")}, + {"name": "security", + "class": "ajaxnav", + "url": "security/", + "label": _("Security")}, ] if user.is_superuser: entries.append({ diff --git a/modoboa/core/tests/test_authentication.py b/modoboa/core/tests/test_authentication.py index 265ab4dd0..567edc01a 100644 --- a/modoboa/core/tests/test_authentication.py +++ b/modoboa/core/tests/test_authentication.py @@ -49,6 +49,28 @@ class AuthenticationTestCase(ModoTestCase): self.assertEqual(response.status_code, 302) self.assertTrue(response.url.endswith(reverse("core:dashboard"))) + @mock.patch("django_otp.match_token") + @mock.patch("django_otp.login") + 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.save() + data = {"username": "user@test.com", "password": "toto"} + response = self.client.post(reverse("core:login"), data) + + response = self.client.get(response.url) + self.assertEqual(response.status_code, 302) + url = reverse("core:2fa_verify") + self.assertTrue(response.url.endswith(url)) + + match_mock.side_effect = [None, user.totpdevice_set.first()] + data = {"tfa_code": "1234"} + response = self.client.post(url, data) + self.assertContains(response, "This code is invalid") + response = self.client.post(url, data) + self.assertEqual(response.status_code, 302) + def test_open_redirect(self): """Check that open redirect is not allowed.""" self.client.logout() diff --git a/modoboa/core/tests/test_viewsets.py b/modoboa/core/tests/test_viewsets.py new file mode 100644 index 000000000..e3ad2d38a --- /dev/null +++ b/modoboa/core/tests/test_viewsets.py @@ -0,0 +1,74 @@ +"""API viewsets test cases.""" + +from unittest import mock + +from django.urls import reverse + +from modoboa.admin import factories as admin_factories +from modoboa.lib.tests import ModoAPITestCase + +from .. import models + + +class AccountViewSetTestCase(ModoAPITestCase): + """Account viewset tests.""" + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + admin_factories.populate_database() + + @mock.patch("django_otp.plugins.otp_totp.models.TOTPDevice.verify_token") + def test_tfa_setup_process(self, verify_mock): + admin = models.User.objects.get(username="admin") + self.client.force_login(admin) + + # First, check if we're in the initial state + security_url = reverse("core:user_security") + response = self.client.get(security_url) + self.assertIn("not yet activated for", response.json()["content"]) + + # Start setup sequence + url = reverse("api:account-tfa-setup") + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + + # Check if security page is up-to-date + security_url = reverse("core:user_security") + response = self.client.get(security_url) + self.assertIn("FreeOTP", response.json()["content"]) + + # Continue setup sequence + verify_mock.side_effect = [True] + url = reverse("api:account-tfa-setup-check") + data = {"pin_code": "1234"} + response = self.client.post(url, data, format="json") + self.assertEqual(response.status_code, 200) + + # Display recovery tokens + security_url = reverse("core:user_security") + response = self.client.get(security_url) + self.assertIn("recovery codes", response.json()["content"]) + admin.refresh_from_db() + self.assertTrue(admin.tfa_enabled) + device = admin.staticdevice_set.first() + self.assertIsNot(device, None) + self.assertEqual(device.token_set.count(), 10) + + # Try to disable TFA auth + url = reverse("api:account-tfa-disable") + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + self.assertFalse(admin.totpdevice_set.exists()) + + def test_tfa_reset_codes(self): + admin = models.User.objects.get(username="admin") + self.client.force_login(admin) + url = reverse("api:account-tfa-reset-codes") + response = self.client.post(url) + self.assertEqual(response.status_code, 403) + + device = admin.staticdevice_set.create(name="Device") + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(device.token_set.count(), 10) diff --git a/modoboa/core/urls.py b/modoboa/core/urls.py index 38e2cd865..51b142f8c 100644 --- a/modoboa/core/urls.py +++ b/modoboa/core/urls.py @@ -12,6 +12,9 @@ urlpatterns = [ path('accounts/login/', views.dologin, name="login"), path('accounts/logout/', views.dologout, name="logout"), + path('accounts/2fa_verify/', + views.TwoFactorCodeVerifyView.as_view(), + name='2fa_verify'), path('core/', views.viewsettings, name="index"), path('core/parameters/', views.parameters, name="parameters"), @@ -27,4 +30,5 @@ urlpatterns = [ name="user_preferences"), path('user/profile/', views.profile, name="user_profile"), path('user/api/', views.api_access, name="user_api_access"), + path('user/security/', views.security, name="user_security"), ] diff --git a/modoboa/core/urls_api.py b/modoboa/core/urls_api.py new file mode 100644 index 000000000..7d6f3b803 --- /dev/null +++ b/modoboa/core/urls_api.py @@ -0,0 +1,11 @@ +"""Core API urls.""" + +from rest_framework import routers + +from . import viewsets + + +router = routers.SimpleRouter() +router.register(r"account", viewsets.AccountViewSet, basename="account") + +urlpatterns = router.urls diff --git a/modoboa/core/views/__init__.py b/modoboa/core/views/__init__.py index 3a4def64a..1036e8308 100644 --- a/modoboa/core/views/__init__.py +++ b/modoboa/core/views/__init__.py @@ -6,11 +6,12 @@ from .admin import ( ) from .auth import ( PasswordResetView, dologin, dologout, VerifySMSCodeView, - ResendSMSCodeView + ResendSMSCodeView, TwoFactorCodeVerifyView ) from .base import RootDispatchView from .dashboard import DashboardView -from .user import api_access, index, preferences, profile +from .user import ( + api_access, index, preferences, profile, security) __all__ = [ "DashboardView", @@ -22,6 +23,7 @@ __all__ = [ "check_top_notifications", "dologin", "dologout", + "TwoFactorCodeVerifyView", "index", "information", "logs", @@ -30,4 +32,5 @@ __all__ = [ "preferences", "profile", "viewsettings", + "security" ] diff --git a/modoboa/core/views/auth.py b/modoboa/core/views/auth.py index 1045b7af5..f23b41927 100644 --- a/modoboa/core/views/auth.py +++ b/modoboa/core/views/auth.py @@ -19,7 +19,9 @@ from django.contrib.auth import ( ) from django.contrib.auth.tokens import default_token_generator -from braces.views import JSONResponseMixin +from braces.views import ( + JSONResponseMixin, LoginRequiredMixin, UserFormKwargsMixin) +import django_otp from modoboa.core import forms from modoboa.core.password_hashers import get_password_hasher @@ -229,3 +231,19 @@ class ResendSMSCodeView(JSONResponseMixin, generic.View): raise Http404 self.request.session["totp_secret"] = secret return self.render_json_response({"status": "ok"}) + + +class TwoFactorCodeVerifyView(LoginRequiredMixin, + UserFormKwargsMixin, + generic.FormView): + """View to verify a 2FA code after login.""" + + form_class = forms.Verify2FACodeForm + template_name = "registration/twofactor_code_verify.html" + + def form_valid(self, form): + """Login user.""" + django_otp.login(self.request, form.cleaned_data["tfa_code"]) + return HttpResponseRedirect( + find_nextlocation(self.request, self.request.user) + ) diff --git a/modoboa/core/views/user.py b/modoboa/core/views/user.py index 8115a0d4e..b58beb449 100644 --- a/modoboa/core/views/user.py +++ b/modoboa/core/views/user.py @@ -1,12 +1,19 @@ """Simple user views.""" -from django.contrib.auth.decorators import login_required, user_passes_test +import io + +import qrcode +import qrcode.image.svg + from django.shortcuts import render from django.template.loader import render_to_string from django.utils import translation from django.utils.encoding import force_text from django.utils.translation import ugettext as _ +from django.contrib.auth.decorators import login_required, user_passes_test + +import django_otp from rest_framework.authtoken.models import Token from modoboa.lib.cryptutils import encrypt @@ -109,3 +116,31 @@ def api_access(request): "content": render_to_string( "core/api_access.html", {"form": form}, request) }) + + +@login_required +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: + 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.save() + else: + device = request.user.totpdevice_set.first() + if device: + factory = qrcode.image.svg.SvgPathImage + img = qrcode.make(device.config_url, image_factory=factory) + buf = io.BytesIO() + img.save(buf) + context.update({"qrcode": buf.getvalue().decode()}) + resp = { + "content": render_to_string( + "core/user_security.html", context, request), + "callback": "security" + } + return render_to_json_response(resp) diff --git a/modoboa/core/viewsets.py b/modoboa/core/viewsets.py new file mode 100644 index 000000000..177160688 --- /dev/null +++ b/modoboa/core/viewsets.py @@ -0,0 +1,72 @@ +"""Core API viewsets.""" + +from django.utils.translation import ugettext as _ + +import django_otp +from django_otp.plugins.otp_static.models import StaticDevice, StaticToken +from django_otp.plugins.otp_totp.models import TOTPDevice +from rest_framework import permissions, response, viewsets +from rest_framework.decorators import action + +from . import serializers + + +class AccountViewSet(viewsets.ViewSet): + """Account viewset. + + Contains endpoints used to manipulate current user's account. + """ + + permission_classes = (permissions.IsAuthenticated, ) + + @action(methods=["post"], detail=False, url_path="tfa/setup") + def tfa_setup(self, request): + """Initiate TFA setup.""" + instance, created = TOTPDevice.objects.get_or_create( + user=request.user, + defaults={"name": "{} TOTP device".format(request.user)} + ) + return response.Response() + + @action(methods=["post"], detail=False, url_path="tfa/setup/check") + def tfa_setup_check(self, request): + """Check TFA setup.""" + serializer = serializers.CheckTFASetupSerializer( + data=request.data, context={"user": request.user}) + serializer.is_valid(raise_exception=True) + # create static device for recovery purposes + 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) + django_otp.login(self.request, request.user.totpdevice_set.first()) + return response.Response() + + @action(methods=["post"], detail=False, url_path="tfa/disable") + def tfa_disable(self, request): + """Disable TFA.""" + if not request.user.tfa_enabled: + return response.Response({"error": _("2FA is not enabled")}, + status=400) + request.user.totpdevice_set.all().delete() + request.user.staticdevice_set.all().delete() + request.user.tfa_enabled = False + request.user.save() + return response.Response() + + @action(methods=["post"], detail=False, url_path="tfa/reset_codes") + def tfa_reset_codes(self, request, *args, **kwargs): + """Reset recovery codes.""" + device = request.user.staticdevice_set.first() + if device is None: + return response.Response(status=403) + device.token_set.all().delete() + 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) + }) diff --git a/modoboa/static/js/global.js b/modoboa/static/js/global.js index 085b4c6b8..e2062efd2 100644 --- a/modoboa/static/js/global.js +++ b/modoboa/static/js/global.js @@ -126,6 +126,25 @@ function clean_form_errors(formid) { $("#" + formid + " span.help-block").remove(); } +function display_field_error($field, error) { + var spanid = $field.attr('id') + "-error"; + var $span = $("#" + spanid); + + if (!$field.parents(".form-group").hasClass("has-error")) { + $field.parents(".form-group").addClass("has-error"); + } + if (!$span.length) { + $span = $("", { + class: "help-block", + html: error, + id: spanid + }); + $field.parents(".form-group").append($span); + } else { + $span.html(error); + } +} + /* * Display validation errors for a given form. */ @@ -134,23 +153,9 @@ function display_form_errors(formid, data) { $.each(data.form_errors, function(id, value) { var fullid = "id_" + (data.prefix ? data.prefix + "-" : "") + id; var $widget = $("#" + formid + " #" + fullid); - var spanid = fullid + "-error"; - var $span = $("#" + spanid); var error = value.join(' '); - if (!$widget.parents(".form-group").hasClass("has-error")) { - $widget.parents(".form-group").addClass("has-error"); - } - if (!$span.length) { - $span = $("", { - "class": "help-block", - "html": error, - "id": spanid - }); - $widget.parents(".form-group").append($span); - } else { - $span.html(error); - } + display_field_error($widget, error); }); } diff --git a/modoboa/static/js/twocols_nav.js b/modoboa/static/js/twocols_nav.js index 0903276b0..45d8bae1c 100644 --- a/modoboa/static/js/twocols_nav.js +++ b/modoboa/static/js/twocols_nav.js @@ -23,6 +23,8 @@ TwocolsNav.prototype = { this.options.defcallback = $.proxy(this.default_cb, this); Listing.prototype.initialize.call(this, this.options); this.listen(); + this.navobj.register_callback( + "security", $.proxy(this.security_callback, this)); }, listen: function() { @@ -154,6 +156,70 @@ TwocolsNav.prototype = { default_cb: function(data) { this.select_left_menu(); this.update_content(data); + }, + + /** + * Navigation callback: security. + */ + security_callback: function (data) { + this.select_left_menu(); + this.update_content(data); + $('#start-tfa-setup').click(this.startTFASetup); + $('#finalize-tfa-setup').click(this.finalizeTFASetup); + $('#deactivate-tfa').confirm({ + question: gettext('Are you sure?'), + warning: gettext('This will invalidate your registered application.'), + method: 'POST' + }); + $('#tfa-refresh-codes').click(this.resetTFARecoveryCodes); + }, + + startTFASetup: function (evt) { + evt.preventDefault(); + $.ajax({ + url: '/api/v1/account/tfa/setup/', + type: 'post', + contentType: 'application/json' + }).done(function (resp) { + window.location.reload(); + }); + }, + + finalizeTFASetup: function (evt) { + evt.preventDefault(); + var data = { + pin_code: $('#pin-code').val() + }; + $.ajax({ + url: '/api/v1/account/tfa/setup/check/', + type: 'post', + global: false, + data: JSON.stringify(data), + contentType: 'application/json' + }).done(function (resp) { + window.location.reload(); + }).fail(function (jqxhr) { + var errors = JSON.parse(jqxhr.responseText); + display_field_error($('#pin-code'), errors.pin_code.join(' ')); + }); + }, + + resetTFARecoveryCodes: function (evt) { + evt.preventDefault(); + $.ajax({ + url: $(this).attr('href'), + type: 'post', + contentType: 'application/json' + }).done(function (resp) { + var $container = $('#tfa-codes-area'); + var $ul = $('
    '); + $.each(resp.tokens, function (index, value) { + var $li = $('
  • ', { html: value }); + $ul.append($li); + }); + $container.append($ul); + $('#codes-modal').modal(); + }); } }; diff --git a/modoboa/templates/registration/twofactor_code_verify.html b/modoboa/templates/registration/twofactor_code_verify.html new file mode 100644 index 000000000..0a39eb4f4 --- /dev/null +++ b/modoboa/templates/registration/twofactor_code_verify.html @@ -0,0 +1,30 @@ +{% extends "registration/base.html" %} + +{% load i18n core_tags lib_tags form_tags static %} + +{% block extra_css %} + +{% endblock %} + +{% block main_content %} +
    +
    + +
    +
    +{% endblock %} diff --git a/modoboa/urls_api.py b/modoboa/urls_api.py index 3e65ccc00..8e1f2e6c9 100644 --- a/modoboa/urls_api.py +++ b/modoboa/urls_api.py @@ -7,6 +7,7 @@ from modoboa.core.extensions import exts_pool app_name = "api" urlpatterns = [ + path('', include("modoboa.core.urls_api")), path('', include("modoboa.admin.urls_api")), path('', include("modoboa.limits.urls_api")), path('', include("modoboa.relaydomains.urls_api")), diff --git a/requirements.txt b/requirements.txt index 8f0be3bb1..baf72252c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ django-ckeditor==5.8.0 django-phonenumber-field[phonenumbers]==4.0.0 django-reversion==3.0.5 django-xforwardedfor-middleware==2.0 +django-otp==1.0.1 dj-database-url @@ -30,6 +31,7 @@ ovh oath redis rrdtool>=0.1.11 +qrcode aiosmtplib aioredis diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index f13a7f4af..65b9bda87 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -63,6 +63,9 @@ INSTALLED_APPS = ( 'ckeditor_uploader', 'rest_framework', 'rest_framework.authtoken', + 'django_otp', + 'django_otp.plugins.otp_totp', + 'django_otp.plugins.otp_static', ) # A dedicated place to register Modoboa applications @@ -100,6 +103,8 @@ MIDDLEWARE = ( 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django_otp.middleware.OTPMiddleware', + 'modoboa.core.middleware.TwoFAMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.locale.LocaleMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',