From 6a2304039e790217d650f74e9e234af1900b66fb Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Tue, 10 Nov 2015 10:32:00 +0100 Subject: [PATCH 3/4] api: new user API (#7862) You can list/add/change users. Security is enforced by basic authentication, session authentication and role permissions: - custom_user.view_user for listing, - custom_user.add_user for creating, - custom_user.change_user for updating, - custom_user.delete_user for deleting. --- setup.py | 1 + src/authentic2/api_urls.py | 1 + src/authentic2/api_views.py | 126 +++++++++++++++++++++++++++++++++++++++++++- src/authentic2/settings.py | 9 ++++ tox.ini | 4 +- 5 files changed, 138 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 8aa65ab..91fb31e 100755 --- a/setup.py +++ b/setup.py @@ -118,6 +118,7 @@ setup(name="authentic2", 'six>=1', 'Markdown>=2.1', 'python-ldap', + 'django-filter', ], extras_require = { 'idp-openid': ['python-openid'], diff --git a/src/authentic2/api_urls.py b/src/authentic2/api_urls.py index 82ead03..1017978 100644 --- a/src/authentic2/api_urls.py +++ b/src/authentic2/api_urls.py @@ -10,3 +10,4 @@ urlpatterns = patterns('', url(r'^user/$', api_views.user, name='a2-api-user'), ) +urlpatterns += api_views.router.urls diff --git a/src/authentic2/api_views.py b/src/authentic2/api_views.py index c374807..bb454db 100644 --- a/src/authentic2/api_views.py +++ b/src/authentic2/api_views.py @@ -1,4 +1,5 @@ '''Views for Authentic2 API''' +import json import smtplib from django.db import models @@ -11,11 +12,15 @@ from django.views.decorators.cache import cache_control from django_rbac.utils import get_ou_model from rest_framework import serializers +from rest_framework.viewsets import ModelViewSet +from rest_framework.routers import SimpleRouter from rest_framework.generics import GenericAPIView from rest_framework.response import Response -from rest_framework import authentication, permissions, status +from rest_framework import permissions, status +from rest_framework.exceptions import PermissionDenied from . import utils, decorators +from .models import Attribute, AttributeValue class HasUserAddPermission(permissions.BasePermission): @@ -83,7 +88,6 @@ class RpcMixin(object): class BaseRpcView(RpcMixin, GenericAPIView): - authentication_classes = (authentication.BasicAuthentication,) permission_classes = (permissions.IsAuthenticated, HasUserAddPermission) @@ -200,3 +204,121 @@ def user(request): if request.user.is_anonymous(): return {} return request.user.to_json() + + +_class_cache = {} + + +def attributes_hash(attributes): + attributes = sorted(attributes, key=lambda at: at.name) + return hash(tuple((at.name, at.required) for at in attributes)) + + +def get_user_class(): + attributes = Attribute.objects.filter(kind='string') + key = 'user-class-%s' % attributes_hash(attributes) + if key not in _class_cache: + user_class = get_user_model() + + class Meta: + proxy = True + fields = { + 'Meta': Meta, + '__module__': user_class.__module__, + } + for at in attributes: + def new_property(at): + def get_property(self): + try: + return json.loads( + AttributeValue.objects.with_owner(self).get(attribute=at).content) + except AttributeValue.DoesNotExist: + return '' + + def set_property(self, value): + at.set_value(self, value) + return property(get_property, set_property) + fields[at.name] = new_property(at) + _class_cache[key] = type('NewUserClass', (user_class,), fields) + return _class_cache[key] + + +class BaseUserSerializer(serializers.ModelSerializer): + ou = serializers.SlugRelatedField( + queryset=get_ou_model().objects.all(), + slug_field='slug', + required=False, allow_null=True) + date_joined = serializers.DateTimeField(read_only=True) + last_login = serializers.DateTimeField(read_only=True) + + def check_perm(self, perm, ou): + self.context['view'].check_perm(perm, ou) + + def create(self, validated_data): + extra_field = {} + for at in Attribute.objects.filter(kind='string'): + if at.name in validated_data: + extra_field[at.name] = validated_data.pop(at.name) + self.check_perm('custom_user.add_user', validated_data.get('ou')) + instance = super(BaseUserSerializer, self).create(validated_data) + for key, value in extra_field.iteritems(): + setattr(instance, key, value) + if 'password' in validated_data: + instance.set_password(validated_data['password']) + instance.save() + return instance + + def update(self, instance, validated_data): + extra_field = {} + for at in Attribute.objects.filter(kind='string'): + if at.name in validated_data: + extra_field[at.name] = validated_data.pop(at.name) + # Double check: to move an user from one ou into another you must be administrator of both + self.check_perm('custom_user.change_user', instance.ou) + self.check_perm('custom_user.change_user', validated_data.get('ou')) + super(BaseUserSerializer, self).update(instance, validated_data) + for key, value in extra_field.iteritems(): + setattr(instance, key, value) + if 'password' in validated_data: + instance.set_password(validated_data['password']) + instance.save() + return instance + + class Meta: + model = get_user_class() + exclude = ('date_joined', 'user_permissions', 'groups', 'last_login') + + +class UsersAPI(ModelViewSet): + filter_fields = ['username', 'first_name', 'last_name'] + ordering_fields = ['username', 'first_name', 'last_name'] + + def get_serializer_class(self): + attributes = Attribute.objects.filter(kind='string') + key = 'user-serializer-%s' % attributes_hash(attributes) + if key not in _class_cache: + attrs = {} + for at in attributes: + attrs[at.name] = serializers.CharField(required=at.required) + _class_cache[key] = type('UserSerializer', (BaseUserSerializer,), attrs) + return _class_cache[key] + + def get_queryset(self): + User = get_user_class() + return self.request.user.filter_by_perm(['custom_user.view_user'], User.objects.all()) + + def check_perm(self, perm, ou): + if ou: + if not self.request.user.has_ou_perm(perm, ou): + raise PermissionDenied(u'You do not have permission %s in %s' % (perm, ou)) + else: + if not self.request.user.has_perm(perm): + raise PermissionDenied(u'You do not have permission %s' % perm) + + def perform_destroy(self, instance): + self.check_perm('custom_user.delete_user', instance.ou) + super(UsersAPI, self).perform_destroy(instance) + + +router = SimpleRouter() +router.register(r'users', UsersAPI, base_name='a2-api-users') diff --git a/src/authentic2/settings.py b/src/authentic2/settings.py index 436ec5b..6455777 100644 --- a/src/authentic2/settings.py +++ b/src/authentic2/settings.py @@ -259,6 +259,15 @@ if django.VERSION >= (1,8): # Django REST Framework REST_FRAMEWORK = { 'NON_FIELD_ERRORS_KEY': '__all__', + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.BasicAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ), + 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework.filters.DjangoFilterBackend', + 'rest_framework.filters.OrderingFilter', + ), + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', } # diff --git a/tox.ini b/tox.ini index b61b838..2d60276 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,7 @@ setenv = DJANGO_SETTINGS_MODULE=authentic2.settings commands = ./getlasso.sh - py.test --junitxml=django17.xml --cov-report xml --cov=src/ --cov-config .coveragerc --ignore=src/django_rbac --nomigrations src/ + py.test --junitxml=django17.xml --cov-report xml --cov=src/ --cov-config .coveragerc --ignore=src/django_rbac --nomigrations src/ tests/ mv coverage.xml django17-coverage.xml # coverage run --source=. -a authentic2-ctl test -t src --settings=django_rbac.test_settings src/django_rbac/ usedevelop = True @@ -26,6 +26,8 @@ deps = django>1.7,<1.8 cssselect pylint==1.4.0 astroid==1.3.2 + django-webtest + WebTest [testenv:rbac-django17] # django.contrib.auth is not tested it does not work with our templates -- 2.1.4