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.
This commit is contained in:
Antoine Nguyen
2020-10-21 10:42:56 +02:00
committed by GitHub
parent 83809c2536
commit a40d46db94
24 changed files with 635 additions and 21 deletions
+65
View File
@@ -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 = "<your server hostname here>"
1.16.0
======
@@ -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/"
+16 -1
View File
@@ -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
+27
View File
@@ -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)
@@ -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),
),
]
+3
View File
@@ -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):
+22
View File
@@ -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
+1 -1
View File
@@ -51,7 +51,7 @@ Settings.prototype = {
$("#toggle_extensions_sel").prop("checked", true);
}
},
/**
* Select or unselect all extensions in on click.
*/
@@ -0,0 +1,103 @@
{% load i18n static %}
<h2>{% trans "Security" %}</h2>
<hr>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Two-Factor authentication" %}</h3>
</div>
<div class="panel-body">
{% if qrcode %}
<div class="row">
<div class="col-sm-4">
{{ qrcode|safe }}
</div>
<div class="col-sm-6">
<div class="alert alert-info">
{% 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 %}
</div>
<form>
<div class="form-group">
<label class="control-label">{% trans "Pin code" %}</label>
<input id="pin-code" class="form-control">
</div>
<button id="finalize-tfa-setup" class="btn btn-primary col-sm-12">{% trans "Register" %}</button>
</form>
</div>
{% elif tokens %}
<div class="alert alert-success">
{% blocktrans trimmed %}
Congratulations! Two-Factor Authentication is now enabled for your account.
{% endblocktrans %}
</div>
<p>
{% 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 %}
</p>
<br>
<ul>
{% for token in tokens %}
<li>{{ token }}</li>
{% endfor %}
</ul>
{% elif user.tfa_enabled %}
<div class="alert alert-info">
{% blocktrans trimmed %}
Two-Factor Authentication is enabled for your account.
{% endblocktrans %}
</div>
<a id="deactivate-tfa" class="btn btn-danger" href="{% url 'api:account-tfa-disable' %}">
{% trans "Disable 2FA" %}
</a>
<a id="tfa-refresh-codes" class="btn btn-default" href="{% url 'api:account-tfa-reset-codes' %}">
{% trans "Reset recovery codes" %}
</a>
{% else %}
<div class="alert alert-info text-center">
{% blocktrans trimmed %}
Two-Factor Authentication (2FA) is not yet activated for
your account. Enabling this feature will increase your
account's security.
{% endblocktrans %}
</div>
<div class="text-center">
<button id="start-tfa-setup" class="btn btn-success">
{% trans "Enable 2FA" %}
</button>
</div>
{% endif %}
</div>
</div>
<div id="codes-modal" class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal"><span aria-hidden="true">&times;</span><span class="sr-only">{% trans "Close" %}</span></button>
<h3 class="modal-title">{% trans "New recovery codes" %}</h3>
</div>
<div class="modal-body">
<div class="alert alert-info" style="overflow-wrap: break-word;">
<strong>{% trans "Here are your new recovery codes. " %}</strong><br>
{% 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 %}
</div>
<div id="tfa-codes-area"></div>
</div>
<div class="modal-footer">
<a class="btn btn-default" data-dismiss="modal">{% trans "Close" %}</a>
</div>
</div>
</div>
</div>
+4
View File
@@ -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({
+22
View File
@@ -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()
+74
View File
@@ -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)
+4
View File
@@ -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"),
]
+11
View File
@@ -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
+5 -2
View File
@@ -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"
]
+19 -1
View File
@@ -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)
)
+36 -1
View File
@@ -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)
+72
View File
@@ -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)
})
+20 -15
View File
@@ -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 = $("<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 = $("<span />", {
"class": "help-block",
"html": error,
"id": spanid
});
$widget.parents(".form-group").append($span);
} else {
$span.html(error);
}
display_field_error($widget, error);
});
}
+66
View File
@@ -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 = $('<ul>');
$.each(resp.tokens, function (index, value) {
var $li = $('<li />', { html: value });
$ul.append($li);
});
$container.append($ul);
$('#codes-modal').modal();
});
}
};
@@ -0,0 +1,30 @@
{% extends "registration/base.html" %}
{% load i18n core_tags lib_tags form_tags static %}
{% block extra_css %}
<link rel="stylesheet" type="text/css" href="{% static 'css/login.css' %}" />
{% endblock %}
{% block main_content %}
<div class="row">
<div class="col-sm-offset-4 col-sm-6">
<form method="post" action="{% url 'core:2fa_verify' %}" class="login-form">
{% csrf_token %}
<div class="form-group{% if form.tfa_code.errors %} has-error{% endif %}">
<label class="control-label">Two-Factor authentication code</label>
<input id="id_tfa_code" name="tfa_code" class="form-control">
<span class="help-block">
{{ form.tfa_code.errors }}
</span>
<p class="help-block">
Enter the code from the two-factor app on your mobile
device. If you've lost your device, you may enter one of your
recovery codes.
</p>
</div>
<button class="btn btn-primary col-sm-12" type="submit">Verify code</button>
</form>
</div>
</div>
{% endblock %}
+1
View File
@@ -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")),
+2
View File
@@ -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
+5
View File
@@ -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',