Gitlab CSE Unil

Commit 8a856559 authored by Julien Furrer's avatar Julien Furrer
Browse files

Added support for Trusted Third Party authentication and authorization

parent 051d7fc1
......@@ -8,4 +8,3 @@
**/*.old.jpg
screen_session_cmd
......@@ -122,7 +122,7 @@ class AnObj(models.Model):
ordering = ["-id"]
def __unicode__(self):
return u"{}".format(self.name)
return "{}".format(self.name)
def save(self, *args, **kwargs):
"""
......
# coding=utf-8
from __future__ import unicode_literals
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.http.response import HttpResponseRedirect
from django.shortcuts import render
from django.core.cache import cache
import sys
from .models import AnObjMembership
SHARING_MODE_NONE = 0
SHARING_MODE_MANUAL = 1
SHARING_MODE_REGKEY = 4
SHARING_MODE_AAIRULES = 8
SHARING_MODE_MOODLELTI = 16
SHARING_MODE_TTP_MOODLE = 16
SHARING_MODE_TTP_TOTO = 24
SHARING_MODE_TTP_MY_TTP = 32
class PermissionClass(object):
......@@ -19,6 +26,7 @@ class PermissionClass(object):
and ``check_revocation``methods
"""
has_interactive_registration = False
ttp = False
@classmethod
def get_interactive_registration_response(cls, request, anobj):
......@@ -158,16 +166,111 @@ class AAIRules(PermissionClass):
pass
class MoodleLTI(PermissionClass):
class ATTP(PermissionClass):
has_interactive_registration = True
ttp = True
ttp_id = 'moodle'
@classmethod
def set_attp_status(cls, request, anobj, status):
key = "attp_{ttp_id}{user_id}{uuid}".format(
ttp_id=cls.ttp_id, user_id=request.user.id,
uuid=anobj.uuid[:12]
)
cache.set(key, status, settings.ATTP['OPTIONS']['CACHE_TIMEOUT'])
return status
@classmethod
def get_attp_status(cls, request, anobj):
key = "attp_{ttp_id}{user_id}{uuid}".format(
ttp_id=cls.ttp_id, user_id=request.user.id,
uuid=anobj.uuid[:12]
)
status = cache.get(key)
return status
@classmethod
def clear_attp_status(cls, request, anobj):
# key = "attp_{ttp_id}{user_id}{uuid}".format(
# ttp_id=cls.ttp_id, user_id=request.user.id,
# uuid=anobj.uuid[:12]
# )
session_key = "anobj_{}".format(anobj.uuid[:12])
try:
del(request.session[session_key])
except KeyError:
pass
@classmethod
def check_registration(cls, request, anobj):
pass
@classmethod
def check_revocation(cls, request, anobj):
pass
@classmethod
def check_permission(cls, request, anobj):
perm_status = cls.get_attp_status(request, anobj)
if perm_status is None:
raise PermissionDenied()
elif perm_status == 'denied':
if not anobj.locked:
# Revoke only if anobj is not locked
cls._revoke_user(request.user, anobj)
cls.clear_attp_status(request, anobj)
raise PermissionDenied()
else:
if request.user not in anobj.members.all():
cls._register_user(request.user, anobj)
# Check ownership
owners = anobj.owners.all()
if request.user in owners:
if perm_status != 'owner' and len(owners) > 1:
# is owner, but shouldn't -> remove only if not last one
anobj.owners.remove(request.user)
else:
if perm_status == 'owner':
# is not owner, but should -> add
anobj.owners.add(request.user)
# cls.clear_attp_status(request, anobj)
return True
@classmethod
def get_interactive_registration_response(cls, request, anobj):
if cls.get_attp_status(request, anobj) is None:
check_url = settings.ATTP.get(cls.ttp_id, {}).get('CHECK_URL')
return HttpResponseRedirect(check_url.format(uuid=anobj.uuid))
else:
cls.clear_attp_status(request, anobj)
raise PermissionDenied()
class MoodleTTP(ATTP):
ttp_id = 'moodle'
class TotoTTP(ATTP):
ttp_id = 'toto'
class MyTTP(ATTP):
ttp_id = 'my_ttp'
PERMISSION_CLASSES = {
SHARING_MODE_NONE: None,
SHARING_MODE_MANUAL: IsMember,
SHARING_MODE_REGKEY: RegistrationKey,
SHARING_MODE_AAIRULES: AAIRules,
SHARING_MODE_MOODLELTI: MoodleLTI
SHARING_MODE_TTP_MOODLE: MoodleTTP,
SHARING_MODE_TTP_TOTO: TotoTTP,
SHARING_MODE_TTP_MY_TTP: MyTTP
}
......@@ -175,6 +278,18 @@ def get_permission_class(sharing_mode):
return PERMISSION_CLASSES.get(sharing_mode)
def get_ttp_sharing_mode(ttp_id=''):
"""
Return the sharing mode value for the given ttp_id
:param ttp_id:
:return:
"""
attr_name = 'SHARING_MODE_TTP_{}'.format(ttp_id.upper())
print attr_name
mode = getattr(sys.modules[__name__], attr_name, 0)
return mode
def check_anobj_permission(request, anobj):
p = get_permission_class(anobj.sharing_mode)
if p is not None:
......
......@@ -6,7 +6,7 @@ from rest_framework.routers import DefaultRouter
from rest_framework_nested import routers
from rest_framework import urls as rest_framework_urls
from adim.views import AnObjViewSet, SharedAnObjViewSet, AnnotationViewSet, SharedAnnotationViewSet, UserViewSet
from adim.views import AnObjViewSet, UAnObjViewSet, SharedAnObjViewSet, AnnotationViewSet, SharedAnnotationViewSet, UserViewSet
# Default router, as wee need the 'root-api' name
......@@ -14,6 +14,7 @@ d_router = DefaultRouter()
router = routers.SimpleRouter()
router.register(r'anobjs', AnObjViewSet, base_name='anobjs')
router.register(r'uanobjs', UAnObjViewSet, base_name='uanobjs')
router.register(r'shared/anobjs', SharedAnObjViewSet, base_name='shared-anobjs')
router.register(r'annotations', AnnotationViewSet, base_name='annotations')
router.register(r'users', UserViewSet, base_name='users')
......
......@@ -114,6 +114,13 @@ class AnObjViewSet(viewsets.ModelViewSet):
return Response({'publish_mode': membership.publish_mode})
class UAnObjViewSet(AnObjViewSet):
"""
Accessing AnObj by uuid. extents AnObj
"""
lookup_field = 'uuid'
class SharedAnObjViewSet(AnObjViewSet):
"""
ViewSet for shared AnObj, requested by a user
......
......@@ -9,3 +9,4 @@ class UploadImageFileForm(forms.Form):
for upload
"""
image_file = forms.ImageField(allow_empty_file=False)
name = forms.CharField(max_length=125, required=False)
\ No newline at end of file
......@@ -66,6 +66,7 @@ function($){
onStart: null,
processstart: null,
autoUpload: true,
sequentialUploads: false,
add: null
// showAlert: function(errId) {},
// clearAlert: function()
......@@ -85,7 +86,8 @@ function($){
dropZone: params.dropZone,
autoUpload: params.autoUpload,
done: function (e, data) {
if (!data.result.error && data.result.next) {
if ($fileuploadElem.fileupload('active') === 1 &&
!data.result.error && data.result.next) {
document.location.assign(data.result.next);
}
},
......
......@@ -35,7 +35,7 @@ define([
SHARING_MODE_MANUAL = 1,
SHARING_MODE_REGKEY = 4,
SHARING_MODE_AAIRULES = 8,
SHARING_MODE_MOODLELTI = 16;
SHARING_MODE_MOODLETTP = 16;
return Backbone.View.extend({
......@@ -200,6 +200,14 @@ define([
// Reset controls state
this.$("input[name=aom-prop-new-member]").val("");
this.$(".aom-shm-ctrl-members-remove-but").attr("disabled", "disabled");
},
'attp_label': function($el, model, opts) {
if (opts.url && opts.label) {
$el.attr("href", opts.url).text(opts.label);
} else {
$el.closest('.aom-prop-shm').addClass('hidden');
}
}
};
......
......@@ -3,7 +3,11 @@ from __future__ import unicode_literals
import json
import os
from django.contrib.auth.views import redirect_to_login
from django.views.decorators.csrf import csrf_exempt
import ldap
import logging
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse
......@@ -11,19 +15,25 @@ from django.core.cache import cache
from django.db.models import Q
from django.http.response import HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, Http404, \
HttpResponseForbidden
from django.shortcuts import render, get_object_or_404
from django.shortcuts import render, get_object_or_404, resolve_url
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.views.decorators.http import require_POST
from django.views.decorators.cache import cache_control
from adim.models import AnObj, AnObjMembership
from adim.permissions import get_permission_class, has_anobj_access, SHARING_MODE_NONE
from adim.permissions import get_permission_class, has_anobj_access, get_ttp_sharing_mode, SHARING_MODE_NONE
from adim_ttp.decorators import attp_login
from adim_utils.decorators import clear_function_cache
from .forms import UploadImageFileForm
from sendfile import sendfile
from .utils import add_image_border, create_image_thumbnail
logger = logging.getLogger(__name__)
def home(request):
"""
Home page
......@@ -127,7 +137,7 @@ def annotate_new(request):
return render(request, "adim/annotation_new.html", {})
@login_required
# @login_required
def annotate(request, anobj_uuid=None):
"""
Annotation page
......@@ -135,9 +145,9 @@ def annotate(request, anobj_uuid=None):
:param anobj_uuid:
:return:
"""
context = {
'membership': False
}
# ----- Some preliminary validations
if anobj_uuid is None or len(anobj_uuid) < 8:
raise Http404()
try:
anobj = AnObj.objects.select_related('owner').get(uuid__startswith=anobj_uuid)
......@@ -148,44 +158,61 @@ def annotate(request, anobj_uuid=None):
if len(anobj_uuid) < 32:
return HttpResponseRedirect(reverse('adim_app:annotate', kwargs={'anobj_uuid': anobj.uuid}))
# ----- Login check. Not using decorator so we can delegate to Trusted Third Party if needed
permission_class = get_permission_class(anobj.sharing_mode)
if request.user.is_anonymous():
if permission_class and permission_class.ttp:
check_url = settings.ATTP.get(permission_class.ttp_id, {}).get('CHECK_URL')
return HttpResponseRedirect(check_url.format(uuid=anobj.uuid))
else:
return redirect_to_login(resolve_url('adim_app:annotate', anobj_uuid=anobj.uuid))
# ----- Build context
context = {
'membership': False
}
# is_owner = request.user == anobj.owner
# is_owner = request.user in anobj.owners.all()
is_owner = anobj.is_owned(request.user.id)
context.update({'is_owner': is_owner})
# Detailed check for permissions
# ----- Detailed check for permissions
membership = None
if is_owner:
if is_owner and not (permission_class and permission_class.ttp):
# User is owner and anobj is not shared via Trusted Third Party
if anobj.sharing_mode != SHARING_MODE_NONE:
membership, _ = AnObjMembership.objects.get_or_create(anobj=anobj, user=request.user)
else:
# User is guest
perm_class = get_permission_class(anobj.sharing_mode)
if perm_class is None:
# User is guest or owner and anobj shared via TTP
if permission_class is None:
# AnObj not shared
raise Http404()
# raise PermissionDenied()
elif not perm_class.has_permission(request, anobj):
# AnObj shared but user has no permission
if perm_class.has_interactive_registration:
elif not permission_class.has_permission(request, anobj):
# AnObj shared but user has no permission yet
if permission_class.has_interactive_registration:
# Interactive registration exists, call it
return perm_class.get_interactive_registration_response(request, anobj)
# No interactive registration
return permission_class.get_interactive_registration_response(request, anobj)
# No interactive registration for this sharing model, deny access
raise PermissionDenied()
else:
# AnObj shared, user registred, go on
# AnObj shared, user authorized and registered
pass
# TTP permission may have changed ownership
if permission_class.ttp:
clear_function_cache(f='adim.models.annotablesis_owned', args=(anobj, request.user.id))
is_owner = anobj.is_owned(request.user.id)
membership = AnObjMembership.objects.get(anobj=anobj, user=request.user)
# Interactive registration may post credentials, if so redirect to current view with GET method
if request.method == 'POST':
return HttpResponseRedirect(reverse('adim_app:annotate', kwargs={'anobj_uuid': anobj_uuid}))
context.update({
'is_owner': is_owner,
'membership': membership,
'anobj': anobj
})
......@@ -210,14 +237,14 @@ def annotate(request, anobj_uuid=None):
)
)
})
return render(request, "adim/annotation.html", context)
@csrf_exempt
@attp_login
@login_required
def upload_file(request, anobj_uuid=None):
"""
-- inspired by: https://github.com/miki725/Django-jQuery-File-Uploader-Integration-demo/blob/master/upload/views.py
:param request:
:return:
......@@ -228,6 +255,8 @@ def upload_file(request, anobj_uuid=None):
response_type = "application/json"
response_data = {}
user = request.user # if request.user.is_authenticated() else moodle_meta.get('user')
form = UploadImageFileForm(request.POST, request.FILES)
if form.is_valid():
image_file = request.FILES['image_file']
......@@ -236,24 +265,43 @@ def upload_file(request, anobj_uuid=None):
'error': file_response.get('error'),
'files': [file_response],
})
anobj_name = form.cleaned_data['name']
# Create AnObj
if not file_response['error']:
anobj = None
if anobj_uuid:
try:
anobj = _get_anobj(request, anobj_uuid=anobj_uuid)
except Http404:
anobj = None
if anobj:
anobj.image = image_file
if anobj_name:
anobj.name = anobj_name
anobj.save()
except Http404:
else:
anobj = AnObj.objects.create(
owner=request.user,
name=os.path.splitext(image_file.name)[0],
owner=user,
name=anobj_name or os.path.splitext(image_file.name)[0],
image=image_file
)
if hasattr(request, 'attp_message'):
ttp_id = request.attp_message.get('attp_id')
sharing_mode = get_ttp_sharing_mode(ttp_id=ttp_id)
if sharing_mode:
anobj.sharing_mode = sharing_mode
sharing_opts = request.attp_message.get('opts')
if sharing_opts:
anobj.sharing_opts = sharing_opts
anobj.save()
# Create original thumbnail, returned to user who has not yet annotated
create_image_thumbnail(anobj.image.path)
response_data['next'] = reverse('adim_app:annotate', kwargs={'anobj_uuid': anobj.uuid})
response_data['uuid'] = anobj.uuid
# Needed when using iFrame transport
if "text/html" in request.META["HTTP_ACCEPT"]:
......@@ -261,7 +309,9 @@ def upload_file(request, anobj_uuid=None):
else:
response_data['error'] = "invalid"
if request.is_ajax():
print("#" * 80, "\n", request.META.get('HTTP_ACCEPT', ''), "#" * 80)
if 'application/json' in request.META.get('HTTP_ACCEPT', ''):
return HttpResponse(json.dumps(response_data), content_type=response_type)
else:
return HttpResponseRedirect(response_data['next'])
......
......@@ -135,6 +135,8 @@ LOGIN_REDIRECT_URL = "adim.app:annotate-new"
LOGIN_URL = "adim.app:home"
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
SESSION_COOKIE_NAME = "adim_sessid"
# ---------- END AUTHENTICATION
......@@ -147,7 +149,7 @@ SENDFILE_BACKEND = 'sendfile.backends.xsendfile'
# ..... REST FRAMEWORK
LOCAL_APPS += ('rest_framework',)
LOCAL_APPS += ('rest_framework', 'rest_framework.authtoken')
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.SessionAuthentication',
......@@ -158,6 +160,7 @@ REST_FRAMEWORK = {
]
}
# ..... SHIBAUTH
LOCAL_APPS += ('shibauth', )
AUTHENTICATION_BACKENDS = ('shibauth.shibbolethbackends.ShibbolethBackend',) + AUTHENTICATION_BACKENDS
......@@ -168,8 +171,20 @@ except ImportError:
raise ImproperlyConfigured("Unable to import SHIBAUTH configurations")
# ..... CORS HEADERS (https://github.com/ottoyiu/django-cors-headers/)
LOCAL_APPS += ('corsheaders',)
MIDDLEWARE_CLASSES = list(MIDDLEWARE_CLASSES)
MIDDLEWARE_CLASSES.insert(
MIDDLEWARE_CLASSES.index('django.middleware.common.CommonMiddleware'),
'corsheaders.middleware.CorsMiddleware'
)
MIDDLEWARE_CLASSES += ('corsheaders.middleware.CorsMiddleware', )
CORS_ORIGIN_ALLOW_ALL = True
CORS_URLS_REGEX = r'^/api/.*$'
# ..... ADIM
LOCAL_APPS += ('adim', 'adim_app',)
LOCAL_APPS += ('adim', 'adim_app', 'adim_ttp')
# Max file size in Mb
ADIM_UPLOAD_MAX_FILESIZE = 50
......@@ -202,6 +217,21 @@ AAI = {
}
}
ATTP = {
'OPTIONS': {
'CACHE_TIMEOUT': 20, # 30,
},
'moodle': {
'CHECK_URL': "http://localhost/tests/phpupload/gv.php?a={uuid}"
},
'toto': {
'CHECK_URL': "http://localhost/tests/phpupload/toto.php?a={uuid}"
},
'my_ttp': {
'CHECK_URL': "http://my-ttp:8001/ttp/check/{uuid}/"
}
}
# ---------- END LOCAL APPS CONFIGURATION
......@@ -220,3 +250,75 @@ INSTALLED_APPS = DJANGO_APPS + LOCAL_APPS
# ---------- END APPS CONFIGURATION
# ========== LOGGING CONFIGURATION
#
def add_remote_info(record):
req = getattr(record, 'request', None)
record.remote_addr = req.META.get('REMOTE_ADDR', '-') if req else '-'
record.forwarded_for = req.META.get('HTTP_X_FORWARDED_FOR', '-') if req else '-'
return True
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'filters': {
'require_debug_false': {
'()': 'django.utils.log.RequireDebugFalse'
},
'require_debug_true': {
'()': 'django.utils.log.RequireDebugTrue'
},
'add_remote_info': {
'()': 'django.utils.log.CallbackFilter',
'callback': add_remote_info,
}
},
'formatters': {
'verbose': {
'format': '%(levelname)s %(asctime)s %(name)s.%(funcName)s %(message)s',
},
'verbose_with_remote': {
'format': '%(levelname)s %(asctime)s %(name)s.%(funcName)s %(remote_addr)s %(forwarded_for)s %(message)s',
},
'simple': {
'format': '%(levelname)s %(message)s'
}
},
'handlers': {
'mail_admins': {
'level': 'ERROR',
'filters': ['require_debug_false'],
'class': 'django.utils.log.AdminEmailHandler'
},
'console': {
'filters': ['require_debug_true', ],
'class': 'logging.StreamHandler',
},
'file': {
'level': 'INFO',
'class': 'logging.FileHandler',
'filters': ['add_remote_info', ],
'formatter': 'verbose_with_remote',
'filename': '{}/log/debug.log'.format(dirname(SITE_ROOT)),
},
},
'loggers': {
'django.request': {
'handlers': ['mail_admins', 'file', ],
'level': 'WARNING',
'propagate': True,
},
'adim_app': {