Merge pull request #794 from tonioo/change_password_api_523

First draft of a REST API.
This commit is contained in:
Antoine Nguyen
2015-11-27 15:30:35 +01:00
25 changed files with 356 additions and 21 deletions
+3 -3
View File
@@ -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)
@@ -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),
),
]
+2 -2
View File
@@ -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"
)
+29
View File
@@ -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)
@@ -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")
+14
View File
@@ -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")
+1
View File
@@ -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
+41
View File
@@ -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
@@ -0,0 +1,32 @@
{% load i18n form_tags %}
<h2>{% trans "API access" %} <small>{% trans "Control your access to Modoboa API" %}</small></h2>
<hr>
{% if user.auth_token %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "API access token" %}</h3>
</div>
<div class="panel-body">
{{ user.auth_token }}
</div>
</div>
<br>
{% endif %}
<form class="form-inline" method="POST" action="{% url 'core:user_api_access' %}"
id="api_form">
{% csrf_token %}
<div class="row">
<div class="col-sm-2">
<div class="checkbox">
<label>
{{ form.enable_api_access }}
{{ form.enable_api_access.label }}
</label>
</div>
</div>
<div class="col-sm-3">
<button type="submit" class="btn btn-primary" id="update" name="update">{% trans 'Update' %}</button>
</div>
</div>
</form>
+1 -1
View File
@@ -18,7 +18,7 @@ $(document).ready(function() {
deflocation: "profile/",
formid: "uprefs_form",
divid: "uprefs_content",
reload_exceptions: ["profile"]
reload_exceptions: ["profile", "api"]
});
});
</script>
@@ -1,10 +1,13 @@
{% load i18n form_tags %}{% load url from future %}
{% load i18n form_tags %}
{% load url from future %}
<h2>{% trans "Profile" %} <small>{% trans "Update your personal information" %}</small></h2>
<hr>
<form class="form-horizontal" method="POST" action="{% url 'core:user_profile' %}"
id="uprefs_form">{% csrf_token %}
id="uprefs_form">
{% csrf_token %}
{% render_form form %}
<div class="form-actions">
<button type="submit" class="btn btn-primary col-lg-offset-8 col-sm-offset-7 col-lg-1 col-sm-2" id="update" name="update">{% trans 'Update' %}</button>
<button type="submit" class="btn btn-primary btn-lg col-sm-offset-4 col-sm-4" id="update" name="update">{% trans 'Update' %}</button>
</div>
</form>
+7
View File
@@ -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', {
+65 -1
View File
@@ -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"))
+2
View File
@@ -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"),
)
+14
View File
@@ -0,0 +1,14 @@
"""Core API urls."""
from django.conf.urls import patterns, url
from . import api
urlpatterns = patterns(
"",
url("^users/(?P<pk>.+)/password/$",
api.UserPasswordChangeAPIView.as_view(),
name="user_password_change"),
)
+26 -2
View File
@@ -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})
})
+3 -3
View File
@@ -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)
+20
View File
@@ -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"
@@ -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,
),
]
+6 -4
View File
@@ -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);
+2 -1
View File
@@ -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)
+6
View File
@@ -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(
'',
+10
View File
@@ -0,0 +1,10 @@
"""External API urls."""
from django.conf.urls import patterns, include
urlpatterns = patterns(
"",
("", include("modoboa.core.urls_api")),
)
+1
View File
@@ -11,3 +11,4 @@ django-xforwardedfor-middleware
django-passwords==0.3.4
rfc6266
progressbar==2.3
djangorestframework==3.3.1
+12 -1
View File
@@ -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")