mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-18 05:51:26 +02:00
Merge branch 'master' into refacto/vuejs_interface
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Core serializers."""
|
||||
"""Core API serializers."""
|
||||
|
||||
from django.utils import formats
|
||||
from django.utils.translation import ugettext_lazy, ugettext as _
|
||||
@@ -140,3 +140,21 @@ class LogSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_date_created(self, log):
|
||||
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
|
||||
|
||||
@@ -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">×</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",
|
||||
"url": "preferences/",
|
||||
"label": _("Preferences")},
|
||||
{"name": "security",
|
||||
"class": "ajaxnav",
|
||||
"url": "security/",
|
||||
"label": _("Security")},
|
||||
]
|
||||
if user.is_superuser:
|
||||
entries.append({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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/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"),
|
||||
]
|
||||
|
||||
@@ -6,6 +6,7 @@ from . import viewsets
|
||||
|
||||
|
||||
router = routers.SimpleRouter()
|
||||
router.register(r"account", viewsets.AccountViewSet, basename="account")
|
||||
router.register(r"logs", viewsets.LogViewSet)
|
||||
|
||||
urlpatterns = router.urls
|
||||
|
||||
@@ -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,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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 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):
|
||||
"""Log viewset."""
|
||||
|
||||
|
||||
+20
-15
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
@@ -5,6 +5,7 @@ django-phonenumber-field[phonenumbers]==4.0.0
|
||||
django-reversion==3.0.5
|
||||
django-xforwardedfor-middleware==2.0
|
||||
django-webpack-loader
|
||||
django-otp==1.0.1
|
||||
|
||||
dj-database-url
|
||||
|
||||
@@ -31,6 +32,7 @@ ovh
|
||||
oath
|
||||
redis
|
||||
rrdtool>=0.1.11
|
||||
qrcode
|
||||
|
||||
aiosmtplib
|
||||
aioredis
|
||||
|
||||
@@ -65,6 +65,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
|
||||
@@ -102,6 +105,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',
|
||||
|
||||
Reference in New Issue
Block a user