From bf61e41b50eac097093d424f58252ed618168da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Wed, 13 Sep 2017 01:57:31 +0200 Subject: [PATCH 1/9] Move auth-related from 'kfet' app to 'kfet.auth'. --- cof/settings/common.py | 7 +- kfet/auth/__init__.py | 1 + kfet/auth/apps.py | 8 +++ kfet/{ => auth}/backends.py | 0 kfet/auth/context_processors.py | 10 +++ kfet/auth/fields.py | 20 ++++++ kfet/auth/forms.py | 44 ++++++++++++ kfet/{ => auth}/middleware.py | 2 +- kfet/auth/migrations/0001_initial.py | 21 ++++++ kfet/auth/migrations/__init__.py | 0 kfet/auth/models.py | 5 ++ .../templates/kfet/login_genericteam.html | 0 kfet/{tests/test_forms.py => auth/tests.py} | 0 kfet/auth/views.py | 69 +++++++++++++++++++ kfet/context_processors.py | 11 --- kfet/forms.py | 61 ++-------------- .../0058_delete_genericteamtoken.py | 17 +++++ kfet/models.py | 6 +- kfet/views.py | 65 ++--------------- 19 files changed, 213 insertions(+), 134 deletions(-) create mode 100644 kfet/auth/__init__.py create mode 100644 kfet/auth/apps.py rename kfet/{ => auth}/backends.py (100%) create mode 100644 kfet/auth/context_processors.py create mode 100644 kfet/auth/fields.py create mode 100644 kfet/auth/forms.py rename kfet/{ => auth}/middleware.py (95%) create mode 100644 kfet/auth/migrations/0001_initial.py create mode 100644 kfet/auth/migrations/__init__.py create mode 100644 kfet/auth/models.py rename kfet/{ => auth}/templates/kfet/login_genericteam.html (100%) rename kfet/{tests/test_forms.py => auth/tests.py} (100%) create mode 100644 kfet/auth/views.py create mode 100644 kfet/migrations/0058_delete_genericteamtoken.py diff --git a/cof/settings/common.py b/cof/settings/common.py index ba0b6044..92759d21 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -90,6 +90,7 @@ INSTALLED_APPS = [ 'wagtailmenus', 'modelcluster', 'taggit', + 'kfet.auth', 'kfet.cms', ] @@ -99,7 +100,7 @@ MIDDLEWARE_CLASSES = [ 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', - 'kfet.middleware.KFetAuthenticationMiddleware', + 'kfet.auth.middleware.KFetAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', @@ -127,7 +128,7 @@ TEMPLATES = [ 'wagtailmenus.context_processors.wagtailmenus', 'djconfig.context_processors.config', 'gestioncof.shared.context_processor', - 'kfet.context_processors.auth', + 'kfet.auth.context_processors.auth', 'kfet.context_processors.config', ], }, @@ -190,7 +191,7 @@ CAS_EMAIL_FORMAT = "%s@clipper.ens.fr" AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', 'gestioncof.shared.COFCASBackend', - 'kfet.backends.GenericTeamBackend', + 'kfet.auth.backends.GenericTeamBackend', ) RECAPTCHA_USE_SSL = True diff --git a/kfet/auth/__init__.py b/kfet/auth/__init__.py new file mode 100644 index 00000000..63392684 --- /dev/null +++ b/kfet/auth/__init__.py @@ -0,0 +1 @@ +default_app_config = 'kfet.auth.apps.KFetAuthConfig' diff --git a/kfet/auth/apps.py b/kfet/auth/apps.py new file mode 100644 index 00000000..ab791d18 --- /dev/null +++ b/kfet/auth/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import ugettext_lazy as _ + + +class KFetAuthConfig(AppConfig): + name = 'kfet.auth' + label = 'kfetauth' + verbose_name = _("K-Fêt - Authentification et Autorisation") diff --git a/kfet/backends.py b/kfet/auth/backends.py similarity index 100% rename from kfet/backends.py rename to kfet/auth/backends.py diff --git a/kfet/auth/context_processors.py b/kfet/auth/context_processors.py new file mode 100644 index 00000000..07c9537f --- /dev/null +++ b/kfet/auth/context_processors.py @@ -0,0 +1,10 @@ +from django.contrib.auth.context_processors import PermWrapper + + +def auth(request): + if hasattr(request, 'real_user'): + return { + 'user': request.real_user, + 'perms': PermWrapper(request.real_user), + } + return {} diff --git a/kfet/auth/fields.py b/kfet/auth/fields.py new file mode 100644 index 00000000..28ba1c9e --- /dev/null +++ b/kfet/auth/fields.py @@ -0,0 +1,20 @@ +from django import forms +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType +from django.forms import widgets + + +class KFetPermissionsField(forms.ModelMultipleChoiceField): + + def __init__(self, *args, **kwargs): + queryset = Permission.objects.filter( + content_type__in=ContentType.objects.filter(app_label="kfet"), + ) + super().__init__( + queryset=queryset, + widget=widgets.CheckboxSelectMultiple, + *args, **kwargs + ) + + def label_from_instance(self, obj): + return obj.name diff --git a/kfet/auth/forms.py b/kfet/auth/forms.py new file mode 100644 index 00000000..0c9fa53b --- /dev/null +++ b/kfet/auth/forms.py @@ -0,0 +1,44 @@ +from django import forms +from django.contrib.auth.models import Group, User + +from .fields import KFetPermissionsField + + +class GroupForm(forms.ModelForm): + permissions = KFetPermissionsField() + + def clean_name(self): + name = self.cleaned_data['name'] + return 'K-Fêt %s' % name + + def clean_permissions(self): + kfet_perms = self.cleaned_data['permissions'] + # TODO: With Django >=1.11, the QuerySet method 'difference' can be + # used. + # other_groups = self.instance.permissions.difference( + # self.fields['permissions'].queryset + # ) + other_perms = self.instance.permissions.exclude( + pk__in=[p.pk for p in self.fields['permissions'].queryset], + ) + return list(kfet_perms) + list(other_perms) + + class Meta: + model = Group + fields = ['name', 'permissions'] + + +class UserGroupForm(forms.ModelForm): + groups = forms.ModelMultipleChoiceField( + Group.objects.filter(name__icontains='K-Fêt'), + label='Statut équipe', + required=False) + + def clean_groups(self): + kfet_groups = self.cleaned_data.get('groups') + other_groups = self.instance.groups.exclude(name__icontains='K-Fêt') + return list(kfet_groups) + list(other_groups) + + class Meta: + model = User + fields = ['groups'] diff --git a/kfet/middleware.py b/kfet/auth/middleware.py similarity index 95% rename from kfet/middleware.py rename to kfet/auth/middleware.py index 9502d393..1a930c3b 100644 --- a/kfet/middleware.py +++ b/kfet/auth/middleware.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import User -from kfet.backends import KFetBackend +from .backends import KFetBackend class KFetAuthenticationMiddleware(object): diff --git a/kfet/auth/migrations/0001_initial.py b/kfet/auth/migrations/0001_initial.py new file mode 100644 index 00000000..30dfca70 --- /dev/null +++ b/kfet/auth/migrations/0001_initial.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0006_require_contenttypes_0002'), + ] + + operations = [ + migrations.CreateModel( + name='GenericTeamToken', + fields=[ + ('id', models.AutoField(verbose_name='ID', auto_created=True, serialize=False, primary_key=True)), + ('token', models.CharField(unique=True, max_length=50)), + ], + ), + ] diff --git a/kfet/auth/migrations/__init__.py b/kfet/auth/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kfet/auth/models.py b/kfet/auth/models.py new file mode 100644 index 00000000..53aef6c9 --- /dev/null +++ b/kfet/auth/models.py @@ -0,0 +1,5 @@ +from django.db import models + + +class GenericTeamToken(models.Model): + token = models.CharField(max_length=50, unique=True) diff --git a/kfet/templates/kfet/login_genericteam.html b/kfet/auth/templates/kfet/login_genericteam.html similarity index 100% rename from kfet/templates/kfet/login_genericteam.html rename to kfet/auth/templates/kfet/login_genericteam.html diff --git a/kfet/tests/test_forms.py b/kfet/auth/tests.py similarity index 100% rename from kfet/tests/test_forms.py rename to kfet/auth/tests.py diff --git a/kfet/auth/views.py b/kfet/auth/views.py new file mode 100644 index 00000000..ce44b007 --- /dev/null +++ b/kfet/auth/views.py @@ -0,0 +1,69 @@ +from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin +from django.contrib.auth import authenticate, login +from django.contrib.auth.decorators import permission_required +from django.contrib.auth.models import Group, User +from django.core.urlresolvers import reverse_lazy +from django.db.models import Prefetch +from django.shortcuts import render +from django.utils.crypto import get_random_string +from django.views.generic.edit import CreateView, UpdateView + +from django_cas_ng.views import logout as cas_logout_view + +from kfet.decorators import teamkfet_required + +from .forms import GroupForm +from .models import GenericTeamToken + + +@teamkfet_required +def login_genericteam(request): + # Check si besoin de déconnecter l'utilisateur de CAS + cas_logout = None + if request.user.profile.login_clipper: + # Récupèration de la vue de déconnexion de CAS + # Ici, car request sera modifié après + next_page = request.META.get('HTTP_REFERER', None) + cas_logout = cas_logout_view(request, next_page=next_page) + + # Authentification du compte générique + token = GenericTeamToken.objects.create(token=get_random_string(50)) + user = authenticate(username="kfet_genericteam", token=token.token) + login(request, user) + + messages.success(request, "Connecté en utilisateur partagé") + + return cas_logout or render(request, "kfet/login_genericteam.html") + + +@permission_required('kfet.manage_perms') +def account_group(request): + user_pre = Prefetch( + 'user_set', + queryset=User.objects.select_related('profile__account_kfet'), + ) + groups = ( + Group.objects + .filter(name__icontains='K-Fêt') + .prefetch_related('permissions', user_pre) + ) + return render(request, 'kfet/account_group.html', { + 'groups': groups, + }) + + +class AccountGroupCreate(SuccessMessageMixin, CreateView): + model = Group + template_name = 'kfet/account_group_form.html' + form_class = GroupForm + success_message = 'Nouveau groupe : %(name)s' + success_url = reverse_lazy('kfet.account.group') + + +class AccountGroupUpdate(SuccessMessageMixin, UpdateView): + queryset = Group.objects.filter(name__icontains='K-Fêt') + template_name = 'kfet/account_group_form.html' + form_class = GroupForm + success_message = 'Groupe modifié : %(name)s' + success_url = reverse_lazy('kfet.account.group') diff --git a/kfet/context_processors.py b/kfet/context_processors.py index 4c7b4fe4..04feec81 100644 --- a/kfet/context_processors.py +++ b/kfet/context_processors.py @@ -1,18 +1,7 @@ # -*- coding: utf-8 -*- -from django.contrib.auth.context_processors import PermWrapper - from kfet.config import kfet_config -def auth(request): - if hasattr(request, 'real_user'): - return { - 'user': request.real_user, - 'perms': PermWrapper(request.real_user), - } - return {} - - def config(request): return {'kfet_config': kfet_config} diff --git a/kfet/forms.py b/kfet/forms.py index 6ef3aefb..66638e6c 100644 --- a/kfet/forms.py +++ b/kfet/forms.py @@ -5,9 +5,8 @@ from decimal import Decimal from django import forms from django.core.exceptions import ValidationError -from django.contrib.auth.models import User, Group, Permission -from django.contrib.contenttypes.models import ContentType -from django.forms import modelformset_factory, widgets +from django.contrib.auth.models import User +from django.forms import modelformset_factory from django.utils import timezone from djconfig.forms import ConfigForm @@ -18,6 +17,8 @@ from kfet.models import ( TransferGroup, Supplier) from gestioncof.models import CofProfile +from .auth.forms import UserGroupForm # noqa + # ----- # Widgets @@ -128,60 +129,6 @@ class UserRestrictTeamForm(UserForm): fields = ['first_name', 'last_name', 'email'] -class UserGroupForm(forms.ModelForm): - groups = forms.ModelMultipleChoiceField( - Group.objects.filter(name__icontains='K-Fêt'), - label='Statut équipe', - required=False) - - def clean_groups(self): - kfet_groups = self.cleaned_data.get('groups') - other_groups = self.instance.groups.exclude(name__icontains='K-Fêt') - return list(kfet_groups) + list(other_groups) - - class Meta: - model = User - fields = ['groups'] - - -class KFetPermissionsField(forms.ModelMultipleChoiceField): - - def __init__(self, *args, **kwargs): - queryset = Permission.objects.filter( - content_type__in=ContentType.objects.filter(app_label="kfet"), - ) - super().__init__( - queryset=queryset, - widget=widgets.CheckboxSelectMultiple, - *args, **kwargs - ) - - def label_from_instance(self, obj): - return obj.name - - -class GroupForm(forms.ModelForm): - permissions = KFetPermissionsField() - - def clean_name(self): - name = self.cleaned_data['name'] - return 'K-Fêt %s' % name - - def clean_permissions(self): - kfet_perms = self.cleaned_data['permissions'] - # TODO: With Django >=1.11, the QuerySet method 'difference' can be used. - # other_groups = self.instance.permissions.difference( - # self.fields['permissions'].queryset - # ) - other_perms = self.instance.permissions.exclude( - pk__in=[p.pk for p in self.fields['permissions'].queryset], - ) - return list(kfet_perms) + list(other_perms) - - class Meta: - model = Group - fields = ['name', 'permissions'] - class AccountNegativeForm(forms.ModelForm): class Meta: diff --git a/kfet/migrations/0058_delete_genericteamtoken.py b/kfet/migrations/0058_delete_genericteamtoken.py new file mode 100644 index 00000000..ea8b55cd --- /dev/null +++ b/kfet/migrations/0058_delete_genericteamtoken.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0057_merge'), + ] + + operations = [ + migrations.DeleteModel( + name='GenericTeamToken', + ), + ] diff --git a/kfet/models.py b/kfet/models.py index ec146ad9..b06114d7 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -14,6 +14,8 @@ from datetime import date import re import hashlib +from .auth.models import GenericTeamToken # noqa + from .config import kfet_config from .utils import to_ukf @@ -710,7 +712,3 @@ class Operation(models.Model): return templates[self.type].format(nb=self.article_nb, article=self.article, amount=self.amount) - - -class GenericTeamToken(models.Model): - token = models.CharField(max_length = 50, unique = True) diff --git a/kfet/views.py b/kfet/views.py index c7eb677b..386eddb6 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -12,34 +12,30 @@ from django.views.generic.edit import CreateView, UpdateView from django.core.urlresolvers import reverse, reverse_lazy from django.contrib import messages from django.contrib.messages.views import SuccessMessageMixin -from django.contrib.auth import authenticate, login -from django.contrib.auth.decorators import login_required, permission_required -from django.contrib.auth.models import User, Permission, Group +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User, Permission from django.http import JsonResponse, Http404 from django.forms import formset_factory from django.db import transaction from django.db.models import F, Sum, Prefetch, Count from django.db.models.functions import Coalesce from django.utils import timezone -from django.utils.crypto import get_random_string from django.utils.decorators import method_decorator -from django_cas_ng.views import logout as cas_logout_view - from gestioncof.models import CofProfile from kfet.config import kfet_config from kfet.decorators import teamkfet_required from kfet.models import ( Account, Checkout, Article, AccountNegative, - CheckoutStatement, GenericTeamToken, Supplier, SupplierArticle, Inventory, + CheckoutStatement, Supplier, SupplierArticle, Inventory, InventoryArticle, Order, OrderArticle, Operation, OperationGroup, TransferGroup, Transfer, ArticleCategory) from kfet.forms import ( AccountTriForm, AccountBalanceForm, AccountNoTriForm, UserForm, CofForm, UserRestrictTeamForm, UserGroupForm, AccountForm, CofRestrictForm, AccountPwdForm, AccountNegativeForm, UserRestrictForm, AccountRestrictForm, - GroupForm, CheckoutForm, CheckoutRestrictForm, CheckoutStatementCreateForm, + CheckoutForm, CheckoutRestrictForm, CheckoutStatementCreateForm, CheckoutStatementUpdateForm, ArticleForm, ArticleRestrictForm, KPsulOperationGroupForm, KPsulAccountForm, KPsulCheckoutForm, KPsulOperationFormSet, AddcostForm, FilterHistoryForm, @@ -54,25 +50,9 @@ import heapq import statistics from kfet.statistic import ScaleMixin, last_stats_manifest, tot_ventes, WeekScale - -@teamkfet_required -def login_genericteam(request): - # Check si besoin de déconnecter l'utilisateur de CAS - cas_logout = None - if request.user.profile.login_clipper: - # Récupèration de la vue de déconnexion de CAS - # Ici, car request sera modifié après - next_page = request.META.get('HTTP_REFERER', None) - cas_logout = cas_logout_view(request, next_page=next_page) - - # Authentification du compte générique - token = GenericTeamToken.objects.create(token=get_random_string(50)) - user = authenticate(username="kfet_genericteam", token=token.token) - login(request, user) - - messages.success(request, "Connecté en utilisateur partagé") - - return cas_logout or render(request, "kfet/login_genericteam.html") +from .auth.views import ( # noqa + account_group, login_genericteam, AccountGroupCreate, AccountGroupUpdate, +) def put_cleaned_data_in_dict(dict, form): @@ -505,37 +485,6 @@ def account_update(request, trigramme): }) -@permission_required('kfet.manage_perms') -def account_group(request): - user_pre = Prefetch( - 'user_set', - queryset=User.objects.select_related('profile__account_kfet'), - ) - groups = ( - Group.objects - .filter(name__icontains='K-Fêt') - .prefetch_related('permissions', user_pre) - ) - return render(request, 'kfet/account_group.html', { - 'groups': groups, - }) - - -class AccountGroupCreate(SuccessMessageMixin, CreateView): - model = Group - template_name = 'kfet/account_group_form.html' - form_class = GroupForm - success_message = 'Nouveau groupe : %(name)s' - success_url = reverse_lazy('kfet.account.group') - -class AccountGroupUpdate(SuccessMessageMixin, UpdateView): - queryset = Group.objects.filter(name__icontains='K-Fêt') - template_name = 'kfet/account_group_form.html' - form_class = GroupForm - success_message = 'Groupe modifié : %(name)s' - success_url = reverse_lazy('kfet.account.group') - - class AccountNegativeList(ListView): queryset = ( AccountNegative.objects From 4091185a684a6d01d15f55fa452f3fa39d8ba8e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Wed, 20 Sep 2017 18:19:15 +0200 Subject: [PATCH 2/9] import LDAP_SERVER_URL in settings --- cof/settings/common.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cof/settings/common.py b/cof/settings/common.py index ba0b6044..799ecc52 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -45,6 +45,7 @@ RECAPTCHA_PUBLIC_KEY = import_secret("RECAPTCHA_PUBLIC_KEY") RECAPTCHA_PRIVATE_KEY = import_secret("RECAPTCHA_PRIVATE_KEY") KFETOPEN_TOKEN = import_secret("KFETOPEN_TOKEN") +LDAP_SERVER_URL = import_secret("LDAP_SERVER_URL") BASE_DIR = os.path.dirname( From d89ba1efe5fd7878bf4883481e5bbf270f6ecb45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Wed, 20 Sep 2017 18:21:59 +0200 Subject: [PATCH 3/9] Fix catalogue behaviour if id=0 --- bda/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bda/views.py b/bda/views.py index d6da2e9f..84b6c9d3 100644 --- a/bda/views.py +++ b/bda/views.py @@ -782,9 +782,9 @@ def catalogue(request, request_type): .select_related('location') .prefetch_related('quote_set') ) - if categories_id: + if categories_id and 0 not in categories_id: shows_qs = shows_qs.filter(category__id__in=categories_id) - if locations_id: + if locations_id and 0 not in locations_id: shows_qs = shows_qs.filter(location__id__in=locations_id) # On convertit les descriptions à envoyer en une liste facilement From 6f2652c4858b3bc2e20093422388659c38bde02f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Wed, 20 Sep 2017 18:23:36 +0200 Subject: [PATCH 4/9] Prod quick hack for Mega export --- gestioncof/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gestioncof/views.py b/gestioncof/views.py index c5701510..ec9f6efd 100644 --- a/gestioncof/views.py +++ b/gestioncof/views.py @@ -577,7 +577,7 @@ def export_members(request): writer = unicodecsv.writer(response) for profile in CofProfile.objects.filter(is_cof=True).all(): user = profile.user - bits = [profile.id, user.username, user.first_name, user.last_name, + bits = [user.id, user.username, user.first_name, user.last_name, user.email, profile.phone, profile.occupation, profile.departement, profile.type_cotiz] writer.writerow([str(bit) for bit in bits]) @@ -596,7 +596,7 @@ def csv_export_mega(filename, qs): comments = "---".join( [comment.content for comment in reg.comments.all()]) bits = [user.username, user.first_name, user.last_name, user.email, - profile.phone, profile.id, + profile.phone, user.id, profile.comments if profile.comments else "", comments] writer.writerow([str(bit) for bit in bits]) From 1d19d1797c0e3949890c317964eae367e3bb4dc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Thu, 21 Sep 2017 23:39:27 +0200 Subject: [PATCH 5/9] Clean setup/retrieve of kfet generic account --- kfet/auth/__init__.py | 3 ++ kfet/auth/apps.py | 5 +++ kfet/auth/backends.py | 20 ++++-------- kfet/auth/migrations/0001_initial.py | 3 ++ kfet/auth/tests.py | 16 +++++++++ kfet/auth/utils.py | 28 ++++++++++++++++ kfet/migrations/0059_create_generic.py | 45 ++++++++++++++++++++++++++ kfet/models.py | 7 ++++ 8 files changed, 113 insertions(+), 14 deletions(-) create mode 100644 kfet/auth/utils.py create mode 100644 kfet/migrations/0059_create_generic.py diff --git a/kfet/auth/__init__.py b/kfet/auth/__init__.py index 63392684..00926030 100644 --- a/kfet/auth/__init__.py +++ b/kfet/auth/__init__.py @@ -1 +1,4 @@ default_app_config = 'kfet.auth.apps.KFetAuthConfig' + +KFET_GENERIC_USERNAME = 'kfet_genericteam' +KFET_GENERIC_TRIGRAMME = 'GNR' diff --git a/kfet/auth/apps.py b/kfet/auth/apps.py index ab791d18..03742843 100644 --- a/kfet/auth/apps.py +++ b/kfet/auth/apps.py @@ -1,4 +1,5 @@ from django.apps import AppConfig +from django.db.models.signals import post_migrate from django.utils.translation import ugettext_lazy as _ @@ -6,3 +7,7 @@ class KFetAuthConfig(AppConfig): name = 'kfet.auth' label = 'kfetauth' verbose_name = _("K-Fêt - Authentification et Autorisation") + + def ready(self): + from .utils import setup_kfet_generic_user + post_migrate.connect(setup_kfet_generic_user, sender=self) diff --git a/kfet/auth/backends.py b/kfet/auth/backends.py index fb9538d0..c972fb55 100644 --- a/kfet/auth/backends.py +++ b/kfet/auth/backends.py @@ -2,10 +2,13 @@ import hashlib -from django.contrib.auth.models import User, Permission -from gestioncof.models import CofProfile +from django.contrib.auth import get_user_model from kfet.models import Account, GenericTeamToken +from .utils import get_kfet_generic_user + +User = get_user_model() + class KFetBackend(object): def authenticate(self, request): @@ -29,18 +32,7 @@ class GenericTeamBackend(object): def authenticate(self, username=None, token=None): valid_token = GenericTeamToken.objects.get(token=token) if username == 'kfet_genericteam' and valid_token: - # Création du user s'il n'existe pas déjà - user, _ = User.objects.get_or_create(username='kfet_genericteam') - profile, _ = CofProfile.objects.get_or_create(user=user) - account, _ = Account.objects.get_or_create( - cofprofile=profile, - trigramme='GNR') - - # Ajoute la permission kfet.is_team à ce user - perm_is_team = Permission.objects.get(codename='is_team') - user.user_permissions.add(perm_is_team) - - return user + return get_kfet_generic_user() return None def get_user(self, user_id): diff --git a/kfet/auth/migrations/0001_initial.py b/kfet/auth/migrations/0001_initial.py index 30dfca70..061570a8 100644 --- a/kfet/auth/migrations/0001_initial.py +++ b/kfet/auth/migrations/0001_initial.py @@ -8,6 +8,9 @@ class Migration(migrations.Migration): dependencies = [ ('auth', '0006_require_contenttypes_0002'), + # Following dependency allows using Account model to set up the kfet + # generic user in post_migrate receiver. + ('kfet', '0058_delete_genericteamtoken'), ] operations = [ diff --git a/kfet/auth/tests.py b/kfet/auth/tests.py index 7f129a3f..47cc2376 100644 --- a/kfet/auth/tests.py +++ b/kfet/auth/tests.py @@ -4,6 +4,10 @@ from django.test import TestCase from django.contrib.auth.models import User, Group from kfet.forms import UserGroupForm +from kfet.models import Account + +from . import KFET_GENERIC_TRIGRAMME, KFET_GENERIC_USERNAME +from .utils import get_kfet_generic_user class UserGroupFormTests(TestCase): @@ -54,3 +58,15 @@ class UserGroupFormTests(TestCase): [repr(g) for g in [self.other_group] + self.kfet_groups], ordered=False, ) + + +class KFetGenericUserTests(TestCase): + + def test_exists(self): + """ + The account is set up when app is ready, so it should exist. + """ + generic = Account.objects.get_generic() + self.assertEqual(generic.trigramme, KFET_GENERIC_TRIGRAMME) + self.assertEqual(generic.user.username, KFET_GENERIC_USERNAME) + self.assertEqual(get_kfet_generic_user(), generic.user) diff --git a/kfet/auth/utils.py b/kfet/auth/utils.py new file mode 100644 index 00000000..78f31028 --- /dev/null +++ b/kfet/auth/utils.py @@ -0,0 +1,28 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission + +from kfet.models import Account + +User = get_user_model() + + +def get_kfet_generic_user(): + """ + Return the user related to the kfet generic account. + """ + return Account.objects.get_generic().user + + +def setup_kfet_generic_user(**kwargs): + """ + First steps of setup of the kfet generic user are done in a migration, as + it is more robust against database schema changes. + Following steps cannot be done from migration. + """ + generic = get_kfet_generic_user() + generic.user_permissions.add( + Permission.objects.get( + content_type__app_label='kfet', + codename='is_team', + ) + ) diff --git a/kfet/migrations/0059_create_generic.py b/kfet/migrations/0059_create_generic.py new file mode 100644 index 00000000..4f04770c --- /dev/null +++ b/kfet/migrations/0059_create_generic.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + +from kfet.auth import KFET_GENERIC_TRIGRAMME, KFET_GENERIC_USERNAME + + +def setup_kfet_generic_user(apps, schema_editor): + """ + Setup models instances for the kfet generic account. + + Username and trigramme are retrieved from kfet.auth.__init__ module. + Other data are registered here. + + See also setup_kfet_generic_user from kfet.auth.utils module. + """ + User = apps.get_model('auth', 'User') + CofProfile = apps.get_model('gestioncof', 'CofProfile') + Account = apps.get_model('kfet', 'Account') + + user, _ = User.objects.update_or_create( + username=KFET_GENERIC_USERNAME, + defaults={ + 'first_name': 'Compte générique K-Fêt', + }, + ) + profile, _ = CofProfile.objects.update_or_create(user=user) + account, _ = Account.objects.update_or_create( + cofprofile=profile, + defaults={ + 'trigramme': KFET_GENERIC_TRIGRAMME, + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('kfet', '0058_delete_genericteamtoken'), + ] + + operations = [ + migrations.RunPython(setup_kfet_generic_user), + ] diff --git a/kfet/models.py b/kfet/models.py index b06114d7..9aefb782 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -14,6 +14,7 @@ from datetime import date import re import hashlib +from .auth import KFET_GENERIC_TRIGRAMME from .auth.models import GenericTeamToken # noqa from .config import kfet_config @@ -35,6 +36,12 @@ class AccountManager(models.Manager): return super().get_queryset().select_related('cofprofile__user', 'negative') + def get_generic(self): + """ + Get the kfet generic account instance. + """ + return self.get(trigramme=KFET_GENERIC_TRIGRAMME) + class Account(models.Model): objects = AccountManager() From e5d19811e859ba366fe595bbbf345b97df83bb60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Fri, 22 Sep 2017 23:31:46 +0200 Subject: [PATCH 6/9] Clean code related to kfet password --- kfet/auth/backends.py | 10 +--------- kfet/auth/utils.py | 6 ++++++ kfet/models.py | 19 ++++++++++++++----- kfet/tests/test_models.py | 25 +++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 14 deletions(-) create mode 100644 kfet/tests/test_models.py diff --git a/kfet/auth/backends.py b/kfet/auth/backends.py index c972fb55..1c9290d6 100644 --- a/kfet/auth/backends.py +++ b/kfet/auth/backends.py @@ -1,7 +1,4 @@ # -*- coding: utf-8 -*- - -import hashlib - from django.contrib.auth import get_user_model from kfet.models import Account, GenericTeamToken @@ -18,12 +15,7 @@ class KFetBackend(object): return None try: - password_sha256 = ( - hashlib.sha256(password.encode('utf-8')) - .hexdigest() - ) - account = Account.objects.get(password=password_sha256) - return account.cofprofile.user + return Account.objects.get_by_password(password).user except Account.DoesNotExist: return None diff --git a/kfet/auth/utils.py b/kfet/auth/utils.py index 78f31028..0edc555d 100644 --- a/kfet/auth/utils.py +++ b/kfet/auth/utils.py @@ -1,3 +1,5 @@ +import hashlib + from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission @@ -26,3 +28,7 @@ def setup_kfet_generic_user(**kwargs): codename='is_team', ) ) + + +def hash_password(password): + return hashlib.sha256(password.encode('utf-8')).hexdigest() diff --git a/kfet/models.py b/kfet/models.py index 9aefb782..e547d248 100644 --- a/kfet/models.py +++ b/kfet/models.py @@ -12,7 +12,6 @@ from django.db import transaction from django.db.models import F from datetime import date import re -import hashlib from .auth import KFET_GENERIC_TRIGRAMME from .auth.models import GenericTeamToken # noqa @@ -42,6 +41,17 @@ class AccountManager(models.Manager): """ return self.get(trigramme=KFET_GENERIC_TRIGRAMME) + def get_by_password(self, password): + """ + Get a kfet generic account by clear password. + + Raises Account.DoesNotExist if no Account has this password. + """ + from .auth.utils import hash_password + if password is None: + raise self.model.DoesNotExist + return self.get(password=hash_password(password)) + class Account(models.Model): objects = AccountManager() @@ -245,10 +255,9 @@ class Account(models.Model): self.cofprofile = cof super(Account, self).save(*args, **kwargs) - def change_pwd(self, pwd): - pwd_sha256 = hashlib.sha256(pwd.encode('utf-8'))\ - .hexdigest() - self.password = pwd_sha256 + def change_pwd(self, clear_password): + from .auth.utils import hash_password + self.password = hash_password(clear_password) # Surcharge de delete # Pas de suppression possible diff --git a/kfet/tests/test_models.py b/kfet/tests/test_models.py new file mode 100644 index 00000000..ea132acd --- /dev/null +++ b/kfet/tests/test_models.py @@ -0,0 +1,25 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from kfet.models import Account + +User = get_user_model() + + +class AccountTests(TestCase): + + def setUp(self): + self.account = Account(trigramme='000') + self.account.save({'username': 'user'}) + + def test_password(self): + self.account.change_pwd('anna') + self.account.save() + + self.assertEqual(Account.objects.get_by_password('anna'), self.account) + + with self.assertRaises(Account.DoesNotExist): + Account.objects.get_by_password(None) + + with self.assertRaises(Account.DoesNotExist): + Account.objects.get_by_password('bernard') From 3fa7754ff4d31e300e9241d8a2122340a3a8de89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Sat, 23 Sep 2017 20:48:28 +0200 Subject: [PATCH 7/9] KFet Backends inherit from BaseKFetBackend Users who authenticate via a KFetBackend got extra select related. It should save 2 db queries on each request for these users. --- kfet/auth/backends.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/kfet/auth/backends.py b/kfet/auth/backends.py index 1c9290d6..b2f1cb03 100644 --- a/kfet/auth/backends.py +++ b/kfet/auth/backends.py @@ -7,7 +7,22 @@ from .utils import get_kfet_generic_user User = get_user_model() -class KFetBackend(object): +class BaseKFetBackend: + def get_user(self, user_id): + """ + Add extra select related up to Account. + """ + try: + return ( + User.objects + .select_related('profile__account_kfet') + .get(pk=user_id) + ) + except User.DoesNotExist: + return None + + +class KFetBackend(BaseKFetBackend): def authenticate(self, request): password = request.POST.get('KFETPASSWORD', '') password = request.META.get('HTTP_KFETPASSWORD', password) @@ -20,7 +35,7 @@ class KFetBackend(object): return None -class GenericTeamBackend(object): +class GenericTeamBackend(BaseKFetBackend): def authenticate(self, username=None, token=None): valid_token = GenericTeamToken.objects.get(token=token) if username == 'kfet_genericteam' and valid_token: From b42452080f36d2a669b19c5c93ea9194a2703afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Delobelle?= Date: Mon, 25 Sep 2017 17:16:19 +0200 Subject: [PATCH 8/9] Mass cleaning of kfet' authentication machinery AccountBackend - Should now work if used in AUTHENTICATION_BACKENDS settings. - It does not retieve itself the password, as it should not be used this way. GenericBackend - Delete useless 'username' arg of its 'authenticate()' method. - Now delete the token in DB. TemporaryAuthMiddleware - New name of the middleware is more meaningful. - Is now responsible to retrieve the password from the request, instead of the AccountBackend. GenericTeamToken model - Add a manager' method to create token, avoiding possible error due to unicity constraint. GenericLoginView (authentication with the kfet generic user) - Replace obscure system with a 100% HTTP handling. - See comments for more information. Misc - More docstrings! - More tests! - Add some i18n. - Add kfet/confirm_form.html template: Ask user to confirm sth via a form (which will send a POST request). Context variables: * title: the page title * confirm_url: action attribute for
* text: displayed confirmation text - kfet.js : Add functions allowing to emit POST request from tag. - Non-link nav items from kfet navbar also get a 'title'. - A utility has been found for the 'sunglasses' glyphicon! --- cof/settings/common.py | 6 +- kfet/apps.py | 1 - kfet/auth/apps.py | 1 + kfet/auth/backends.py | 36 +-- kfet/auth/context_processors.py | 2 +- kfet/auth/middleware.py | 25 +- kfet/auth/models.py | 12 + kfet/auth/signals.py | 40 +++ .../templates/kfet/login_genericteam.html | 7 - kfet/auth/tests.py | 299 +++++++++++++++++- kfet/auth/views.py | 111 +++++-- kfet/signals.py | 21 -- kfet/static/kfet/js/kfet.js | 66 +++- kfet/templates/kfet/base_nav.html | 21 +- kfet/templates/kfet/confirm_form.html | 20 ++ kfet/templates/kfet/nav_item.html | 4 + kfet/urls.py | 4 +- kfet/views.py | 2 +- 18 files changed, 559 insertions(+), 119 deletions(-) create mode 100644 kfet/auth/signals.py delete mode 100644 kfet/auth/templates/kfet/login_genericteam.html delete mode 100644 kfet/signals.py create mode 100644 kfet/templates/kfet/confirm_form.html diff --git a/cof/settings/common.py b/cof/settings/common.py index 92759d21..0437f5db 100644 --- a/cof/settings/common.py +++ b/cof/settings/common.py @@ -100,7 +100,7 @@ MIDDLEWARE_CLASSES = [ 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', - 'kfet.auth.middleware.KFetAuthenticationMiddleware', + 'kfet.auth.middleware.TemporaryAuthMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.security.SecurityMiddleware', @@ -128,7 +128,7 @@ TEMPLATES = [ 'wagtailmenus.context_processors.wagtailmenus', 'djconfig.context_processors.config', 'gestioncof.shared.context_processor', - 'kfet.auth.context_processors.auth', + 'kfet.auth.context_processors.temporary_auth', 'kfet.context_processors.config', ], }, @@ -191,7 +191,7 @@ CAS_EMAIL_FORMAT = "%s@clipper.ens.fr" AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', 'gestioncof.shared.COFCASBackend', - 'kfet.auth.backends.GenericTeamBackend', + 'kfet.auth.backends.GenericBackend', ) RECAPTCHA_USE_SSL = True diff --git a/kfet/apps.py b/kfet/apps.py index 3dd2c0e8..4f114c37 100644 --- a/kfet/apps.py +++ b/kfet/apps.py @@ -11,7 +11,6 @@ class KFetConfig(AppConfig): verbose_name = "Application K-Fêt" def ready(self): - import kfet.signals self.register_config() def register_config(self): diff --git a/kfet/auth/apps.py b/kfet/auth/apps.py index 03742843..d91931f5 100644 --- a/kfet/auth/apps.py +++ b/kfet/auth/apps.py @@ -9,5 +9,6 @@ class KFetAuthConfig(AppConfig): verbose_name = _("K-Fêt - Authentification et Autorisation") def ready(self): + from . import signals # noqa from .utils import setup_kfet_generic_user post_migrate.connect(setup_kfet_generic_user, sender=self) diff --git a/kfet/auth/backends.py b/kfet/auth/backends.py index b2f1cb03..c6ad21b2 100644 --- a/kfet/auth/backends.py +++ b/kfet/auth/backends.py @@ -22,32 +22,22 @@ class BaseKFetBackend: return None -class KFetBackend(BaseKFetBackend): - def authenticate(self, request): - password = request.POST.get('KFETPASSWORD', '') - password = request.META.get('HTTP_KFETPASSWORD', password) - if not password: - return None - +class AccountBackend(BaseKFetBackend): + def authenticate(self, request, kfet_password=None): try: - return Account.objects.get_by_password(password).user + return Account.objects.get_by_password(kfet_password).user except Account.DoesNotExist: return None -class GenericTeamBackend(BaseKFetBackend): - def authenticate(self, username=None, token=None): - valid_token = GenericTeamToken.objects.get(token=token) - if username == 'kfet_genericteam' and valid_token: - return get_kfet_generic_user() - return None - - def get_user(self, user_id): +class GenericBackend(BaseKFetBackend): + def authenticate(self, request, kfet_token=None): try: - return ( - User.objects - .select_related('profile__account_kfet') - .get(pk=user_id) - ) - except User.DoesNotExist: - return None + team_token = GenericTeamToken.objects.get(token=kfet_token) + except GenericTeamToken.DoesNotExist: + return + + # No need to keep the token. + team_token.delete() + + return get_kfet_generic_user() diff --git a/kfet/auth/context_processors.py b/kfet/auth/context_processors.py index 07c9537f..7b59b88b 100644 --- a/kfet/auth/context_processors.py +++ b/kfet/auth/context_processors.py @@ -1,7 +1,7 @@ from django.contrib.auth.context_processors import PermWrapper -def auth(request): +def temporary_auth(request): if hasattr(request, 'real_user'): return { 'user': request.real_user, diff --git a/kfet/auth/middleware.py b/kfet/auth/middleware.py index 1a930c3b..748ce4dd 100644 --- a/kfet/auth/middleware.py +++ b/kfet/auth/middleware.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- +from django.contrib.auth import get_user_model -from django.contrib.auth.models import User +from .backends import AccountBackend -from .backends import KFetBackend +User = get_user_model() -class KFetAuthenticationMiddleware(object): - """Authenticate another user for this request if KFetBackend succeeds. +class TemporaryAuthMiddleware: + """Authenticate another user for this request if AccountBackend succeeds. By the way, if a user is authenticated, we refresh its from db to add values from CofProfile and Account of this user. @@ -15,15 +16,23 @@ class KFetAuthenticationMiddleware(object): def process_request(self, request): if request.user.is_authenticated(): # avoid multiple db accesses in views and templates - user_pk = request.user.pk request.user = ( User.objects .select_related('profile__account_kfet') - .get(pk=user_pk) + .get(pk=request.user.pk) ) - kfet_backend = KFetBackend() - temp_request_user = kfet_backend.authenticate(request) + temp_request_user = AccountBackend().authenticate( + request, + kfet_password=self.get_kfet_password(request), + ) + if temp_request_user: request.real_user = request.user request.user = temp_request_user + + def get_kfet_password(self, request): + return ( + request.META.get('HTTP_KFETPASSWORD') or + request.POST.get('KFETPASSWORD') + ) diff --git a/kfet/auth/models.py b/kfet/auth/models.py index 53aef6c9..ecd40091 100644 --- a/kfet/auth/models.py +++ b/kfet/auth/models.py @@ -1,5 +1,17 @@ from django.db import models +from django.utils.crypto import get_random_string + + +class GenericTeamTokenManager(models.Manager): + + def create_token(self): + token = get_random_string(50) + while self.filter(token=token).exists(): + token = get_random_string(50) + return self.create(token=token) class GenericTeamToken(models.Model): token = models.CharField(max_length=50, unique=True) + + objects = GenericTeamTokenManager() diff --git a/kfet/auth/signals.py b/kfet/auth/signals.py new file mode 100644 index 00000000..3d7af18b --- /dev/null +++ b/kfet/auth/signals.py @@ -0,0 +1,40 @@ +from django.contrib import messages +from django.contrib.auth.signals import user_logged_in +from django.core.urlresolvers import reverse +from django.dispatch import receiver +from django.utils.safestring import mark_safe +from django.utils.translation import ugettext as _ + +from .utils import get_kfet_generic_user + + +@receiver(user_logged_in) +def suggest_auth_generic(sender, request, user, **kwargs): + """ + Suggest logged in user to continue as the kfet generic user. + + Message is only added if the following conditions are met: + - the next page (where user is going to be redirected due to successful + authentication) is related to kfet, i.e. 'k-fet' is in its url. + - logged in user is a kfet staff member (except the generic user). + """ + # Filter against the next page. + if not(hasattr(request, 'GET') and 'next' in request.GET): + return + + next_page = request.GET['next'] + generic_url = reverse('kfet.login.generic') + + if not('k-fet' in next_page and not next_page.startswith(generic_url)): + return + + # Filter against the logged in user. + if not(user.has_perm('kfet.is_team') and user != get_kfet_generic_user()): + return + + # Seems legit to add message. + text = _("K-Fêt — Ouvrir une session partagée ?") + messages.info(request, mark_safe( + '{}' + .format(generic_url, text) + )) diff --git a/kfet/auth/templates/kfet/login_genericteam.html b/kfet/auth/templates/kfet/login_genericteam.html deleted file mode 100644 index d2f8eca0..00000000 --- a/kfet/auth/templates/kfet/login_genericteam.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends 'kfet/base.html' %} - -{% block extra_head %} - -{% endblock %} diff --git a/kfet/auth/tests.py b/kfet/auth/tests.py index 47cc2376..c2f183cd 100644 --- a/kfet/auth/tests.py +++ b/kfet/auth/tests.py @@ -1,15 +1,26 @@ # -*- coding: utf-8 -*- +from unittest import mock -from django.test import TestCase -from django.contrib.auth.models import User, Group +from django.core import signing +from django.core.urlresolvers import reverse +from django.contrib.auth.models import AnonymousUser, Group, Permission, User +from django.test import RequestFactory, TestCase from kfet.forms import UserGroupForm from kfet.models import Account from . import KFET_GENERIC_TRIGRAMME, KFET_GENERIC_USERNAME +from .backends import AccountBackend, GenericBackend +from .middleware import TemporaryAuthMiddleware +from .models import GenericTeamToken from .utils import get_kfet_generic_user +from .views import GenericLoginView +## +# Forms +## + class UserGroupFormTests(TestCase): """Test suite for UserGroupForm.""" @@ -70,3 +81,287 @@ class KFetGenericUserTests(TestCase): self.assertEqual(generic.trigramme, KFET_GENERIC_TRIGRAMME) self.assertEqual(generic.user.username, KFET_GENERIC_USERNAME) self.assertEqual(get_kfet_generic_user(), generic.user) + + +## +# Backends +## + +class AccountBackendTests(TestCase): + + def setUp(self): + self.request = RequestFactory().get('/') + + def test_valid(self): + acc = Account(trigramme='000') + acc.change_pwd('valid') + acc.save({'username': 'user'}) + + auth = AccountBackend().authenticate( + self.request, kfet_password='valid') + + self.assertEqual(auth, acc.user) + + def test_invalid(self): + auth = AccountBackend().authenticate( + self.request, kfet_password='invalid') + self.assertIsNone(auth) + + +class GenericBackendTests(TestCase): + + def setUp(self): + self.request = RequestFactory().get('/') + + def test_valid(self): + token = GenericTeamToken.objects.create_token() + + auth = GenericBackend().authenticate( + self.request, kfet_token=token.token) + + self.assertEqual(auth, get_kfet_generic_user()) + self.assertEqual(GenericTeamToken.objects.all().count(), 0) + + def test_invalid(self): + auth = GenericBackend().authenticate( + self.request, kfet_token='invalid') + self.assertIsNone(auth) + + +## +# Views +## + +class GenericLoginViewTests(TestCase): + + def setUp(self): + patcher_messages = mock.patch('gestioncof.signals.messages') + patcher_messages.start() + self.addCleanup(patcher_messages.stop) + + user_acc = Account(trigramme='000') + user_acc.save({'username': 'user'}) + self.user = user_acc.user + self.user.set_password('user') + self.user.save() + + team_acc = Account(trigramme='100') + team_acc.save({'username': 'team'}) + self.team = team_acc.user + self.team.set_password('team') + self.team.save() + self.team.user_permissions.add( + Permission.objects.get( + content_type__app_label='kfet', codename='is_team'), + ) + + self.url = reverse('kfet.login.generic') + self.generic_user = get_kfet_generic_user() + + def test_url(self): + self.assertEqual(self.url, '/k-fet/login/generic') + + def test_notoken_get(self): + """ + Send confirmation for user to emit POST request, instead of GET. + """ + self.client.login(username='team', password='team') + + r = self.client.get(self.url) + + self.assertEqual(r.status_code, 200) + self.assertTemplateUsed(r, 'kfet/confirm_form.html') + + def test_notoken_post(self): + """ + POST request without token in COOKIES sets a token and redirects to + logout url. + """ + self.client.login(username='team', password='team') + + r = self.client.post(self.url) + + self.assertRedirects( + r, '/logout?next={}'.format(self.url), + fetch_redirect_response=False, + ) + + def test_notoken_not_team(self): + """ + Logged in user must be a team user to initiate login as generic user. + """ + self.client.login(username='user', password='user') + + # With GET. + r = self.client.get(self.url) + self.assertRedirects( + r, '/login?next={}'.format(self.url), + fetch_redirect_response=False, + ) + + # Also with POST. + r = self.client.post(self.url) + self.assertRedirects( + r, '/login?next={}'.format(self.url), + fetch_redirect_response=False, + ) + + def _set_signed_cookie(self, client, key, value): + signed_value = signing.get_cookie_signer(salt=key).sign(value) + client.cookies.load({key: signed_value}) + + def _is_cookie_deleted(self, client, key): + try: + self.assertNotIn(key, client.cookies) + except AssertionError: + try: + cookie = client.cookies[key] + # It also can be emptied. + self.assertEqual(cookie.value, '') + self.assertEqual( + cookie['expires'], 'Thu, 01-Jan-1970 00:00:00 GMT') + self.assertEqual(cookie['max-age'], 0) + except AssertionError: + raise AssertionError("The cookie '%s' still exists." % key) + + def test_withtoken_valid(self): + """ + The kfet generic user is logged in. + """ + token = GenericTeamToken.objects.create(token='valid') + self._set_signed_cookie( + self.client, GenericLoginView.TOKEN_COOKIE_NAME, 'valid') + + r = self.client.get(self.url) + + self.assertRedirects(r, reverse('kfet.kpsul')) + self.assertEqual(r.wsgi_request.user, self.generic_user) + self._is_cookie_deleted( + self.client, GenericLoginView.TOKEN_COOKIE_NAME) + with self.assertRaises(GenericTeamToken.DoesNotExist): + token.refresh_from_db() + + def test_withtoken_invalid(self): + """ + If token is invalid, delete it and try again. + """ + self._set_signed_cookie( + self.client, GenericLoginView.TOKEN_COOKIE_NAME, 'invalid') + + r = self.client.get(self.url) + + self.assertRedirects(r, self.url, fetch_redirect_response=False) + self.assertEqual(r.wsgi_request.user, AnonymousUser()) + self._is_cookie_deleted( + self.client, GenericLoginView.TOKEN_COOKIE_NAME) + + def test_flow_ok(self): + """ + A team user is logged in as the kfet generic user. + """ + self.client.login(username='team', password='team') + next_url = '/k-fet/' + + r = self.client.post( + '{}?next={}'.format(self.url, next_url), follow=True) + + self.assertEqual(r.wsgi_request.user, self.generic_user) + self.assertEqual(r.wsgi_request.path, '/k-fet/') + + +## +# Temporary authentication +# +# Includes: +# - TemporaryAuthMiddleware +# - temporary_auth context processor +## + +class TemporaryAuthTests(TestCase): + + def setUp(self): + patcher_messages = mock.patch('gestioncof.signals.messages') + patcher_messages.start() + self.addCleanup(patcher_messages.stop) + + self.factory = RequestFactory() + + user1_acc = Account(trigramme='000') + user1_acc.change_pwd('kfet_user1') + user1_acc.save({'username': 'user1'}) + self.user1 = user1_acc.user + self.user1.set_password('user1') + self.user1.save() + + user2_acc = Account(trigramme='100') + user2_acc.change_pwd('kfet_user2') + user2_acc.save({'username': 'user2'}) + self.user2 = user2_acc.user + self.user2.set_password('user2') + self.user2.save() + + self.perm = Permission.objects.get( + content_type__app_label='kfet', codename='is_team') + self.user2.user_permissions.add(self.perm) + + def test_middleware_header(self): + """ + A user can be authenticated if ``HTTP_KFETPASSWORD`` header of a + request contains a valid kfet password. + """ + request = self.factory.get('/', HTTP_KFETPASSWORD='kfet_user2') + request.user = self.user1 + + TemporaryAuthMiddleware().process_request(request) + + self.assertEqual(request.user, self.user2) + self.assertEqual(request.real_user, self.user1) + + def test_middleware_post(self): + """ + A user can be authenticated if ``KFETPASSWORD`` of POST data contains + a valid kfet password. + """ + request = self.factory.post('/', {'KFETPASSWORD': 'kfet_user2'}) + request.user = self.user1 + + TemporaryAuthMiddleware().process_request(request) + + self.assertEqual(request.user, self.user2) + self.assertEqual(request.real_user, self.user1) + + def test_middleware_invalid(self): + """ + The given password must be a password of an Account. + """ + request = self.factory.post('/', {'KFETPASSWORD': 'invalid'}) + request.user = self.user1 + + TemporaryAuthMiddleware().process_request(request) + + self.assertEqual(request.user, self.user1) + self.assertFalse(hasattr(request, 'real_user')) + + def test_context_processor(self): + """ + Context variables give the real authenticated user and his permissions. + """ + self.client.login(username='user1', password='user1') + + r = self.client.get('/k-fet/accounts/', HTTP_KFETPASSWORD='kfet_user2') + + self.assertEqual(r.context['user'], self.user1) + self.assertNotIn('kfet.is_team', r.context['perms']) + + def test_auth_not_persistent(self): + """ + The authentication is temporary, i.e. for one request. + """ + self.client.login(username='user1', password='user1') + + r1 = self.client.get( + '/k-fet/accounts/', HTTP_KFETPASSWORD='kfet_user2') + self.assertEqual(r1.wsgi_request.user, self.user2) + + r2 = self.client.get('/k-fet/accounts/') + self.assertEqual(r2.wsgi_request.user, self.user1) diff --git a/kfet/auth/views.py b/kfet/auth/views.py index ce44b007..7b9f4099 100644 --- a/kfet/auth/views.py +++ b/kfet/auth/views.py @@ -3,38 +3,105 @@ from django.contrib.messages.views import SuccessMessageMixin from django.contrib.auth import authenticate, login from django.contrib.auth.decorators import permission_required from django.contrib.auth.models import Group, User -from django.core.urlresolvers import reverse_lazy +from django.contrib.auth.views import redirect_to_login +from django.core.urlresolvers import reverse, reverse_lazy from django.db.models import Prefetch -from django.shortcuts import render -from django.utils.crypto import get_random_string +from django.http import QueryDict +from django.shortcuts import redirect, render +from django.utils.decorators import method_decorator +from django.utils.translation import ugettext_lazy as _ +from django.views.generic import View +from django.views.decorators.http import require_http_methods from django.views.generic.edit import CreateView, UpdateView -from django_cas_ng.views import logout as cas_logout_view - -from kfet.decorators import teamkfet_required - from .forms import GroupForm from .models import GenericTeamToken -@teamkfet_required -def login_genericteam(request): - # Check si besoin de déconnecter l'utilisateur de CAS - cas_logout = None - if request.user.profile.login_clipper: - # Récupèration de la vue de déconnexion de CAS - # Ici, car request sera modifié après - next_page = request.META.get('HTTP_REFERER', None) - cas_logout = cas_logout_view(request, next_page=next_page) +class GenericLoginView(View): + """ + View to authenticate as kfet generic user. - # Authentification du compte générique - token = GenericTeamToken.objects.create(token=get_random_string(50)) - user = authenticate(username="kfet_genericteam", token=token.token) - login(request, user) + It is a 2-step view. First, issue a token if user is a team member and send + him to the logout view (for proper disconnect) with callback url to here. + Then authenticate the token to log in as the kfet generic user. - messages.success(request, "Connecté en utilisateur partagé") + Token is stored in COOKIES to avoid share it with the authentication + provider, which can be external. Session is unusable as it will be cleared + on logout. + """ + TOKEN_COOKIE_NAME = 'kfettoken' - return cas_logout or render(request, "kfet/login_genericteam.html") + @method_decorator(require_http_methods(['GET', 'POST'])) + def dispatch(self, request, *args, **kwargs): + token = request.get_signed_cookie(self.TOKEN_COOKIE_NAME, None) + if not token: + if not request.user.has_perm('kfet.is_team'): + return redirect_to_login(request.get_full_path()) + + if request.method == 'POST': + # Step 1: set token and logout user. + return self.prepare_auth() + else: + # GET request should not change server/client states. Send a + # confirmation template to emit a POST request. + return render(request, 'kfet/confirm_form.html', { + 'title': _("Ouvrir une session partagée"), + 'text': _( + "Êtes-vous sûr·e de vouloir ouvrir une session " + "partagée ?" + ), + }) + else: + # Step 2: validate token. + return self.validate_auth(token) + + def prepare_auth(self): + # Issue token. + token = GenericTeamToken.objects.create_token() + + # Prepare callback of logout. + here_url = reverse(login_generic) + if 'next' in self.request.GET: + # Keep given next page. + here_qd = QueryDict(mutable=True) + here_qd['next'] = self.request.GET['next'] + here_url += '?{}'.format(here_qd.urlencode()) + + logout_url = reverse('cof-logout') + logout_qd = QueryDict(mutable=True) + logout_qd['next'] = here_url + logout_url += '?{}'.format(logout_qd.urlencode(safe='/')) + + resp = redirect(logout_url) + resp.set_signed_cookie( + self.TOKEN_COOKIE_NAME, token.token, httponly=True) + return resp + + def validate_auth(self, token): + # Authenticate with GenericBackend. + user = authenticate(request=self.request, kfet_token=token) + + if user: + # Log in generic user. + login(self.request, user) + messages.success(self.request, _( + "K-Fêt — Ouverture d'une session partagée." + )) + resp = redirect(self.get_next_url()) + else: + # Try again. + resp = redirect(self.request.get_full_path()) + + # Prevents blocking due to an invalid COOKIE. + resp.delete_cookie(self.TOKEN_COOKIE_NAME) + return resp + + def get_next_url(self): + return self.request.GET.get('next', reverse('kfet.kpsul')) + + +login_generic = GenericLoginView.as_view() @permission_required('kfet.manage_perms') diff --git a/kfet/signals.py b/kfet/signals.py deleted file mode 100644 index c677ac9c..00000000 --- a/kfet/signals.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- - -from django.contrib import messages -from django.contrib.auth.signals import user_logged_in -from django.core.urlresolvers import reverse -from django.dispatch import receiver -from django.utils.safestring import mark_safe - - -@receiver(user_logged_in) -def messages_on_login(sender, request, user, **kwargs): - if (not user.username == 'kfet_genericteam' and - user.has_perm('kfet.is_team') and - hasattr(request, 'GET') and - 'k-fet' in request.GET.get('next', '')): - messages.info(request, mark_safe( - '' - ' Connexion en utilisateur partagé ?' - '' - .format(reverse('kfet.login.genericteam')) - )) diff --git a/kfet/static/kfet/js/kfet.js b/kfet/static/kfet/js/kfet.js index b34f2005..75b80b04 100644 --- a/kfet/static/kfet/js/kfet.js +++ b/kfet/static/kfet/js/kfet.js @@ -1,22 +1,33 @@ -$(document).ready(function() { - if (typeof Cookies !== 'undefined') { - // Retrieving csrf token - csrftoken = Cookies.get('csrftoken'); - // Appending csrf token to ajax post requests - function csrfSafeMethod(method) { - // these HTTP methods do not require CSRF protection - return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); +/** + * CSRF Token + */ + +var csrftoken = ''; +if (typeof Cookies !== 'undefined') + csrftoken = Cookies.get('csrftoken'); + +// Add CSRF token in header of AJAX requests. + +function csrfSafeMethod(method) { + // these HTTP methods do not require CSRF protection + return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); +} + +$.ajaxSetup({ + beforeSend: function(xhr, settings) { + if (!csrfSafeMethod(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", csrftoken); } - $.ajaxSetup({ - beforeSend: function(xhr, settings) { - if (!csrfSafeMethod(settings.type) && !this.crossDomain) { - xhr.setRequestHeader("X-CSRFToken", csrftoken); - } - } - }); } }); +function add_csrf_form($form) { + $form.append( + $('', {'name': 'csrfmiddlewaretoken', 'value': csrftoken}) + ); +} + + /* * Generic Websocket class and k-psul ws instanciation */ @@ -199,3 +210,28 @@ jconfirm.defaults = { confirmButton: '', cancelButton: '' }; + + +/** + * Create form node, given an url used as 'action', with csrftoken set. + */ +function create_form(url) { + let $form = $('', { + 'action': url, + 'method': 'post', + }); + add_csrf_form($form); + return $form; +} + + +/** + * Emit a POST request from tag. + * + * Usage: + * {…} + */ +function submit_url(el) { + let url = $(el).data('url'); + create_form(url).appendTo($('body')).submit(); +} diff --git a/kfet/templates/kfet/base_nav.html b/kfet/templates/kfet/base_nav.html index abcb8e18..dda6c1ef 100644 --- a/kfet/templates/kfet/base_nav.html +++ b/kfet/templates/kfet/base_nav.html @@ -1,4 +1,4 @@ -{% load static %} +{% load i18n static %} {% load wagtailcore_tags %} - - diff --git a/kfet/templates/kfet/confirm_form.html b/kfet/templates/kfet/confirm_form.html new file mode 100644 index 00000000..1cffd171 --- /dev/null +++ b/kfet/templates/kfet/confirm_form.html @@ -0,0 +1,20 @@ +{% extends "kfet/base_form.html" %} +{% load i18n %} + +{% block title %}{{ title }}{% endblock %} +{% block header %}{% endblock %} + +{% block main-class %}main-bg main-padding text-center{% endblock %} +{% block main-size %}col-sm-8 col-sm-offset-2 col-md-6 col-md-offset-3{% endblock %} + +{% block main %} + + +

+ {{ text }} +

+ + {% csrf_token %} +
+ +{% endblock %} diff --git a/kfet/templates/kfet/nav_item.html b/kfet/templates/kfet/nav_item.html index 8bc311b8..b5981266 100644 --- a/kfet/templates/kfet/nav_item.html +++ b/kfet/templates/kfet/nav_item.html @@ -4,6 +4,8 @@
  • {% if href %} + {% else %} + {% endif %} {% if href %} + {% else %} + {% endif %}
  • diff --git a/kfet/urls.py b/kfet/urls.py index c3499b18..eb4f8311 100644 --- a/kfet/urls.py +++ b/kfet/urls.py @@ -8,8 +8,8 @@ from kfet.decorators import teamkfet_required urlpatterns = [ - url(r'^login/genericteam$', views.login_genericteam, - name='kfet.login.genericteam'), + url(r'^login/generic$', views.login_generic, + name='kfet.login.generic'), url(r'^history$', views.history, name='kfet.history'), diff --git a/kfet/views.py b/kfet/views.py index 386eddb6..79fe184d 100644 --- a/kfet/views.py +++ b/kfet/views.py @@ -51,7 +51,7 @@ import statistics from kfet.statistic import ScaleMixin, last_stats_manifest, tot_ventes, WeekScale from .auth.views import ( # noqa - account_group, login_genericteam, AccountGroupCreate, AccountGroupUpdate, + account_group, login_generic, AccountGroupCreate, AccountGroupUpdate, ) From fccad5edee83df4726cf54aa19dee5b02661db1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20P=C3=A9pin?= Date: Mon, 16 Oct 2017 14:31:02 +0200 Subject: [PATCH 9/9] rename root -> kfet_genericteam in fixtures --- kfet/cms/fixtures/kfet_wagtail_17_05.json | 86 +++++++++++------------ 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/kfet/cms/fixtures/kfet_wagtail_17_05.json b/kfet/cms/fixtures/kfet_wagtail_17_05.json index f6a46c30..66ac7040 100644 --- a/kfet/cms/fixtures/kfet_wagtail_17_05.json +++ b/kfet/cms/fixtures/kfet_wagtail_17_05.json @@ -53,7 +53,7 @@ "kfetpage" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -83,7 +83,7 @@ "kfetpage" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -113,7 +113,7 @@ "kfetpage" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -143,7 +143,7 @@ "kfetpage" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -173,7 +173,7 @@ "kfetpage" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -203,7 +203,7 @@ "kfetpage" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -233,7 +233,7 @@ "page" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -263,7 +263,7 @@ "kfetpage" ], "owner": [ - "root" + "kfet_genericteam" ], "expired": false, "first_published_at": "2017-05-28T04:20:00.000Z", @@ -681,7 +681,7 @@ "fields": { "created_at": "2017-05-30T04:20:00.000Z", "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "collection": 2, "title": "K-F\u00eat - Plan d'acc\u00e8s", @@ -694,7 +694,7 @@ "fields": { "created_at": "2017-05-30T04:20:00.000Z", "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "collection": 2, "title": "K-F\u00eat - Demande d'autorisation", @@ -707,7 +707,7 @@ "fields": { "created_at": "2017-05-30T04:20:00.000Z", "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "collection": 2, "title": "K-F\u00eat - Trait\u00e9 de Flipper Th\u00e9orique", @@ -730,7 +730,7 @@ "title": "K-F\u00eat - Amazon Hunt", "width": 200, "uploaded_by_user": [ - "root" + "kfet_genericteam" ] } }, @@ -750,7 +750,7 @@ "title": "K-F\u00eat - Fun Machine", "width": 200, "uploaded_by_user": [ - "root" + "kfet_genericteam" ] } }, @@ -767,7 +767,7 @@ "title": "Hugo Manet", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -787,7 +787,7 @@ "title": "Lisa Gourdon", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -807,7 +807,7 @@ "title": "Pierre Quesselaire", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -827,7 +827,7 @@ "title": "Thibault Scoquard", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -847,7 +847,7 @@ "title": "Arnaud Fanthomme", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -867,7 +867,7 @@ "title": "Vincent Balerdi", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -887,7 +887,7 @@ "title": "Nathana\u00ebl Willaime", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -907,7 +907,7 @@ "title": "\u00c9lisabeth Miller", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -927,7 +927,7 @@ "title": "Arthur Lesage", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -947,7 +947,7 @@ "title": "Sarah Asset", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -967,7 +967,7 @@ "title": "Alexandre Legrand", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -987,7 +987,7 @@ "title": "\u00c9tienne Baudel", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1007,7 +1007,7 @@ "title": "Marine Snape", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1027,7 +1027,7 @@ "title": "Anatole Gosset", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1047,7 +1047,7 @@ "title": "Jacko Rastikian", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1067,7 +1067,7 @@ "title": "Alexandre Jannaud", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1087,7 +1087,7 @@ "title": "Aur\u00e9lien Delobelle", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1107,7 +1107,7 @@ "title": "Sylvain Douteau", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1127,7 +1127,7 @@ "title": "Rapha\u00ebl Lescanne", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1147,7 +1147,7 @@ "title": "Romain Gourvil", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1167,7 +1167,7 @@ "title": "Marie Labeye", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1187,7 +1187,7 @@ "title": "Oscar Blumberg", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1207,7 +1207,7 @@ "title": "Za\u00efd Allybokus", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1227,7 +1227,7 @@ "title": "Damien Garreau", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1247,7 +1247,7 @@ "title": "Andr\u00e9a Londono-Lopez", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1267,7 +1267,7 @@ "title": "Tristan Roussel", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1287,7 +1287,7 @@ "title": "Guillaume Vernade", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1307,7 +1307,7 @@ "title": "Lucas Mercier", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1327,7 +1327,7 @@ "title": "Fran\u00e7ois Maillot", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" }, @@ -1347,7 +1347,7 @@ "title": "Fabrice Catoire", "collection": 2, "uploaded_by_user": [ - "root" + "kfet_genericteam" ], "created_at": "2017-05-30T04:20:00.000Z" },