mirror of
https://github.com/jwinterm/modoboa.git
synced 2026-10-17 05:41:25 +02:00
Merge pull request #794 from tonioo/change_password_api_523
First draft of a REST API.
This commit is contained in:
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
)
|
||||
@@ -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})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
'',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""External API urls."""
|
||||
|
||||
from django.conf.urls import patterns, include
|
||||
|
||||
|
||||
urlpatterns = patterns(
|
||||
"",
|
||||
|
||||
("", include("modoboa.core.urls_api")),
|
||||
)
|
||||
@@ -11,3 +11,4 @@ django-xforwardedfor-middleware
|
||||
django-passwords==0.3.4
|
||||
rfc6266
|
||||
progressbar==2.3
|
||||
djangorestframework==3.3.1
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user