From f40f927eda9f76046d777513a95ae78683e3fe98 Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Wed, 8 Jul 2015 00:46:20 +0200 Subject: [PATCH 3/3] views: add a user_info endpoint The endpoint supports JSON with CORS or JSONP with Referer validation. Browser or proxy not sending Referer headers will be forbidden to access the view. Cross-origin check are disabled when DEBUG=True. It also means that just viewing it in you browser is forbidden (as the browser will not send the Referer or Origin header). --- src/authentic2/a2_rbac/models.py | 9 ++++++++ src/authentic2/cors.py | 44 ++++++++++++++++++++++++++++++++++++ src/authentic2/custom_user/models.py | 13 +++++++++++ src/authentic2/decorators.py | 39 +++++++++++++++++++++++++++++++- src/authentic2/idp/saml/__init__.py | 10 ++++++++ src/authentic2/models.py | 12 ++++++++++ src/authentic2/tests.py | 9 ++++++++ src/authentic2/urls.py | 1 + src/authentic2/views.py | 10 ++++++++ 9 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 src/authentic2/cors.py diff --git a/src/authentic2/a2_rbac/models.py b/src/authentic2/a2_rbac/models.py index bddd218..e59ef9c 100644 --- a/src/authentic2/a2_rbac/models.py +++ b/src/authentic2/a2_rbac/models.py @@ -147,6 +147,15 @@ class Role(RoleAbstractBase): verbose_name_plural = _('roles') ordering = ('ou', 'service', 'name',) + def to_json(self): + return { + 'uuid': self.uuid, + 'name': self.name, + 'slug': self.slug, + 'is_admin': bool(self.admin_scope_ct and self.admin_scope_id), + 'is_service': bool(self.service), + } + class RoleParenting(RoleParentingAbstractBase): class Meta: diff --git a/src/authentic2/cors.py b/src/authentic2/cors.py new file mode 100644 index 0000000..712bf75 --- /dev/null +++ b/src/authentic2/cors.py @@ -0,0 +1,44 @@ +from .decorators import SessionCache +import urlparse + +from django.conf import settings + +from . import plugins + + +def make_origin(url): + '''Build origin of an URL''' + parsed = urlparse.urlparse(url) + if ':' in parsed.netloc: + host, port = parsed.netloc.split(':', 1) + if parsed.scheme == 'http' and port == 80: + port = None + if parsed.scheme == 'https' and port == 443: + port = None + else: + host, port = parsed.netloc, None + result = '%s://%s' % (parsed.scheme, host) + if port: + result += ':%s' % port + return result + + +@SessionCache(timeout=60, args=(1,)) +def check_origin(request, origin): + '''Decide if an origin is authorized to do a CORS request''' + if settings.DEBUG: + return True + request_origin = make_origin(request.build_absolute_uri()) + if origin == 'null': + return False + if not origin: + return False + if origin == request_origin: + return True + for plugin in plugins.get_plugins(): + if hasattr(plugin, 'check_origin'): + if plugin.check_origin(request, origin): + return True + return False + + diff --git a/src/authentic2/custom_user/models.py b/src/authentic2/custom_user/models.py index e401838..9f3c150 100644 --- a/src/authentic2/custom_user/models.py +++ b/src/authentic2/custom_user/models.py @@ -13,6 +13,7 @@ from django_rbac.utils import get_role_parenting_model from authentic2 import utils, validators, app_settings from authentic2.decorators import errorcollector +from authentic2.models import Service from .managers import UserManager @@ -128,3 +129,15 @@ class User(AbstractBaseUser, PermissionMixin): def natural_key(self): return (self.uuid,) + + def to_json(self): + return { + 'uuid': self.uuid, + 'username': self.username, + 'email': self.email, + 'first_name': self.first_name, + 'last_name': self.last_name, + 'is_superuser': self.is_superuser, + 'roles': [role.to_json() for role in self.roles_and_parents().filter(service__isnull=True)], + 'services': [service.to_json(user=self) for service in Service.objects.all()], + } diff --git a/src/authentic2/decorators.py b/src/authentic2/decorators.py index 8132c6f..df81bb8 100644 --- a/src/authentic2/decorators.py +++ b/src/authentic2/decorators.py @@ -1,10 +1,12 @@ +import re +from json import dumps as json_dumps from contextlib import contextmanager import time from functools import wraps from django.contrib.auth.decorators import login_required from django.views.debug import technical_404_response -from django.http import Http404 +from django.http import Http404, HttpResponseForbidden, HttpResponse, HttpResponseBadRequest from django.core.cache import cache as django_cache from . import utils, app_settings, middleware @@ -234,3 +236,38 @@ def errorcollector(error_dict): yield except ValidationError, e: e.update_error_dict(error_dict) + + +def json(func): + '''Convert view to a JSON or JSON web-service supporting CORS''' + from . import cors + @wraps(func) + def f(request, *args, **kwargs): + # 1. check origin + origin = request.META.get('HTTP_ORIGIN') + if origin is None: + origin = request.META.get('HTTP_REFERER') + if origin: + origin = cors.make_origin(origin) + if not cors.check_origin(request, origin): + return HttpResponseForbidden('bad origin') + # 2. build response + result = func(request, *args, **kwargs) + json_str = json_dumps(result) + response = HttpResponse(content_type='application/json') + for variable in ('jsonpCallback', 'callback'): + if variable in request.GET: + identifier = request.GET[variable] + if not re.match(r'^[$a-zA-Z_][0-9a-zA-Z_$]*$', identifier): + return HttpResponseBadRequest('invalid JSONP callback name') + json_str = '%s(%s);' % (identifier, json_str) + break + else: + response['Access-Control-Allow-Origin'] = origin + response['Access-Control-Allow-Credentials'] = 'true' + response['Access-Control-Allow-Headers'] = 'x-requested-with' + response.write(json_str) + return response + return f + + diff --git a/src/authentic2/idp/saml/__init__.py b/src/authentic2/idp/saml/__init__.py index ea46221..a9f516c 100644 --- a/src/authentic2/idp/saml/__init__.py +++ b/src/authentic2/idp/saml/__init__.py @@ -32,6 +32,16 @@ class Plugin(object): def get_idp_backends(self): return ['authentic2.idp.saml.backend.SamlBackend'] + def check_origin(self, request, origin): + from authentic2.cors import make_origin + from authentic2.saml.models import LibertySession + for session in LibertySession.objects.filter( + django_session_key=request.session.session_key): + provider_origin = make_origin(session.provider_id) + if origin == provider_origin: + return True + + from django.apps import AppConfig class SAML2IdPConfig(AppConfig): name = 'authentic2.idp.saml' diff --git a/src/authentic2/models.py b/src/authentic2/models.py index 9225961..93c25dd 100644 --- a/src/authentic2/models.py +++ b/src/authentic2/models.py @@ -277,3 +277,15 @@ class Service(models.Model): def __unicode__(self): return self.name + + def to_json(self, user=None): + if user: + roles = user.roles_and_parents().filter(service=self) + else: + roles = self.roles.all() + return { + 'name': self.name, + 'slug': self.slug, + 'ou': unicode(self.ou) if self.ou else None, + 'roles': [role.to_json() for role in roles], + } diff --git a/src/authentic2/tests.py b/src/authentic2/tests.py index efebd17..db940cc 100644 --- a/src/authentic2/tests.py +++ b/src/authentic2/tests.py @@ -651,6 +651,8 @@ class CacheTests(TestCase): def f(): return random.random() + def f2(a, b): + return a # few chances the same value comme two times in a row self.assertNotEquals(f(), f()) @@ -669,6 +671,13 @@ class CacheTests(TestCase): # null timeout, no cache h = GlobalCache(timeout=0)(f) self.assertNotEquals(h(), h()) + # vary on second arg + i = GlobalCache(hostname_vary=False, args=(1,))(f2) + for a in range(1, 10): + self.assertEquals(i(a, 1), 1) + for a in range(2, 10): + self.assertEquals(i(a, a), a) + def test_django_cache(self): client = Client() diff --git a/src/authentic2/urls.py b/src/authentic2/urls.py index 76bdb3f..0d0f4a8 100644 --- a/src/authentic2/urls.py +++ b/src/authentic2/urls.py @@ -19,6 +19,7 @@ not_homepage_patterns = patterns('authentic2.views', url(r'^logout/$', 'logout', name='auth_logout'), url(r'^redirect/(.*)', 'redirect', name='auth_redirect'), url(r'^accounts/', include('authentic2.profile_urls')), + url(r'^user_info/', 'user_info', name='user_info'), ) not_homepage_patterns += patterns('', diff --git a/src/authentic2/views.py b/src/authentic2/views.py index 3c3dc9d..8de83ed 100644 --- a/src/authentic2/views.py +++ b/src/authentic2/views.py @@ -31,6 +31,7 @@ from django.views.decorators.cache import never_cache from django.contrib.auth.decorators import login_required from django.db.models.fields import FieldDoesNotExist from django.db.models.query import Q +from django.views.decorators.vary import vary_on_headers # FIXME: this decorator has nothing to do with an idp, should be moved in the @@ -509,3 +510,12 @@ def test_redirect(request): messages.warning(request, 'Un warning') messages.error(request, 'Une erreur') return HttpResponseRedirect(next_url) + +@vary_on_headers('Cookie', 'Origin', 'Referer') +@decorators.json +def user_info(request): + if request.user.is_anonymous(): + return {} + return request.user.to_json() + + -- 2.1.4