From 68b5fc1a3e9478870c25e991ec6654d7d39ff5f5 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Fri, 27 Nov 2015 14:48:07 +0100 Subject: [PATCH 1/3] First draft of a REST API. see #523 --- modoboa/admin/lib.py | 6 +- .../migrations/0003_auto_20151118_1215.py | 19 ++++++ modoboa/core/api.py | 29 ++++++++ .../core/commands/templates/settings.py.tpl | 12 ++++ modoboa/core/forms.py | 14 ++++ modoboa/core/models.py | 1 + modoboa/core/serializers.py | 41 ++++++++++++ modoboa/core/templates/core/api_access.html | 32 +++++++++ modoboa/core/templates/core/user_index.html | 2 +- modoboa/core/templates/core/user_profile.html | 9 ++- modoboa/core/templatetags/core_tags.py | 7 ++ modoboa/core/tests.py | 66 ++++++++++++++++++- modoboa/core/urls.py | 2 + modoboa/core/urls_api.py | 14 ++++ modoboa/core/views/user.py | 28 +++++++- modoboa/lib/parameters.py | 6 +- modoboa/lib/tests.py | 20 ++++++ .../migrations/0002_auto_20151114_1518.py | 22 +++++++ modoboa/static/js/global.js | 10 +-- modoboa/static/js/twocols_nav.js | 3 +- modoboa/urls.py | 6 ++ modoboa/urls_api.py | 10 +++ requirements.txt | 1 + 23 files changed, 342 insertions(+), 18 deletions(-) create mode 100644 modoboa/admin/migrations/0003_auto_20151118_1215.py create mode 100644 modoboa/core/api.py create mode 100644 modoboa/core/serializers.py create mode 100644 modoboa/core/templates/core/api_access.html create mode 100644 modoboa/core/urls_api.py create mode 100644 modoboa/limits/migrations/0002_auto_20151114_1518.py create mode 100644 modoboa/urls_api.py diff --git a/modoboa/admin/lib.py b/modoboa/admin/lib.py index f5da1820e..547d487ca 100644 --- a/modoboa/admin/lib.py +++ b/modoboa/admin/lib.py @@ -55,7 +55,7 @@ def get_identities(user, searchquery=None, idtfilter=None, grpfilter=None): q &= Q(is_superuser=True) else: q &= Q(groups__name=grpfilter) - accounts = User.objects.select_related().filter(q) + accounts = User.objects.filter(q).prefetch_related("groups") aliases = [] if idtfilter is None or not idtfilter \ @@ -63,13 +63,13 @@ def get_identities(user, searchquery=None, idtfilter=None, grpfilter=None): alct = ContentType.objects.get_for_model(Alias) ids = user.objectaccess_set.filter(content_type=alct) \ .values_list('object_id', flat=True) - q = Q(pk__in=ids) + q = Q(pk__in=ids, internal=False) if searchquery is not None: q &= ( Q(address__icontains=searchquery) | Q(domain__name__icontains=searchquery) ) - aliases = Alias.objects.select_related().filter(q) + aliases = Alias.objects.select_related("domain").filter(q) if idtfilter is not None and idtfilter: aliases = [al for al in aliases if al.type == idtfilter] return chain(accounts, aliases) diff --git a/modoboa/admin/migrations/0003_auto_20151118_1215.py b/modoboa/admin/migrations/0003_auto_20151118_1215.py new file mode 100644 index 000000000..3dbbfe390 --- /dev/null +++ b/modoboa/admin/migrations/0003_auto_20151118_1215.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('admin', '0002_migrate_from_modoboa_admin'), + ] + + operations = [ + migrations.AlterField( + model_name='aliasrecipient', + name='address', + field=models.EmailField(max_length=254), + ), + ] diff --git a/modoboa/core/api.py b/modoboa/core/api.py new file mode 100644 index 000000000..7f89f380a --- /dev/null +++ b/modoboa/core/api.py @@ -0,0 +1,29 @@ +"""Modoboa core viewsets.""" + +from django.http import Http404 + +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from . import models +from . import serializers + + +class UserPasswordChangeAPIView(APIView): + + """A view to update password for user instances.""" + + def put(self, request, pk, format=None): + """PUT method hander.""" + try: + user = models.User.objects.get(username=pk) + except models.User.DoesNotExist: + raise Http404 + serializer = serializers.UserPasswordSerializer( + user, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response() + return Response( + serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index 8a3e60f91..e53e4c738 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -48,6 +48,7 @@ INSTALLED_APPS = ( 'django.contrib.sites', 'django.contrib.staticfiles', 'reversion', + 'rest_framework.authtoken' {% if devmode %} 'djangobower',{% endif %} ) @@ -148,6 +149,17 @@ STATICFILES_DIRS = ( MEDIA_URL = '/media/' MEDIA_ROOT = os.path.join(BASE_DIR, 'media') +# Rest framework settings + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.TokenAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ) +} + # Modoboa settings #MODOBOA_CUSTOM_LOGO = os.path.join(MEDIA_URL, "custom_logo.png") diff --git a/modoboa/core/forms.py b/modoboa/core/forms.py index 719badc5a..816dc5878 100644 --- a/modoboa/core/forms.py +++ b/modoboa/core/forms.py @@ -90,3 +90,17 @@ class ProfileForm(forms.ModelForm): ) user.save() return user + + +class APIAccessForm(forms.Form): + + """Form to control API access.""" + + enable_api_access = forms.BooleanField( + label=_("Enable API access"), required=False) + + def __init__(self, *args, **kwargs): + """Initialize form.""" + user = kwargs.pop("user") + super(APIAccessForm, self).__init__(*args, **kwargs) + self.fields["enable_api_access"].initial = hasattr(user, "auth_token") diff --git a/modoboa/core/models.py b/modoboa/core/models.py index 2a787369e..08c103250 100644 --- a/modoboa/core/models.py +++ b/modoboa/core/models.py @@ -166,6 +166,7 @@ class User(PermissionsMixin): ) def check_password(self, raw_value): + """Compare raw_value to current password.""" match = self.password_expr.match(self.password) if match is None: return False diff --git a/modoboa/core/serializers.py b/modoboa/core/serializers.py new file mode 100644 index 000000000..e81354c30 --- /dev/null +++ b/modoboa/core/serializers.py @@ -0,0 +1,41 @@ +"""Modoboa core serializers.""" + +from django.core.exceptions import ValidationError + +from rest_framework import serializers +from passwords import validators + +from . import models + + +class UserPasswordSerializer(serializers.ModelSerializer): + + """A serializer used to change a user password.""" + + new_password = serializers.CharField() + + class Meta: + model = models.User + fields = ( + "password", "new_password", ) + + def validate_password(self, value): + """Check password.""" + if not self.instance.check_password(value): + raise serializers.ValidationError("Password not correct") + return value + + def validate_new_password(self, value): + """Check new password.""" + try: + validators.validate_length(value) + validators.complexity(value) + except ValidationError as exc: + raise serializers.ValidationError(exc.messages[0]) + return value + + def update(self, instance, validated_data): + """Set new password.""" + instance.set_password(validated_data["new_password"]) + instance.save() + return instance diff --git a/modoboa/core/templates/core/api_access.html b/modoboa/core/templates/core/api_access.html new file mode 100644 index 000000000..d0669a7b6 --- /dev/null +++ b/modoboa/core/templates/core/api_access.html @@ -0,0 +1,32 @@ +{% load i18n form_tags %} + +

{% trans "API access" %} {% trans "Control your access to Modoboa API" %}

+
+{% if user.auth_token %} +
+
+

{% trans "API access token" %}

+
+
+ {{ user.auth_token }} +
+
+
+{% endif %} +
+ {% csrf_token %} +
+
+
+ +
+
+
+ +
+
+
diff --git a/modoboa/core/templates/core/user_index.html b/modoboa/core/templates/core/user_index.html index 711603221..db4896e44 100644 --- a/modoboa/core/templates/core/user_index.html +++ b/modoboa/core/templates/core/user_index.html @@ -18,7 +18,7 @@ $(document).ready(function() { deflocation: "profile/", formid: "uprefs_form", divid: "uprefs_content", - reload_exceptions: ["profile"] + reload_exceptions: ["profile", "api"] }); }); diff --git a/modoboa/core/templates/core/user_profile.html b/modoboa/core/templates/core/user_profile.html index 15fb395d4..62617fba7 100644 --- a/modoboa/core/templates/core/user_profile.html +++ b/modoboa/core/templates/core/user_profile.html @@ -1,10 +1,13 @@ -{% load i18n form_tags %}{% load url from future %} +{% load i18n form_tags %} +{% load url from future %} +

{% trans "Profile" %} {% trans "Update your personal information" %}


{% csrf_token %} + id="uprefs_form"> + {% csrf_token %} {% render_form form %}
- +
diff --git a/modoboa/core/templatetags/core_tags.py b/modoboa/core/templatetags/core_tags.py index 805fa289c..eb1f8cd1b 100644 --- a/modoboa/core/templatetags/core_tags.py +++ b/modoboa/core/templatetags/core_tags.py @@ -106,6 +106,13 @@ def uprefs_menu(selection, user): "url": "preferences/", "label": _("Preferences")}, ] + if user.is_superuser: + entries.append({ + "name": "api", + "class": "ajaxnav", + "url": "api/", + "label": _("API"), + }) entries += events.raiseQueryEvent("UserMenuDisplay", "uprefs_menu", user) entries = sorted(entries, key=lambda e: e["label"]) return render_to_string('common/menu.html', { diff --git a/modoboa/core/tests.py b/modoboa/core/tests.py index 0c27dd593..cb9880917 100644 --- a/modoboa/core/tests.py +++ b/modoboa/core/tests.py @@ -2,8 +2,9 @@ from django.core.urlresolvers import reverse -from modoboa.lib.tests import ModoTestCase +from modoboa.lib.tests import ModoTestCase, ModoAPITestCase from . import factories +from . import models class AuthenticationTestCase(ModoTestCase): @@ -80,3 +81,66 @@ class ProfileTestCase(ModoTestCase): self.assertTrue( self.clt.login(username="user@test.com", password="Toto1234") ) + + +class APIAccessFormTestCase(ModoTestCase): + + """Check form access.""" + + @classmethod + def setUpTestData(cls): + """Create test data.""" + super(APIAccessFormTestCase, cls).setUpTestData() + cls.account = factories.UserFactory( + username="user@test.com", groups=('SimpleUsers',) + ) + + def test_form_access(self): + """Check access restrictions.""" + url = reverse("core:user_api_access") + self.ajax_get(url) + self.clt.logout() + self.clt.login(username="user@test.com", password="toto") + response = self.clt.get(url, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + self.assertEqual(response.status_code, 278) + + def test_form(self): + """Check that token is created/removed.""" + url = reverse("core:user_api_access") + self.ajax_post(url, {"enable_api_access": True}) + user = models.User.objects.get(username="admin") + self.assertTrue(hasattr(user, "auth_token")) + self.ajax_post(url, {"enable_api_access": False}) + user = models.User.objects.get(username="admin") + self.assertFalse(hasattr(user, "auth_token")) + + +class APITestCase(ModoAPITestCase): + + """Check API.""" + + @classmethod + def setUpTestData(cls): + """Create test data.""" + super(APITestCase, cls).setUpTestData() + cls.account = factories.UserFactory( + username="user@test.com", groups=('SimpleUsers', ) + ) + + def test_change_password(self): + """Check the change password service.""" + url = reverse( + "external_api:user_password_change", args=["user@test.com"]) + response = self.client.put( + url, {"password": "toto", "new_password": "pass"}, + format="json") + # must fail because password is too weak + self.assertEqual(response.status_code, 400) + + response = self.client.put( + url, {"password": "toto", "new_password": "Toto1234"}, + format="json") + self.assertEqual(response.status_code, 200) + self.assertTrue( + models.User.objects.get( + pk=self.account.pk).check_password("Toto1234")) diff --git a/modoboa/core/urls.py b/modoboa/core/urls.py index e4fba293d..61c56ceca 100644 --- a/modoboa/core/urls.py +++ b/modoboa/core/urls.py @@ -29,4 +29,6 @@ urlpatterns = patterns( name="user_preferences"), url(r'^user/profile/$', 'modoboa.core.views.user.profile', name="user_profile"), + url(r'^user/api/$', 'modoboa.core.views.user.api_access', + name="user_api_access"), ) diff --git a/modoboa/core/urls_api.py b/modoboa/core/urls_api.py new file mode 100644 index 000000000..7e967fef1 --- /dev/null +++ b/modoboa/core/urls_api.py @@ -0,0 +1,14 @@ +"""Core API urls.""" + +from django.conf.urls import patterns, url + +from . import api + + +urlpatterns = patterns( + "", + url("^users/(?P.+)/password/$", + api.UserPasswordChangeAPIView.as_view(), + name="user_password_change"), + +) diff --git a/modoboa/core/views/user.py b/modoboa/core/views/user.py index 91583856b..149dc91a3 100644 --- a/modoboa/core/views/user.py +++ b/modoboa/core/views/user.py @@ -4,14 +4,16 @@ from django.shortcuts import render from django.utils import translation from django.utils.translation import ugettext as _ -from django.contrib.auth.decorators import login_required +from django.contrib.auth.decorators import login_required, user_passes_test + +from rest_framework.authtoken.models import Token from modoboa.lib import events, parameters from modoboa.lib.cryptutils import encrypt from modoboa.lib.web_utils import ( _render_to_string, render_to_json_response ) -from ..forms import ProfileForm +from ..forms import ProfileForm, APIAccessForm @login_required @@ -73,3 +75,25 @@ def preferences(request): "forms": parameters.get_user_forms(request.user) }) }) + + +@login_required +@user_passes_test(lambda u: u.is_superuser) +def api_access(request): + """A view to configure API access.""" + if request.method == "POST": + form = APIAccessForm(request.POST, user=request.user) + if form.is_valid(): + if form.cleaned_data.get("enable_api_access"): + Token.objects.get_or_create(user=request.user) + else: + Token.objects.filter(user=request.user).delete() + return render_to_json_response(_("Access updated")) + return render_to_json_response({ + "form_errors": form.errors + }, status=400) + form = APIAccessForm(user=request.user) + return render_to_json_response({ + "content": _render_to_string( + request, "core/api_access.html", {"form": form}) + }) diff --git a/modoboa/lib/parameters.py b/modoboa/lib/parameters.py index 1ea5eadc2..2cb780b3b 100644 --- a/modoboa/lib/parameters.py +++ b/modoboa/lib/parameters.py @@ -207,9 +207,9 @@ def unregister(app=None): def __is_defined(app, level, name): not_defined = ( - level not in ['A', 'U'] - or app not in _params[level] - or name not in _params[level][app]["defaults"] + level not in ['A', 'U'] or + app not in _params[level] or + name not in _params[level][app]["defaults"] ) if not_defined: raise NotDefined(app, name) diff --git a/modoboa/lib/tests.py b/modoboa/lib/tests.py index 08b2d486e..a83622bcc 100644 --- a/modoboa/lib/tests.py +++ b/modoboa/lib/tests.py @@ -8,6 +8,9 @@ from django.core import management from django.test import TestCase from django.test.client import Client +from rest_framework.authtoken.models import Token +from rest_framework.test import APITestCase + from modoboa.lib import parameters from modoboa.core import models as core_models @@ -47,6 +50,23 @@ class ModoTestCase(TestCase): return self.ajax_request('get', *args, **kwargs) +class ModoAPITestCase(APITestCase): + + """All test cases must inherit from this one.""" + + @classmethod + def setUpTestData(cls): + """Create a default user.""" + management.call_command("load_initial_data") + cls.token = Token.objects.create( + user=core_models.User.objects.get(username="admin")) + + def setUp(self): + """Setup.""" + super(ModoAPITestCase, self).setUp() + self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key) + + class TestParams(parameters.AdminParametersForm): app = "test" diff --git a/modoboa/limits/migrations/0002_auto_20151114_1518.py b/modoboa/limits/migrations/0002_auto_20151114_1518.py new file mode 100644 index 000000000..a6ed7714f --- /dev/null +++ b/modoboa/limits/migrations/0002_auto_20151114_1518.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('limits', '0001_initial'), + ] + + operations = [ + migrations.AlterModelTable( + name='limit', + table=None, + ), + migrations.AlterModelTable( + name='limitspool', + table=None, + ), + ] diff --git a/modoboa/static/js/global.js b/modoboa/static/js/global.js index 88eeea8df..01ac1f8c0 100644 --- a/modoboa/static/js/global.js +++ b/modoboa/static/js/global.js @@ -330,11 +330,13 @@ function activate_widget(e) { /* * Default error handler for AJAX requests. */ -function default_ajax_error_handler(event, jqxhr, settings) { +function defaultAjaxErrorHandler(event, jqxhr, settings) { + var data; + try { - var data = $.parseJSON(jqxhr.responseText); + data = $.parseJSON(jqxhr.responseText); } catch (x) { - var data = gettext("Internal error"); + data = gettext("Internal error"); } $('body').notify('error', data); } @@ -353,7 +355,7 @@ String.prototype.format = function() { $(document).ready(function() { $(document).ajaxSuccess(function(e, xhr, settings) { ajax_login_redirect(xhr); }); - $(document).ajaxError(default_ajax_error_handler); + $(document).ajaxError(defaultAjaxErrorHandler); $(document).on('click', 'a[data-toggle="ajaxmodal"]', modalbox); $(document).on('click', 'a[data-toggle="ajaxmodal-autowidth"]', modalbox_autowidth); $(document).on('click', '.activator', activate_widget); diff --git a/modoboa/static/js/twocols_nav.js b/modoboa/static/js/twocols_nav.js index 9094d549c..404cf6f5c 100644 --- a/modoboa/static/js/twocols_nav.js +++ b/modoboa/static/js/twocols_nav.js @@ -25,8 +25,9 @@ TwocolsNav.prototype = { listen: function() { $("a.ajaxnav").click($.proxy(this.load_section, this)); $(document).on("click", "#update", $.proxy(function(e) { + var $form = $("form").first(); simple_ajax_form_post(e, { - formid: this.options.formid, + formid: $form.attr("id"), modal: false, reload_on_success: false, success_cb: $.proxy(this.save_cb, this) diff --git a/modoboa/urls.py b/modoboa/urls.py index eacfe23f8..86fe5b038 100644 --- a/modoboa/urls.py +++ b/modoboa/urls.py @@ -36,6 +36,12 @@ urlpatterns += patterns( parameters.apply_to_django_settings() +# API urls +urlpatterns += patterns( + "", + ("^api/v1/", include("modoboa.urls_api", namespace="external_api")), +) + if 'modoboa.demo' in settings.INSTALLED_APPS: urlpatterns += patterns( '', diff --git a/modoboa/urls_api.py b/modoboa/urls_api.py new file mode 100644 index 000000000..5baa6af29 --- /dev/null +++ b/modoboa/urls_api.py @@ -0,0 +1,10 @@ +"""External API urls.""" + +from django.conf.urls import patterns, include + + +urlpatterns = patterns( + "", + + ("", include("modoboa.core.urls_api")), +) diff --git a/requirements.txt b/requirements.txt index f37d2ca7a..536663d61 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ django-xforwardedfor-middleware django-passwords==0.3.4 rfc6266 progressbar==2.3 +djangorestframework==3.3.1 From c4ec4f447c275bd7826d12a02a5e2b8aec56408d Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Fri, 27 Nov 2015 14:53:53 +0100 Subject: [PATCH 2/3] Update test config. --- test_project/test_project/settings.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 5971f257a..9e938d703 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -45,7 +45,7 @@ INSTALLED_APPS = ( 'django.contrib.sites', 'django.contrib.staticfiles', 'reversion', - + 'rest_framework.authtoken' ) # A dedicated place to register Modoboa applications @@ -135,6 +135,17 @@ STATICFILES_DIRS = ( MEDIA_URL = '/media/' MEDIA_ROOT = os.path.join(BASE_DIR, 'media') +# Rest framework settings + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.TokenAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ) +} + # Modoboa settings #MODOBOA_CUSTOM_LOGO = os.path.join(MEDIA_URL, "custom_logo.png") From 0411ac2d54067f3bee09182552d4521a1871e040 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Fri, 27 Nov 2015 15:02:43 +0100 Subject: [PATCH 3/3] Fix unit tests. see #523 --- modoboa/admin/tests/export.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modoboa/admin/tests/export.py b/modoboa/admin/tests/export.py index 65801db61..715ce1b23 100644 --- a/modoboa/admin/tests/export.py +++ b/modoboa/admin/tests/export.py @@ -37,7 +37,7 @@ class ExportTestCase(ModoTestCase): def test_export_identities(self): response = self.__export_identities() self.assertListEqual( - "account;admin@test.com;{PLAIN}toto;;;True;DomainAdmins;admin@test.com;10;test.com\r\naccount;admin@test2.com;{PLAIN}toto;;;True;DomainAdmins;admin@test2.com;10;test2.com\r\naccount;user@test.com;{PLAIN}toto;;;True;SimpleUsers;user@test.com;10\r\naccount;user@test2.com;{PLAIN}toto;;;True;SimpleUsers;user@test2.com;10\r\nalias;alias@test.com;True;user@test.com\r\nforward;forward@test.com;True;user@external.com\r\ndlist;postmaster@test.com;True;toto@titi.com;test@truc.fr\r\n", + "account;admin@test.com;{PLAIN}toto;;;True;DomainAdmins;admin@test.com;10;test.com\r\naccount;admin@test2.com;{PLAIN}toto;;;True;DomainAdmins;admin@test2.com;10;test2.com\r\naccount;user@test.com;{PLAIN}toto;;;True;SimpleUsers;user@test.com;10\r\naccount;user@test2.com;{PLAIN}toto;;;True;SimpleUsers;user@test2.com;10\r\nalias;alias@test.com;True;user@test.com\r\nforward;forward@test.com;True;user@external.com\r\ndlist;postmaster@test.com;True;test@truc.fr;toto@titi.com\r\n", response.content.strip() ) @@ -92,5 +92,5 @@ class ExportTestCase(ModoTestCase): response = self.__export_identities(idtfilter="dlist") self.assertEqual( response.content.strip(), - "dlist;postmaster@test.com;True;toto@titi.com;test@truc.fr" + "dlist;postmaster@test.com;True;test@truc.fr;toto@titi.com" )