mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-11-11 08:43:55 +01:00
Merge branch 'master' into refacto/vuejs_interface
This commit is contained in:
@@ -91,6 +91,71 @@ Rebuild Virtual Environment
|
|||||||
Specific instructions
|
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
|
1.16.0
|
||||||
======
|
======
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ INSTALLED_APPS = (
|
|||||||
'rest_framework',
|
'rest_framework',
|
||||||
'rest_framework.authtoken',
|
'rest_framework.authtoken',
|
||||||
'phonenumber_field',
|
'phonenumber_field',
|
||||||
|
'django_otp',
|
||||||
|
'django_otp.plugins.otp_totp',
|
||||||
|
'django_otp.plugins.otp_static',
|
||||||
{% if devmode %} 'djangobower',{% endif %}
|
{% if devmode %} 'djangobower',{% endif %}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -98,6 +101,8 @@ MIDDLEWARE = (
|
|||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django_otp.middleware.OTPMiddleware',
|
||||||
|
'modoboa.core.middleware.TwoFAMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.locale.LocaleMiddleware',
|
'django.middleware.locale.LocaleMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
@@ -230,6 +235,10 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# 2FA
|
||||||
|
|
||||||
|
OTP_TOTP_ISSUER = "{{ allowed_host }}"
|
||||||
|
|
||||||
# CKeditor
|
# CKeditor
|
||||||
|
|
||||||
CKEDITOR_UPLOAD_PATH = "uploads/"
|
CKEDITOR_UPLOAD_PATH = "uploads/"
|
||||||
|
|||||||
+16
-1
@@ -1,6 +1,5 @@
|
|||||||
"""Core forms."""
|
"""Core forms."""
|
||||||
|
|
||||||
# import pyotp
|
|
||||||
import oath
|
import oath
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
@@ -10,6 +9,9 @@ from django.contrib.auth import (
|
|||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import ugettext as _, ugettext_lazy
|
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.core.models import User
|
||||||
from modoboa.parameters import tools as param_tools
|
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]:
|
if not oath.accept_totp(self.totp_secret, code)[0]:
|
||||||
raise forms.ValidationError(_("Invalid code"))
|
raise forms.ValidationError(_("Invalid code"))
|
||||||
return 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
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Core middlewares."""
|
"""Core middlewares."""
|
||||||
|
|
||||||
|
from django import http
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils.deprecation import MiddlewareMixin
|
from django.utils.deprecation import MiddlewareMixin
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
@@ -11,3 +13,28 @@ class LocalConfigMiddleware(MiddlewareMixin):
|
|||||||
def process_request(self, request):
|
def process_request(self, request):
|
||||||
"""Inject LocalConfig instance to request."""
|
"""Inject LocalConfig instance to request."""
|
||||||
request.localconfig = models.LocalConfig.objects.first()
|
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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -71,6 +71,9 @@ class User(AbstractUser):
|
|||||||
help_text=ugettext_lazy(
|
help_text=ugettext_lazy(
|
||||||
"An alternative e-mail address, can be used for recovery needs.")
|
"An alternative e-mail address, can be used for recovery needs.")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
tfa_enabled = models.BooleanField(default=False)
|
||||||
|
|
||||||
_parameters = jsonfield.JSONField(default={})
|
_parameters = jsonfield.JSONField(default={})
|
||||||
|
|
||||||
class Meta(object):
|
class Meta(object):
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Core serializers."""
|
"""Core API serializers."""
|
||||||
|
|
||||||
from django.utils import formats
|
from django.utils import formats
|
||||||
from django.utils.translation import ugettext_lazy, ugettext as _
|
from django.utils.translation import ugettext_lazy, ugettext as _
|
||||||
@@ -140,3 +140,21 @@ class LogSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def get_date_created(self, log):
|
def get_date_created(self, log):
|
||||||
return formats.date_format(log.date_created, "SHORT_DATETIME_FORMAT")
|
return formats.date_format(log.date_created, "SHORT_DATETIME_FORMAT")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -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">×</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>
|
||||||
@@ -118,6 +118,10 @@ def uprefs_menu(selection, user):
|
|||||||
"class": "ajaxnav",
|
"class": "ajaxnav",
|
||||||
"url": "preferences/",
|
"url": "preferences/",
|
||||||
"label": _("Preferences")},
|
"label": _("Preferences")},
|
||||||
|
{"name": "security",
|
||||||
|
"class": "ajaxnav",
|
||||||
|
"url": "security/",
|
||||||
|
"label": _("Security")},
|
||||||
]
|
]
|
||||||
if user.is_superuser:
|
if user.is_superuser:
|
||||||
entries.append({
|
entries.append({
|
||||||
|
|||||||
@@ -49,6 +49,28 @@ class AuthenticationTestCase(ModoTestCase):
|
|||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertTrue(response.url.endswith(reverse("core:dashboard")))
|
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):
|
def test_open_redirect(self):
|
||||||
"""Check that open redirect is not allowed."""
|
"""Check that open redirect is not allowed."""
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -12,6 +12,9 @@ urlpatterns = [
|
|||||||
|
|
||||||
path('accounts/login/', views.dologin, name="login"),
|
path('accounts/login/', views.dologin, name="login"),
|
||||||
path('accounts/logout/', views.dologout, name="logout"),
|
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/', views.viewsettings, name="index"),
|
||||||
path('core/parameters/', views.parameters, name="parameters"),
|
path('core/parameters/', views.parameters, name="parameters"),
|
||||||
@@ -27,4 +30,5 @@ urlpatterns = [
|
|||||||
name="user_preferences"),
|
name="user_preferences"),
|
||||||
path('user/profile/', views.profile, name="user_profile"),
|
path('user/profile/', views.profile, name="user_profile"),
|
||||||
path('user/api/', views.api_access, name="user_api_access"),
|
path('user/api/', views.api_access, name="user_api_access"),
|
||||||
|
path('user/security/', views.security, name="user_security"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from . import viewsets
|
|||||||
|
|
||||||
|
|
||||||
router = routers.SimpleRouter()
|
router = routers.SimpleRouter()
|
||||||
|
router.register(r"account", viewsets.AccountViewSet, basename="account")
|
||||||
router.register(r"logs", viewsets.LogViewSet)
|
router.register(r"logs", viewsets.LogViewSet)
|
||||||
|
|
||||||
urlpatterns = router.urls
|
urlpatterns = router.urls
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ from .admin import (
|
|||||||
)
|
)
|
||||||
from .auth import (
|
from .auth import (
|
||||||
PasswordResetView, dologin, dologout, VerifySMSCodeView,
|
PasswordResetView, dologin, dologout, VerifySMSCodeView,
|
||||||
ResendSMSCodeView
|
ResendSMSCodeView, TwoFactorCodeVerifyView
|
||||||
)
|
)
|
||||||
from .base import RootDispatchView
|
from .base import RootDispatchView
|
||||||
from .dashboard import DashboardView
|
from .dashboard import DashboardView
|
||||||
from .user import api_access, index, preferences, profile
|
from .user import (
|
||||||
|
api_access, index, preferences, profile, security)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DashboardView",
|
"DashboardView",
|
||||||
@@ -22,6 +23,7 @@ __all__ = [
|
|||||||
"check_top_notifications",
|
"check_top_notifications",
|
||||||
"dologin",
|
"dologin",
|
||||||
"dologout",
|
"dologout",
|
||||||
|
"TwoFactorCodeVerifyView",
|
||||||
"index",
|
"index",
|
||||||
"information",
|
"information",
|
||||||
"logs",
|
"logs",
|
||||||
@@ -30,4 +32,5 @@ __all__ = [
|
|||||||
"preferences",
|
"preferences",
|
||||||
"profile",
|
"profile",
|
||||||
"viewsettings",
|
"viewsettings",
|
||||||
|
"security"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ from django.contrib.auth import (
|
|||||||
)
|
)
|
||||||
from django.contrib.auth.tokens import default_token_generator
|
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 import forms
|
||||||
from modoboa.core.password_hashers import get_password_hasher
|
from modoboa.core.password_hashers import get_password_hasher
|
||||||
@@ -229,3 +231,19 @@ class ResendSMSCodeView(JSONResponseMixin, generic.View):
|
|||||||
raise Http404
|
raise Http404
|
||||||
self.request.session["totp_secret"] = secret
|
self.request.session["totp_secret"] = secret
|
||||||
return self.render_json_response({"status": "ok"})
|
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)
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
"""Simple user views."""
|
"""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.shortcuts import render
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
from django.utils.encoding import force_text
|
from django.utils.encoding import force_text
|
||||||
from django.utils.translation import ugettext as _
|
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 rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from modoboa.lib.cryptutils import encrypt
|
from modoboa.lib.cryptutils import encrypt
|
||||||
@@ -109,3 +116,31 @@ def api_access(request):
|
|||||||
"content": render_to_string(
|
"content": render_to_string(
|
||||||
"core/api_access.html", {"form": form}, request)
|
"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)
|
||||||
|
|||||||
@@ -1,11 +1,78 @@
|
|||||||
"""Core viewsets."""
|
"""Core API viewsets."""
|
||||||
|
|
||||||
from rest_framework import permissions, 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 models
|
from . import models
|
||||||
from . import serializers
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class LogViewSet(viewsets.ReadOnlyModelViewSet):
|
class LogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
"""Log viewset."""
|
"""Log viewset."""
|
||||||
|
|
||||||
|
|||||||
+20
-15
@@ -126,6 +126,25 @@ function clean_form_errors(formid) {
|
|||||||
$("#" + formid + " span.help-block").remove();
|
$("#" + 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.
|
* Display validation errors for a given form.
|
||||||
*/
|
*/
|
||||||
@@ -134,23 +153,9 @@ function display_form_errors(formid, data) {
|
|||||||
$.each(data.form_errors, function(id, value) {
|
$.each(data.form_errors, function(id, value) {
|
||||||
var fullid = "id_" + (data.prefix ? data.prefix + "-" : "") + id;
|
var fullid = "id_" + (data.prefix ? data.prefix + "-" : "") + id;
|
||||||
var $widget = $("#" + formid + " #" + fullid);
|
var $widget = $("#" + formid + " #" + fullid);
|
||||||
var spanid = fullid + "-error";
|
|
||||||
var $span = $("#" + spanid);
|
|
||||||
var error = value.join(' ');
|
var error = value.join(' ');
|
||||||
|
|
||||||
if (!$widget.parents(".form-group").hasClass("has-error")) {
|
display_field_error($widget, 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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ TwocolsNav.prototype = {
|
|||||||
this.options.defcallback = $.proxy(this.default_cb, this);
|
this.options.defcallback = $.proxy(this.default_cb, this);
|
||||||
Listing.prototype.initialize.call(this, this.options);
|
Listing.prototype.initialize.call(this, this.options);
|
||||||
this.listen();
|
this.listen();
|
||||||
|
this.navobj.register_callback(
|
||||||
|
"security", $.proxy(this.security_callback, this));
|
||||||
},
|
},
|
||||||
|
|
||||||
listen: function() {
|
listen: function() {
|
||||||
@@ -154,6 +156,70 @@ TwocolsNav.prototype = {
|
|||||||
default_cb: function(data) {
|
default_cb: function(data) {
|
||||||
this.select_left_menu();
|
this.select_left_menu();
|
||||||
this.update_content(data);
|
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 %}
|
||||||
@@ -5,6 +5,7 @@ django-phonenumber-field[phonenumbers]==4.0.0
|
|||||||
django-reversion==3.0.5
|
django-reversion==3.0.5
|
||||||
django-xforwardedfor-middleware==2.0
|
django-xforwardedfor-middleware==2.0
|
||||||
django-webpack-loader
|
django-webpack-loader
|
||||||
|
django-otp==1.0.1
|
||||||
|
|
||||||
dj-database-url
|
dj-database-url
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ ovh
|
|||||||
oath
|
oath
|
||||||
redis
|
redis
|
||||||
rrdtool>=0.1.11
|
rrdtool>=0.1.11
|
||||||
|
qrcode
|
||||||
|
|
||||||
aiosmtplib
|
aiosmtplib
|
||||||
aioredis
|
aioredis
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ INSTALLED_APPS = (
|
|||||||
'ckeditor_uploader',
|
'ckeditor_uploader',
|
||||||
'rest_framework',
|
'rest_framework',
|
||||||
'rest_framework.authtoken',
|
'rest_framework.authtoken',
|
||||||
|
'django_otp',
|
||||||
|
'django_otp.plugins.otp_totp',
|
||||||
|
'django_otp.plugins.otp_static',
|
||||||
)
|
)
|
||||||
|
|
||||||
# A dedicated place to register Modoboa applications
|
# A dedicated place to register Modoboa applications
|
||||||
@@ -102,6 +105,8 @@ MIDDLEWARE = (
|
|||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django_otp.middleware.OTPMiddleware',
|
||||||
|
'modoboa.core.middleware.TwoFAMiddleware',
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'django.middleware.locale.LocaleMiddleware',
|
'django.middleware.locale.LocaleMiddleware',
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
|||||||
Reference in New Issue
Block a user