diff --git a/apps/activity/forms.py b/apps/activity/forms.py index 60c18311..b40463c0 100644 --- a/apps/activity/forms.py +++ b/apps/activity/forms.py @@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _ from member.models import Club from note.models import Note, NoteUser from note_kfet.inputs import Autocomplete, DateTimePickerInput -from note_kfet.middlewares import get_current_authenticated_user +from note_kfet.middlewares import get_current_request from permission.backends import PermissionBackend from .models import Activity, Guest @@ -24,7 +24,7 @@ class ActivityForm(forms.ModelForm): self.fields["attendees_club"].initial = Club.objects.get(name="Kfet") self.fields["attendees_club"].widget.attrs["placeholder"] = "Kfet" clubs = list(Club.objects.filter(PermissionBackend - .filter_queryset(get_current_authenticated_user(), Club, "view")).all()) + .filter_queryset(get_current_request(), Club, "view")).all()) shuffle(clubs) self.fields["organizer"].widget.attrs["placeholder"] = ", ".join(club.name for club in clubs[:4]) + ", ..." diff --git a/apps/activity/views.py b/apps/activity/views.py index 86914caf..a2ae59ab 100644 --- a/apps/activity/views.py +++ b/apps/activity/views.py @@ -74,12 +74,12 @@ class ActivityListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView upcoming_activities = Activity.objects.filter(date_end__gt=timezone.now()) context['upcoming'] = ActivityTable( - data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")), + data=upcoming_activities.filter(PermissionBackend.filter_queryset(self.request, Activity, "view")), prefix='upcoming-', ) started_activities = Activity.objects\ - .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ + .filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\ .filter(open=True, valid=True).all() context["started_activities"] = started_activities @@ -98,7 +98,7 @@ class ActivityDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): context = super().get_context_data() table = GuestTable(data=Guest.objects.filter(activity=self.object) - .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))) + .filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))) context["guests"] = table context["activity_started"] = timezone.now() > timezone.localtime(self.object.date_start) @@ -144,7 +144,7 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView): def get_form(self, form_class=None): form = super().get_form(form_class) - form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ + form.activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\ .get(pk=self.kwargs["pk"]) form.fields["inviter"].initial = self.request.user.note return form @@ -152,7 +152,7 @@ class ActivityInviteView(ProtectQuerysetMixin, ProtectedCreateView): @transaction.atomic def form_valid(self, form): form.instance.activity = Activity.objects\ - .filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view")).get(pk=self.kwargs["pk"]) + .filter(PermissionBackend.filter_queryset(self.request, Activity, "view")).get(pk=self.kwargs["pk"]) return super().form_valid(form) def get_success_url(self, **kwargs): @@ -173,7 +173,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView): activity = Activity.objects.get(pk=self.kwargs["pk"]) sample_entry = Entry(activity=activity, note=self.request.user.note) - if not PermissionBackend.check_perm(self.request.user, "activity.add_entry", sample_entry): + if not PermissionBackend.check_perm(self.request, "activity.add_entry", sample_entry): raise PermissionDenied(_("You are not allowed to display the entry interface for this activity.")) if not activity.activity_type.manage_entries: @@ -191,7 +191,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView): guest_qs = Guest.objects\ .annotate(balance=F("inviter__balance"), note_name=F("inviter__user__username"))\ .filter(activity=activity)\ - .filter(PermissionBackend.filter_queryset(self.request.user, Guest, "view"))\ + .filter(PermissionBackend.filter_queryset(self.request, Guest, "view"))\ .order_by('last_name', 'first_name').distinct() if "search" in self.request.GET and self.request.GET["search"]: @@ -230,7 +230,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView): ) # Filter with permission backend - note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view")) + note_qs = note_qs.filter(PermissionBackend.filter_queryset(self.request, Alias, "view")) if "search" in self.request.GET and self.request.GET["search"]: pattern = self.request.GET["search"] @@ -256,7 +256,7 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView): """ context = super().get_context_data(**kwargs) - activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request.user, Activity, "view"))\ + activity = Activity.objects.filter(PermissionBackend.filter_queryset(self.request, Activity, "view"))\ .distinct().get(pk=self.kwargs["pk"]) context["activity"] = activity @@ -281,9 +281,9 @@ class ActivityEntryView(LoginRequiredMixin, TemplateView): context["notespecial_ctype"] = ContentType.objects.get_for_model(NoteSpecial).pk activities_open = Activity.objects.filter(open=True).filter( - PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all() + PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().all() context["activities_open"] = [a for a in activities_open - if PermissionBackend.check_perm(self.request.user, + if PermissionBackend.check_perm(self.request, "activity.add_entry", Entry(activity=a, note=self.request.user.note,))] diff --git a/apps/api/viewsets.py b/apps/api/viewsets.py index 25221cfc..faeadee1 100644 --- a/apps/api/viewsets.py +++ b/apps/api/viewsets.py @@ -9,7 +9,6 @@ from django.contrib.auth.models import User from rest_framework.filters import SearchFilter from rest_framework.viewsets import ReadOnlyModelViewSet, ModelViewSet from permission.backends import PermissionBackend -from note_kfet.middlewares import get_current_session from note.models import Alias from .serializers import UserSerializer, ContentTypeSerializer @@ -25,9 +24,7 @@ class ReadProtectedModelViewSet(ModelViewSet): self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class() def get_queryset(self): - user = self.request.user - get_current_session().setdefault("permission_mask", 42) - return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct() + return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct() class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet): @@ -40,9 +37,7 @@ class ReadOnlyProtectedModelViewSet(ReadOnlyModelViewSet): self.model = ContentType.objects.get_for_model(self.serializer_class.Meta.model).model_class() def get_queryset(self): - user = self.request.user - get_current_session().setdefault("permission_mask", 42) - return self.queryset.filter(PermissionBackend.filter_queryset(user, self.model, "view")).distinct() + return self.queryset.filter(PermissionBackend.filter_queryset(self.request, self.model, "view")).distinct() class UserViewSet(ReadProtectedModelViewSet): diff --git a/apps/logs/signals.py b/apps/logs/signals.py index 862dbd75..a3166eed 100644 --- a/apps/logs/signals.py +++ b/apps/logs/signals.py @@ -5,7 +5,7 @@ from django.contrib.contenttypes.models import ContentType from rest_framework.renderers import JSONRenderer from rest_framework.serializers import ModelSerializer from note.models import NoteUser, Alias -from note_kfet.middlewares import get_current_authenticated_user, get_current_ip +from note_kfet.middlewares import get_current_request from .models import Changelog @@ -57,9 +57,9 @@ def save_object(sender, instance, **kwargs): previous = instance._previous # Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP - user, ip = get_current_authenticated_user(), get_current_ip() + request = get_current_request() - if user is None: + if request is None: # Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py` # On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée # IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info @@ -71,9 +71,23 @@ def save_object(sender, instance, **kwargs): # else: if note.exists(): user = note.get().user + else: + user = None + else: + user = request.user + if 'HTTP_X_REAL_IP' in request.META: + ip = request.META.get('HTTP_X_REAL_IP') + elif 'HTTP_X_FORWARDED_FOR' in request.META: + ip = request.META.get('HTTP_X_FORWARDED_FOR').split(', ')[0] + else: + ip = request.META.get('REMOTE_ADDR') + + if not user.is_authenticated: + # For registration and OAuth2 purposes + user = None # noinspection PyProtectedMember - if user is not None and instance._meta.label_lower == "auth.user" and previous: + if request is not None and instance._meta.label_lower == "auth.user" and previous: # On n'enregistre pas les connexions if instance.last_login != previous.last_login: return @@ -121,9 +135,9 @@ def delete_object(sender, instance, **kwargs): return # Si un utilisateur est connecté, on récupère l'utilisateur courant ainsi que son adresse IP - user, ip = get_current_authenticated_user(), get_current_ip() + request = get_current_request() - if user is None: + if request is None: # Si la modification n'a pas été faite via le client Web, on suppose que c'est du à `manage.py` # On récupère alors l'utilisateur·trice connecté·e à la VM, et on récupère la note associée # IMPORTANT : l'utilisateur dans la VM doit être un des alias note du respo info @@ -135,6 +149,20 @@ def delete_object(sender, instance, **kwargs): # else: if note.exists(): user = note.get().user + else: + user = None + else: + user = request.user + if 'HTTP_X_REAL_IP' in request.META: + ip = request.META.get('HTTP_X_REAL_IP') + elif 'HTTP_X_FORWARDED_FOR' in request.META: + ip = request.META.get('HTTP_X_FORWARDED_FOR').split(', ')[0] + else: + ip = request.META.get('REMOTE_ADDR') + + if not user.is_authenticated: + # For registration and OAuth2 purposes + user = None # On crée notre propre sérialiseur JSON pour pouvoir sauvegarder les modèles class CustomSerializer(ModelSerializer): diff --git a/apps/member/hashers.py b/apps/member/hashers.py index 99b2c30e..69db24b0 100644 --- a/apps/member/hashers.py +++ b/apps/member/hashers.py @@ -6,7 +6,7 @@ import hashlib from django.conf import settings from django.contrib.auth.hashers import PBKDF2PasswordHasher from django.utils.crypto import constant_time_compare -from note_kfet.middlewares import get_current_authenticated_user, get_current_session +from note_kfet.middlewares import get_current_request class CustomNK15Hasher(PBKDF2PasswordHasher): @@ -24,16 +24,22 @@ class CustomNK15Hasher(PBKDF2PasswordHasher): def must_update(self, encoded): if settings.DEBUG: - current_user = get_current_authenticated_user() + # Small hack to let superusers to impersonate people. + # Don't change their password. + request = get_current_request() + current_user = request.user if current_user is not None and current_user.is_superuser: return False return True def verify(self, password, encoded): if settings.DEBUG: - current_user = get_current_authenticated_user() + # Small hack to let superusers to impersonate people. + # If a superuser is already connected, let him/her log in as another person. + request = get_current_request() + current_user = request.user if current_user is not None and current_user.is_superuser\ - and get_current_session().get("permission_mask", -1) >= 42: + and request.session.get("permission_mask", -1) >= 42: return True if '|' in encoded: @@ -51,8 +57,11 @@ class DebugSuperuserBackdoor(PBKDF2PasswordHasher): def verify(self, password, encoded): if settings.DEBUG: - current_user = get_current_authenticated_user() + # Small hack to let superusers to impersonate people. + # If a superuser is already connected, let him/her log in as another person. + request = get_current_request() + current_user = request.user if current_user is not None and current_user.is_superuser\ - and get_current_session().get("permission_mask", -1) >= 42: + and request.session.get("permission_mask", -1) >= 42: return True return super().verify(password, encoded) diff --git a/apps/member/tables.py b/apps/member/tables.py index d97da7ca..1c152526 100644 --- a/apps/member/tables.py +++ b/apps/member/tables.py @@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _ from django.urls import reverse_lazy from django.utils.html import format_html from note.templatetags.pretty_money import pretty_money -from note_kfet.middlewares import get_current_authenticated_user +from note_kfet.middlewares import get_current_request from permission.backends import PermissionBackend from .models import Club, Membership @@ -51,19 +51,19 @@ class UserTable(tables.Table): def render_email(self, record, value): # Replace the email by a dash if the user can't see the profile detail # Replace also the URL - if not PermissionBackend.check_perm(get_current_authenticated_user(), "member.view_profile", record.profile): + if not PermissionBackend.check_perm(get_current_request(), "member.view_profile", record.profile): value = "—" record.email = value return value def render_section(self, record, value): return value \ - if PermissionBackend.check_perm(get_current_authenticated_user(), "member.view_profile", record.profile) \ + if PermissionBackend.check_perm(get_current_request(), "member.view_profile", record.profile) \ else "—" def render_balance(self, record, value): return pretty_money(value)\ - if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", record.note) else "—" + if PermissionBackend.check_perm(get_current_request(), "note.view_note", record.note) else "—" class Meta: attrs = { @@ -93,7 +93,7 @@ class MembershipTable(tables.Table): def render_user(self, value): # If the user has the right, link the displayed user with the page of its detail. s = value.username - if PermissionBackend.check_perm(get_current_authenticated_user(), "auth.view_user", value): + if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value): s = format_html("{name}", url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s) @@ -102,7 +102,7 @@ class MembershipTable(tables.Table): def render_club(self, value): # If the user has the right, link the displayed club with the page of its detail. s = value.name - if PermissionBackend.check_perm(get_current_authenticated_user(), "member.view_club", value): + if PermissionBackend.check_perm(get_current_request(), "member.view_club", value): s = format_html("{name}", url=reverse_lazy('member:club_detail', kwargs={"pk": value.pk}), name=s) @@ -127,7 +127,7 @@ class MembershipTable(tables.Table): date_end=date.today(), fee=0, ) - if PermissionBackend.check_perm(get_current_authenticated_user(), + if PermissionBackend.check_perm(get_current_request(), "member.add_membership", empty_membership): # If the user has right renew_url = reverse_lazy('member:club_renew_membership', kwargs={"pk": record.pk}) @@ -142,7 +142,7 @@ class MembershipTable(tables.Table): # If the user has the right to manage the roles, display the link to manage them roles = record.roles.all() s = ", ".join(str(role) for role in roles) - if PermissionBackend.check_perm(get_current_authenticated_user(), "member.change_membership_roles", record): + if PermissionBackend.check_perm(get_current_request(), "member.change_membership_roles", record): s = format_html("" + s + "") return s @@ -165,7 +165,7 @@ class ClubManagerTable(tables.Table): def render_user(self, value): # If the user has the right, link the displayed user with the page of its detail. s = value.username - if PermissionBackend.check_perm(get_current_authenticated_user(), "auth.view_user", value): + if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value): s = format_html("{name}", url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s) diff --git a/apps/member/templates/member/manage_auth_tokens.html b/apps/member/templates/member/manage_auth_tokens.html index 014686f1..0f6a64ec 100644 --- a/apps/member/templates/member/manage_auth_tokens.html +++ b/apps/member/templates/member/manage_auth_tokens.html @@ -5,32 +5,98 @@ SPDX-License-Identifier: GPL-3.0-or-later {% load i18n %} {% block content %} -
-

À quoi sert un jeton d'authentification ?

+
+
+
+
+

{% trans "Token authentication" %}

+
+
+
+

À quoi sert un jeton d'authentification ?

- Un jeton vous permet de vous connecter à l'API de la Note Kfet.
- Il suffit pour cela d'ajouter en en-tête de vos requêtes Authorization: Token <TOKEN> - pour pouvoir vous identifier.

+ Un jeton vous permet de vous connecter à l'API de la Note Kfet via votre propre compte + depuis un client externe.
+ Il suffit pour cela d'ajouter en en-tête de vos requêtes Authorization: Token <TOKEN> + pour pouvoir vous identifier.

- Une documentation de l'API arrivera ultérieurement. + La documentation de l'API est disponible ici : + {{ request.scheme }}://{{ request.get_host }}/doc/api/. +
+ +
+ {%trans 'Token' %} : + {% if 'show' in request.GET %} + {{ token.key }} (cacher) + {% else %} + caché (montrer) + {% endif %} +
+ {%trans 'Created' %} : {{ token.created }} +
+ +
+ {% trans "Warning" %} : regénérer le jeton va révoquer tout accès autorisé à l'API via ce jeton ! +
+
+ +
+
+ +
+
+
+

{% trans "OAuth2 authentication" %}

+
+
+
+

+ La Note Kfet implémente également le protocole OAuth2, afin de + permettre à des applications tierces d'interagir avec la Note en récoltant des informations + (de connexion par exemple) voir en permettant des modifications à distance, par exemple lorsqu'il + s'agit d'avoir un site marchand sur lequel faire des transactions via la Note Kfet. +

+ +

+ L'usage de ce protocole est recommandé pour tout usage non personnel, car permet de mieux cibler + les droits dont on a besoin, en restreignant leur usage par jeton généré. +

+ +

+ La documentation vis-à-vis de l'usage de ce protocole est disponible ici : + {{ request.scheme }}://{{ request.get_host }}/doc/external_services/oauth2/. +

+
+ + Liste des URL à communiquer à votre application : + + +
+ +
+
- -
- {%trans 'Token' %} : - {% if 'show' in request.GET %} - {{ token.key }} (cacher) - {% else %} - caché (montrer) - {% endif %} -
- {%trans 'Created' %} : {{ token.created }} -
- -
- Attention : regénérer le jeton va révoquer tout accès autorisé à l'API via ce jeton ! -
- - - - {% endblock %} \ No newline at end of file diff --git a/apps/member/views.py b/apps/member/views.py index 514c0644..39edcc0b 100644 --- a/apps/member/views.py +++ b/apps/member/views.py @@ -21,7 +21,7 @@ from rest_framework.authtoken.models import Token from note.models import Alias, NoteUser from note.models.transactions import Transaction, SpecialTransaction from note.tables import HistoryTable, AliasTable -from note_kfet.middlewares import _set_current_user_and_ip +from note_kfet.middlewares import _set_current_request from permission.backends import PermissionBackend from permission.models import Role from permission.views import ProtectQuerysetMixin, ProtectedCreateView @@ -41,7 +41,8 @@ class CustomLoginView(LoginView): @transaction.atomic def form_valid(self, form): logout(self.request) - _set_current_user_and_ip(form.get_user(), self.request.session, None) + self.request.user = form.get_user() + _set_current_request(self.request) self.request.session['permission_mask'] = form.cleaned_data['permission_mask'].rank return super().form_valid(form) @@ -70,7 +71,7 @@ class UserUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView): form.fields['email'].required = True form.fields['email'].help_text = _("This address must be valid.") - if PermissionBackend.check_perm(self.request.user, "member.change_profile", context['user_object'].profile): + if PermissionBackend.check_perm(self.request, "member.change_profile", context['user_object'].profile): context['profile_form'] = self.profile_form(instance=context['user_object'].profile, data=self.request.POST if self.request.POST else None) if not self.object.profile.report_frequency: @@ -153,13 +154,13 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): history_list = \ Transaction.objects.all().filter(Q(source=user.note) | Q(destination=user.note))\ .order_by("-created_at")\ - .filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view")) + .filter(PermissionBackend.filter_queryset(self.request, Transaction, "view")) history_table = HistoryTable(history_list, prefix='transaction-') history_table.paginate(per_page=20, page=self.request.GET.get("transaction-page", 1)) context['history_list'] = history_table club_list = Membership.objects.filter(user=user, date_end__gte=date.today() - timedelta(days=15))\ - .filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view"))\ + .filter(PermissionBackend.filter_queryset(self.request, Membership, "view"))\ .order_by("club__name", "-date_start") # Display only the most recent membership club_list = club_list.distinct("club__name")\ @@ -176,21 +177,20 @@ class UserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): modified_note.is_active = True modified_note.inactivity_reason = 'manual' context["can_lock_note"] = user.note.is_active and PermissionBackend\ - .check_perm(self.request.user, "note.change_noteuser_is_active", - modified_note) + .check_perm(self.request, "note.change_noteuser_is_active", modified_note) old_note = NoteUser.objects.select_for_update().get(pk=user.note.pk) modified_note.inactivity_reason = 'forced' modified_note._force_save = True modified_note.save() context["can_force_lock"] = user.note.is_active and PermissionBackend\ - .check_perm(self.request.user, "note.change_note_is_active", modified_note) + .check_perm(self.request, "note.change_note_is_active", modified_note) old_note._force_save = True old_note._no_signal = True old_note.save() modified_note.refresh_from_db() modified_note.is_active = True context["can_unlock_note"] = not user.note.is_active and PermissionBackend\ - .check_perm(self.request.user, "note.change_note_is_active", modified_note) + .check_perm(self.request, "note.change_note_is_active", modified_note) return context @@ -237,7 +237,7 @@ class UserListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - pre_registered_users = User.objects.filter(PermissionBackend.filter_queryset(self.request.user, User, "view"))\ + pre_registered_users = User.objects.filter(PermissionBackend.filter_queryset(self.request, User, "view"))\ .filter(profile__registration_valid=False) context["can_manage_registrations"] = pre_registered_users.exists() return context @@ -256,8 +256,8 @@ class ProfileAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): context = super().get_context_data(**kwargs) note = context['object'].note context["aliases"] = AliasTable( - note.alias.filter(PermissionBackend.filter_queryset(self.request.user, Alias, "view")).distinct().all()) - context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias( + note.alias.filter(PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct().all()) + context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias( note=context["object"].note, name="", normalized_name="", @@ -382,7 +382,7 @@ class ClubListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["can_add_club"] = PermissionBackend.check_perm(self.request.user, "member.add_club", Club( + context["can_add_club"] = PermissionBackend.check_perm(self.request, "member.add_club", Club( name="", email="club@example.com", )) @@ -404,7 +404,7 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): context = super().get_context_data(**kwargs) club = context["club"] - if PermissionBackend.check_perm(self.request.user, "member.change_club_membership_start", club): + if PermissionBackend.check_perm(self.request, "member.change_club_membership_start", club): club.update_membership_dates() # managers list managers = Membership.objects.filter(club=self.object, roles__name="Bureau de club", @@ -413,7 +413,7 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): context["managers"] = ClubManagerTable(data=managers, prefix="managers-") # transaction history club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note))\ - .filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))\ + .filter(PermissionBackend.filter_queryset(self.request, Transaction, "view"))\ .order_by('-created_at') history_table = HistoryTable(club_transactions, prefix="history-") history_table.paginate(per_page=20, page=self.request.GET.get('history-page', 1)) @@ -422,7 +422,7 @@ class ClubDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): club_member = Membership.objects.filter( club=club, date_end__gte=date.today() - timedelta(days=15), - ).filter(PermissionBackend.filter_queryset(self.request.user, Membership, "view"))\ + ).filter(PermissionBackend.filter_queryset(self.request, Membership, "view"))\ .order_by("user__username", "-date_start") # Display only the most recent membership club_member = club_member.distinct("user__username")\ @@ -459,8 +459,8 @@ class ClubAliasView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): context = super().get_context_data(**kwargs) note = context['object'].note context["aliases"] = AliasTable(note.alias.filter( - PermissionBackend.filter_queryset(self.request.user, Alias, "view")).distinct().all()) - context["can_create"] = PermissionBackend.check_perm(self.request.user, "note.add_alias", Alias( + PermissionBackend.filter_queryset(self.request, Alias, "view")).distinct().all()) + context["can_create"] = PermissionBackend.check_perm(self.request, "note.add_alias", Alias( note=context["object"].note, name="", normalized_name="", @@ -535,7 +535,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView): form = context['form'] if "club_pk" in self.kwargs: # We create a new membership. - club = Club.objects.filter(PermissionBackend.filter_queryset(self.request.user, Club, "view"))\ + club = Club.objects.filter(PermissionBackend.filter_queryset(self.request, Club, "view"))\ .get(pk=self.kwargs["club_pk"], weiclub=None) form.fields['credit_amount'].initial = club.membership_fee_paid # Ensure that the user is member of the parent club and all its the family tree. @@ -683,7 +683,7 @@ class ClubAddMemberView(ProtectQuerysetMixin, ProtectedCreateView): """ # Get the club that is concerned by the membership if "club_pk" in self.kwargs: # get from url of new membership - club = Club.objects.filter(PermissionBackend.filter_queryset(self.request.user, Club, "view")) \ + club = Club.objects.filter(PermissionBackend.filter_queryset(self.request, Club, "view")) \ .get(pk=self.kwargs["club_pk"]) user = form.instance.user old_membership = None @@ -867,7 +867,7 @@ class ClubMembersListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableV def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) club = Club.objects.filter( - PermissionBackend.filter_queryset(self.request.user, Club, "view") + PermissionBackend.filter_queryset(self.request, Club, "view") ).get(pk=self.kwargs["pk"]) context["club"] = club diff --git a/apps/note/api/serializers.py b/apps/note/api/serializers.py index f4905103..7dda6dba 100644 --- a/apps/note/api/serializers.py +++ b/apps/note/api/serializers.py @@ -8,7 +8,7 @@ from rest_framework.exceptions import ValidationError from rest_polymorphic.serializers import PolymorphicSerializer from member.api.serializers import MembershipSerializer from member.models import Membership -from note_kfet.middlewares import get_current_authenticated_user +from note_kfet.middlewares import get_current_request from permission.backends import PermissionBackend from rest_framework.utils import model_meta @@ -126,7 +126,7 @@ class ConsumerSerializer(serializers.ModelSerializer): """ # If the user has no right to see the note, then we only display the note identifier return NotePolymorphicSerializer().to_representation(obj.note)\ - if PermissionBackend.check_perm(get_current_authenticated_user(), "note.view_note", obj.note)\ + if PermissionBackend.check_perm(get_current_request(), "note.view_note", obj.note)\ else dict( id=obj.note.id, name=str(obj.note), @@ -142,7 +142,7 @@ class ConsumerSerializer(serializers.ModelSerializer): def get_membership(self, obj): if isinstance(obj.note, NoteUser): memberships = Membership.objects.filter( - PermissionBackend.filter_queryset(get_current_authenticated_user(), Membership, "view")).filter( + PermissionBackend.filter_queryset(get_current_request(), Membership, "view")).filter( user=obj.note.user, club=2, # Kfet ).order_by("-date_start") diff --git a/apps/note/api/views.py b/apps/note/api/views.py index 594b2b9c..d4021210 100644 --- a/apps/note/api/views.py +++ b/apps/note/api/views.py @@ -10,7 +10,6 @@ from rest_framework import viewsets from rest_framework.response import Response from rest_framework import status from api.viewsets import ReadProtectedModelViewSet, ReadOnlyProtectedModelViewSet -from note_kfet.middlewares import get_current_session from permission.backends import PermissionBackend from .serializers import NotePolymorphicSerializer, AliasSerializer, ConsumerSerializer,\ @@ -40,12 +39,11 @@ class NotePolymorphicViewSet(ReadProtectedModelViewSet): Parse query and apply filters. :return: The filtered set of requested notes """ - user = self.request.user - get_current_session().setdefault("permission_mask", 42) - queryset = self.queryset.filter(PermissionBackend.filter_queryset(user, Note, "view") - | PermissionBackend.filter_queryset(user, NoteUser, "view") - | PermissionBackend.filter_queryset(user, NoteClub, "view") - | PermissionBackend.filter_queryset(user, NoteSpecial, "view")).distinct() + queryset = self.queryset.filter(PermissionBackend.filter_queryset(self.request, Note, "view") + | PermissionBackend.filter_queryset(self.request, NoteUser, "view") + | PermissionBackend.filter_queryset(self.request, NoteClub, "view") + | PermissionBackend.filter_queryset(self.request, NoteSpecial, "view"))\ + .distinct() alias = self.request.query_params.get("alias", ".*") queryset = queryset.filter( @@ -67,7 +65,8 @@ class AliasViewSet(ReadProtectedModelViewSet): serializer_class = AliasSerializer filter_backends = [SearchFilter, DjangoFilterBackend, OrderingFilter] search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] - filterset_fields = ['note', 'note__noteuser__user', 'note__noteclub__club', 'note__polymorphic_ctype__model', ] + filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user', + 'note__noteclub__club', 'note__polymorphic_ctype__model', ] ordering_fields = ['name', 'normalized_name', ] def get_serializer_class(self): @@ -118,7 +117,8 @@ class ConsumerViewSet(ReadOnlyProtectedModelViewSet): serializer_class = ConsumerSerializer filter_backends = [SearchFilter, OrderingFilter, DjangoFilterBackend] search_fields = ['$normalized_name', '$name', '$note__polymorphic_ctype__model', ] - filterset_fields = ['note', 'note__noteuser__user', 'note__noteclub__club', 'note__polymorphic_ctype__model', ] + filterset_fields = ['name', 'normalized_name', 'note', 'note__noteuser__user', + 'note__noteclub__club', 'note__polymorphic_ctype__model', ] ordering_fields = ['name', 'normalized_name', ] def get_queryset(self): @@ -205,7 +205,5 @@ class TransactionViewSet(ReadProtectedModelViewSet): ordering_fields = ['created_at', 'amount', ] def get_queryset(self): - user = self.request.user - get_current_session().setdefault("permission_mask", 42) - return self.model.objects.filter(PermissionBackend.filter_queryset(user, self.model, "view"))\ + return self.model.objects.filter(PermissionBackend.filter_queryset(self.request, self.model, "view"))\ .order_by("created_at", "id") diff --git a/apps/note/tables.py b/apps/note/tables.py index e98a9b0b..518173c6 100644 --- a/apps/note/tables.py +++ b/apps/note/tables.py @@ -7,7 +7,7 @@ import django_tables2 as tables from django.utils.html import format_html from django_tables2.utils import A from django.utils.translation import gettext_lazy as _ -from note_kfet.middlewares import get_current_authenticated_user +from note_kfet.middlewares import get_current_request from permission.backends import PermissionBackend from .models.notes import Alias @@ -88,16 +88,16 @@ class HistoryTable(tables.Table): "class": lambda record: str(record.valid).lower() + (' validate' if record.source.is_active and record.destination.is_active and PermissionBackend - .check_perm(get_current_authenticated_user(), "note.change_transaction_invalidity_reason", record) + .check_perm(get_current_request(), "note.change_transaction_invalidity_reason", record) else ''), "data-toggle": "tooltip", "title": lambda record: (_("Click to invalidate") if record.valid else _("Click to validate")) - if PermissionBackend.check_perm(get_current_authenticated_user(), + if PermissionBackend.check_perm(get_current_request(), "note.change_transaction_invalidity_reason", record) and record.source.is_active and record.destination.is_active else None, "onclick": lambda record: 'de_validate(' + str(record.id) + ', ' + str(record.valid).lower() + ', "' + str(record.__class__.__name__) + '")' - if PermissionBackend.check_perm(get_current_authenticated_user(), + if PermissionBackend.check_perm(get_current_request(), "note.change_transaction_invalidity_reason", record) and record.source.is_active and record.destination.is_active else None, "onmouseover": lambda record: '$("#invalidity_reason_' @@ -126,7 +126,7 @@ class HistoryTable(tables.Table): When the validation status is hovered, an input field is displayed to let the user specify an invalidity reason """ has_perm = PermissionBackend \ - .check_perm(get_current_authenticated_user(), "note.change_transaction_invalidity_reason", record) + .check_perm(get_current_request(), "note.change_transaction_invalidity_reason", record) val = "✔" if value else "✖" @@ -165,7 +165,7 @@ class AliasTable(tables.Table): extra_context={"delete_trans": _('delete')}, attrs={'td': {'class': lambda record: 'col-sm-1' + ( ' d-none' if not PermissionBackend.check_perm( - get_current_authenticated_user(), "note.delete_alias", + get_current_request(), "note.delete_alias", record) else '')}}, verbose_name=_("Delete"), ) diff --git a/apps/note/views.py b/apps/note/views.py index 73e5a084..279eadc6 100644 --- a/apps/note/views.py +++ b/apps/note/views.py @@ -38,7 +38,7 @@ class TransactionCreateView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTabl def get_queryset(self, **kwargs): # retrieves only Transaction that user has the right to see. return Transaction.objects.filter( - PermissionBackend.filter_queryset(self.request.user, Transaction, "view") + PermissionBackend.filter_queryset(self.request, Transaction, "view") ).order_by("-created_at").all()[:20] def get_context_data(self, **kwargs): @@ -47,16 +47,16 @@ class TransactionCreateView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTabl context['polymorphic_ctype'] = ContentType.objects.get_for_model(Transaction).pk context['special_polymorphic_ctype'] = ContentType.objects.get_for_model(SpecialTransaction).pk context['special_types'] = NoteSpecial.objects\ - .filter(PermissionBackend.filter_queryset(self.request.user, NoteSpecial, "view"))\ + .filter(PermissionBackend.filter_queryset(self.request, NoteSpecial, "view"))\ .order_by("special_type").all() # Add a shortcut for entry page for open activities if "activity" in settings.INSTALLED_APPS: from activity.models import Activity activities_open = Activity.objects.filter(open=True).filter( - PermissionBackend.filter_queryset(self.request.user, Activity, "view")).distinct().all() + PermissionBackend.filter_queryset(self.request, Activity, "view")).distinct().all() context["activities_open"] = [a for a in activities_open - if PermissionBackend.check_perm(self.request.user, + if PermissionBackend.check_perm(self.request, "activity.add_entry", Entry(activity=a, note=self.request.user.note, ))] @@ -159,7 +159,7 @@ class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): return self.handle_no_permission() templates = TransactionTemplate.objects.filter( - PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view") + PermissionBackend().filter_queryset(self.request, TransactionTemplate, "view") ) if not templates.exists(): raise PermissionDenied(_("You can't see any button.")) @@ -170,7 +170,7 @@ class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): restrict to the transaction history the user can see. """ return Transaction.objects.filter( - PermissionBackend.filter_queryset(self.request.user, Transaction, "view") + PermissionBackend.filter_queryset(self.request, Transaction, "view") ).order_by("-created_at").all()[:20] def get_context_data(self, **kwargs): @@ -180,13 +180,13 @@ class ConsoView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): # for each category, find which transaction templates the user can see. for category in categories: category.templates_filtered = category.templates.filter( - PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view") + PermissionBackend().filter_queryset(self.request, TransactionTemplate, "view") ).filter(display=True).order_by('name').all() context['categories'] = [cat for cat in categories if cat.templates_filtered] # some transactiontemplate are put forward to find them easily context['highlighted'] = TransactionTemplate.objects.filter(highlighted=True).filter( - PermissionBackend().filter_queryset(self.request.user, TransactionTemplate, "view") + PermissionBackend().filter_queryset(self.request, TransactionTemplate, "view") ).order_by('name').all() context['polymorphic_ctype'] = ContentType.objects.get_for_model(RecurrentTransaction).pk @@ -209,7 +209,7 @@ class TransactionSearchView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView data = form.cleaned_data if form.is_valid() else {} transactions = Transaction.objects.annotate(total_amount=F("quantity") * F("amount")).filter( - PermissionBackend.filter_queryset(self.request.user, Transaction, "view"))\ + PermissionBackend.filter_queryset(self.request, Transaction, "view"))\ .filter(Q(source=self.object) | Q(destination=self.object)).order_by('-created_at') if "source" in data and data["source"]: diff --git a/apps/permission/backends.py b/apps/permission/backends.py index b43340f0..af071455 100644 --- a/apps/permission/backends.py +++ b/apps/permission/backends.py @@ -4,12 +4,12 @@ from datetime import date from django.contrib.auth.backends import ModelBackend -from django.contrib.auth.models import User, AnonymousUser +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models import Q, F from django.utils import timezone from note.models import Note, NoteUser, NoteClub, NoteSpecial -from note_kfet.middlewares import get_current_session +from note_kfet.middlewares import get_current_request from member.models import Membership, Club from .decorators import memoize @@ -26,14 +26,31 @@ class PermissionBackend(ModelBackend): @staticmethod @memoize - def get_raw_permissions(user, t): + def get_raw_permissions(request, t): """ Query permissions of a certain type for a user, then memoize it. - :param user: The owner of the permissions + :param request: The current request :param t: The type of the permissions: view, change, add or delete :return: The queryset of the permissions of the user (memoized) grouped by clubs """ - if isinstance(user, AnonymousUser): + if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'): + # OAuth2 Authentication + user = request.auth.user + + def permission_filter(membership_obj): + query = Q(pk=-1) + for scope in request.auth.scope.split(' '): + permission_id, club_id = scope.split('_') + if int(club_id) == membership_obj.club_id: + query |= Q(pk=permission_id) + return query + else: + user = request.user + + def permission_filter(membership_obj): + return Q(mask__rank__lte=request.session.get("permission_mask", 42)) + + if user.is_anonymous: # Unauthenticated users have no permissions return Permission.objects.none() @@ -43,7 +60,7 @@ class PermissionBackend(ModelBackend): for membership in memberships: for role in membership.roles.all(): - for perm in role.permissions.filter(type=t, mask__rank__lte=get_current_session().get("permission_mask", -1)).all(): + for perm in role.permissions.filter(permission_filter(membership), type=t).all(): if not perm.permanent: if membership.date_start > date.today() or membership.date_end < date.today(): continue @@ -52,16 +69,22 @@ class PermissionBackend(ModelBackend): return perms @staticmethod - def permissions(user, model, type): + def permissions(request, model, type): """ List all permissions of the given user that applies to a given model and a give type - :param user: The owner of the permissions + :param request: The current request :param model: The model that the permissions shoud apply :param type: The type of the permissions: view, change, add or delete :return: A generator of the requested permissions """ - for permission in PermissionBackend.get_raw_permissions(user, type): + if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'): + # OAuth2 Authentication + user = request.auth.user + else: + user = request.user + + for permission in PermissionBackend.get_raw_permissions(request, type): if not isinstance(model.model_class()(), permission.model.model_class()) or not permission.membership: continue @@ -88,20 +111,26 @@ class PermissionBackend(ModelBackend): @staticmethod @memoize - def filter_queryset(user, model, t, field=None): + def filter_queryset(request, model, t, field=None): """ Filter a queryset by considering the permissions of a given user. - :param user: The owner of the permissions that are fetched + :param request: The current request :param model: The concerned model of the queryset :param t: The type of modification (view, add, change, delete) :param field: The field of the model to test, if concerned :return: A query that corresponds to the filter to give to a queryset """ - if user is None or isinstance(user, AnonymousUser): + if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'): + # OAuth2 Authentication + user = request.auth.user + else: + user = request.user + + if user is None or user.is_anonymous: # Anonymous users can't do anything return Q(pk=-1) - if user.is_superuser and get_current_session().get("permission_mask", -1) >= 42: + if user.is_superuser and request.session.get("permission_mask", -1) >= 42: # Superusers have all rights return Q() @@ -110,7 +139,7 @@ class PermissionBackend(ModelBackend): # Never satisfied query = Q(pk=-1) - perms = PermissionBackend.permissions(user, model, t) + perms = PermissionBackend.permissions(request, model, t) for perm in perms: if perm.field and field != perm.field: continue @@ -122,7 +151,7 @@ class PermissionBackend(ModelBackend): @staticmethod @memoize - def check_perm(user_obj, perm, obj=None): + def check_perm(request, perm, obj=None): """ Check is the given user has the permission over a given object. The result is then memoized. @@ -130,10 +159,15 @@ class PermissionBackend(ModelBackend): primary key, the result is not memoized. Moreover, the right could change (e.g. for a transaction, the balance of the user could change) """ - if user_obj is None or isinstance(user_obj, AnonymousUser): - return False + user_obj = request.user + sess = request.session - sess = get_current_session() + if hasattr(request, 'auth') and request.auth is not None and hasattr(request.auth, 'scope'): + # OAuth2 Authentication + user_obj = request.auth.user + + if user_obj is None or user_obj.is_anonymous: + return False if user_obj.is_superuser and sess.get("permission_mask", -1) >= 42: return True @@ -147,16 +181,19 @@ class PermissionBackend(ModelBackend): ct = ContentType.objects.get_for_model(obj) if any(permission.applies(obj, perm_type, perm_field) - for permission in PermissionBackend.permissions(user_obj, ct, perm_type)): + for permission in PermissionBackend.permissions(request, ct, perm_type)): return True return False def has_perm(self, user_obj, perm, obj=None): - return PermissionBackend.check_perm(user_obj, perm, obj) + # Warning: this does not check that user_obj has the permission, + # but if the current request has the permission. + # This function is implemented for backward compatibility, and should not be used. + return PermissionBackend.check_perm(get_current_request(), perm, obj) def has_module_perms(self, user_obj, app_label): return False def get_all_permissions(self, user_obj, obj=None): ct = ContentType.objects.get_for_model(obj) - return list(self.permissions(user_obj, ct, "view")) + return list(self.permissions(get_current_request(), ct, "view")) diff --git a/apps/permission/decorators.py b/apps/permission/decorators.py index 7f5b48b0..0e79df90 100644 --- a/apps/permission/decorators.py +++ b/apps/permission/decorators.py @@ -5,7 +5,7 @@ from functools import lru_cache from time import time from django.contrib.sessions.models import Session -from note_kfet.middlewares import get_current_session +from note_kfet.middlewares import get_current_request def memoize(f): @@ -48,11 +48,11 @@ def memoize(f): last_collect = time() # If there is no session, then we don't memoize anything. - sess = get_current_session() - if sess is None or sess.session_key is None: + request = get_current_request() + if request is None or request.session is None or request.session.session_key is None: return f(*args, **kwargs) - sess_key = sess.session_key + sess_key = request.session.session_key if sess_key not in sess_funs: # lru_cache makes the job of memoization # We store only the 512 latest data per session. It has to be enough. diff --git a/apps/permission/permissions.py b/apps/permission/permissions.py index 9a0c1e12..1a5f51e5 100644 --- a/apps/permission/permissions.py +++ b/apps/permission/permissions.py @@ -45,7 +45,7 @@ class StrongDjangoObjectPermissions(DjangoObjectPermissions): perms = self.get_required_object_permissions(request.method, model_cls) # if not user.has_perms(perms, obj): - if not all(PermissionBackend.check_perm(user, perm, obj) for perm in perms): + if not all(PermissionBackend.check_perm(request, perm, obj) for perm in perms): # If the user does not have permissions we need to determine if # they have read permissions to see 403, or not, and simply see # a 404 response. diff --git a/apps/permission/scopes.py b/apps/permission/scopes.py new file mode 100644 index 00000000..bf74cb81 --- /dev/null +++ b/apps/permission/scopes.py @@ -0,0 +1,34 @@ +# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from oauth2_provider.scopes import BaseScopes +from member.models import Club +from note_kfet.middlewares import get_current_request + +from .backends import PermissionBackend +from .models import Permission + + +class PermissionScopes(BaseScopes): + """ + An OAuth2 scope is defined by a permission object and a club. + A token will have a subset of permissions from the owner of the application, + and can be useful to make queries through the API with limited privileges. + """ + + def get_all_scopes(self): + return {f"{p.id}_{club.id}": f"{p.description} (club {club.name})" + for p in Permission.objects.all() for club in Club.objects.all()} + + def get_available_scopes(self, application=None, request=None, *args, **kwargs): + if not application: + return [] + return [f"{p.id}_{p.membership.club.id}" + for t in Permission.PERMISSION_TYPES + for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0])] + + def get_default_scopes(self, application=None, request=None, *args, **kwargs): + if not application: + return [] + return [f"{p.id}_{p.membership.club.id}" + for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')] diff --git a/apps/permission/signals.py b/apps/permission/signals.py index b419ce09..78d0b8f9 100644 --- a/apps/permission/signals.py +++ b/apps/permission/signals.py @@ -3,7 +3,7 @@ from django.core.exceptions import PermissionDenied from django.utils.translation import gettext_lazy as _ -from note_kfet.middlewares import get_current_authenticated_user +from note_kfet.middlewares import get_current_request from permission.backends import PermissionBackend @@ -16,6 +16,9 @@ EXCLUDED = [ 'contenttypes.contenttype', 'logs.changelog', 'migrations.migration', + 'oauth2_provider.accesstoken', + 'oauth2_provider.grant', + 'oauth2_provider.refreshtoken', 'sessions.session', ] @@ -31,8 +34,8 @@ def pre_save_object(sender, instance, **kwargs): if hasattr(instance, "_force_save") or hasattr(instance, "_no_signal"): return - user = get_current_authenticated_user() - if user is None: + request = get_current_request() + if request is None: # Action performed on shell is always granted return @@ -45,7 +48,7 @@ def pre_save_object(sender, instance, **kwargs): # We check if the user can change the model # If the user has all right on a model, then OK - if PermissionBackend.check_perm(user, app_label + ".change_" + model_name, instance): + if PermissionBackend.check_perm(request, app_label + ".change_" + model_name, instance): return # In the other case, we check if he/she has the right to change one field @@ -58,7 +61,8 @@ def pre_save_object(sender, instance, **kwargs): # If the field wasn't modified, no need to check the permissions if old_value == new_value: continue - if not PermissionBackend.check_perm(user, app_label + ".change_" + model_name + "_" + field_name, instance): + if not PermissionBackend.check_perm(request, app_label + ".change_" + model_name + "_" + field_name, + instance): raise PermissionDenied( _("You don't have the permission to change the field {field} on this instance of model" " {app_label}.{model_name}.") @@ -66,7 +70,7 @@ def pre_save_object(sender, instance, **kwargs): ) else: # We check if the user has right to add the object - has_perm = PermissionBackend.check_perm(user, app_label + ".add_" + model_name, instance) + has_perm = PermissionBackend.check_perm(request, app_label + ".add_" + model_name, instance) if not has_perm: raise PermissionDenied( @@ -87,8 +91,8 @@ def pre_delete_object(instance, **kwargs): # Don't check permissions on force-deleted objects return - user = get_current_authenticated_user() - if user is None: + request = get_current_request() + if request is None: # Action performed on shell is always granted return @@ -97,7 +101,7 @@ def pre_delete_object(instance, **kwargs): model_name = model_name_full[1] # We check if the user has rights to delete the object - if not PermissionBackend.check_perm(user, app_label + ".delete_" + model_name, instance): + if not PermissionBackend.check_perm(request, app_label + ".delete_" + model_name, instance): raise PermissionDenied( _("You don't have the permission to delete this instance of model {app_label}.{model_name}.") .format(app_label=app_label, model_name=model_name)) diff --git a/apps/permission/tables.py b/apps/permission/tables.py index 9e82fa8e..eaec5138 100644 --- a/apps/permission/tables.py +++ b/apps/permission/tables.py @@ -8,7 +8,7 @@ from django.urls import reverse_lazy from django.utils.html import format_html from django_tables2 import A from member.models import Membership -from note_kfet.middlewares import get_current_authenticated_user +from note_kfet.middlewares import get_current_request from permission.backends import PermissionBackend @@ -20,7 +20,7 @@ class RightsTable(tables.Table): def render_user(self, value): # If the user has the right, link the displayed user with the page of its detail. s = value.username - if PermissionBackend.check_perm(get_current_authenticated_user(), "auth.view_user", value): + if PermissionBackend.check_perm(get_current_request(), "auth.view_user", value): s = format_html("{name}", url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s) return s @@ -28,7 +28,7 @@ class RightsTable(tables.Table): def render_club(self, value): # If the user has the right, link the displayed user with the page of its detail. s = value.name - if PermissionBackend.check_perm(get_current_authenticated_user(), "member.view_club", value): + if PermissionBackend.check_perm(get_current_request(), "member.view_club", value): s = format_html("{name}", url=reverse_lazy('member:club_detail', kwargs={"pk": value.pk}), name=s) @@ -42,7 +42,7 @@ class RightsTable(tables.Table): | Q(name="Bureau de club")) & Q(weirole__isnull=True))).all() s = ", ".join(str(role) for role in roles) - if PermissionBackend.check_perm(get_current_authenticated_user(), "member.change_membership_roles", record): + if PermissionBackend.check_perm(get_current_request(), "member.change_membership_roles", record): s = format_html("" + s + "") return s diff --git a/apps/permission/templates/permission/scopes.html b/apps/permission/templates/permission/scopes.html new file mode 100644 index 00000000..31a4395e --- /dev/null +++ b/apps/permission/templates/permission/scopes.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block content %} +
+
+

{% trans "Available scopes" %}

+
+
+
+ {% for app, app_scopes in scopes.items %} +
+ +
+
+ {% for scope_id, scope_desc in app_scopes.items %} +
+ +
+ {% endfor %} +

+ + {{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code + +

+
+
+
+ {% empty %} +

+ {% trans "No applications defined" %}. + {% trans "Click here" %} {% trans "if you want to register a new one" %}. +

+ {% endfor %} +
+
+
+{% endblock %} + +{% block extrajavascript %} + +{% endblock %} diff --git a/apps/permission/templatetags/perms.py b/apps/permission/templatetags/perms.py index 2fb376d4..17f336f4 100644 --- a/apps/permission/templatetags/perms.py +++ b/apps/permission/templatetags/perms.py @@ -1,12 +1,12 @@ # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes.models import ContentType from django.template.defaultfilters import stringfilter from django import template -from note_kfet.middlewares import get_current_authenticated_user, get_current_session -from permission.backends import PermissionBackend +from note_kfet.middlewares import get_current_request + +from ..backends import PermissionBackend @stringfilter @@ -14,9 +14,10 @@ def not_empty_model_list(model_name): """ Return True if and only if the current user has right to see any object of the given model. """ - user = get_current_authenticated_user() - session = get_current_session() - if user is None or isinstance(user, AnonymousUser): + request = get_current_request() + user = request.user + session = request.session + if user is None or not user.is_authenticated: return False elif user.is_superuser and session.get("permission_mask", -1) >= 42: return True @@ -29,11 +30,12 @@ def model_list(model_name, t="view", fetch=True): """ Return the queryset of all visible instances of the given model. """ - user = get_current_authenticated_user() + request = get_current_request() + user = request.user spl = model_name.split(".") ct = ContentType.objects.get(app_label=spl[0], model=spl[1]) - qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(user, ct, t)) - if user is None or isinstance(user, AnonymousUser): + qs = ct.model_class().objects.filter(PermissionBackend.filter_queryset(request, ct, t)) + if user is None or not user.is_authenticated: return qs.none() if fetch: qs = qs.all() @@ -49,7 +51,7 @@ def model_list_length(model_name, t="view"): def has_perm(perm, obj): - return PermissionBackend.check_perm(get_current_authenticated_user(), perm, obj) + return PermissionBackend.check_perm(get_current_request(), perm, obj) register = template.Library() diff --git a/apps/permission/tests/test_oauth2.py b/apps/permission/tests/test_oauth2.py new file mode 100644 index 00000000..4593b35a --- /dev/null +++ b/apps/permission/tests/test_oauth2.py @@ -0,0 +1,94 @@ +# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from datetime import timedelta + +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone +from django.utils.crypto import get_random_string +from member.models import Membership, Club +from note.models import NoteUser +from oauth2_provider.models import Application, AccessToken + +from ..models import Role, Permission + + +class OAuth2TestCase(TestCase): + fixtures = ('initial', ) + + def setUp(self): + self.user = User.objects.create( + username="toto", + ) + self.application = Application.objects.create( + name="Test", + client_type=Application.CLIENT_PUBLIC, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + user=self.user, + ) + + def test_oauth2_access(self): + """ + Create a simple OAuth2 access token that only has the right to see data of the current user + and check that this token has required access, and nothing more. + """ + + bde = Club.objects.get(name="BDE") + view_user_perm = Permission.objects.get(pk=1) # View own user detail + + # Create access token that has access to our own user detail + token = AccessToken.objects.create( + user=self.user, + application=self.application, + scope=f"{view_user_perm.pk}_{bde.pk}", + token=get_random_string(64), + expires=timezone.now() + timedelta(days=365), + ) + + # No access without token + resp = self.client.get(f'/api/user/{self.user.pk}/') + self.assertEqual(resp.status_code, 403) + + # Valid token but user has no membership, so the query is not returning the user object + resp = self.client.get(f'/api/user/{self.user.pk}/', **{'Authorization': f'Bearer {token.token}'}) + self.assertEqual(resp.status_code, 404) + + # Create membership to validate permissions + NoteUser.objects.create(user=self.user) + membership = Membership.objects.create(user=self.user, club_id=bde.pk) + membership.roles.add(Role.objects.get(name="Adhérent BDE")) + membership.save() + + # User is now a member and can now see its own user detail + resp = self.client.get(f'/api/user/{self.user.pk}/', **{'Authorization': f'Bearer {token.token}'}) + self.assertEqual(resp.status_code, 200) + + # Token is not granted to see profile detail + resp = self.client.get(f'/api/members/profile/{self.user.profile.pk}/', + **{'Authorization': f'Bearer {token.token}'}) + self.assertEqual(resp.status_code, 404) + + def test_scopes(self): + """ + Ensure that the scopes page is loading. + """ + self.client.force_login(self.user) + + resp = self.client.get(reverse('permission:scopes')) + self.assertEqual(resp.status_code, 200) + self.assertIn(self.application, resp.context['scopes']) + self.assertNotIn('1_1', resp.context['scopes'][self.application]) # The user has not this permission + + # Create membership to validate permissions + bde = Club.objects.get(name="BDE") + NoteUser.objects.create(user=self.user) + membership = Membership.objects.create(user=self.user, club_id=bde.pk) + membership.roles.add(Role.objects.get(name="Adhérent BDE")) + membership.save() + + resp = self.client.get(reverse('permission:scopes')) + self.assertEqual(resp.status_code, 200) + self.assertIn(self.application, resp.context['scopes']) + self.assertIn('1_1', resp.context['scopes'][self.application]) # Now the user has this permission diff --git a/apps/permission/urls.py b/apps/permission/urls.py index 0894ecf0..43eec1ef 100644 --- a/apps/permission/urls.py +++ b/apps/permission/urls.py @@ -1,10 +1,17 @@ # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later +from django.conf import settings from django.urls import path -from permission.views import RightsView + +from .views import RightsView, ScopesView app_name = 'permission' urlpatterns = [ - path('rights', RightsView.as_view(), name="rights"), + path('rights/', RightsView.as_view(), name="rights"), ] + +if "oauth2_provider" in settings.INSTALLED_APPS: + urlpatterns += [ + path('scopes/', ScopesView.as_view(), name="scopes"), + ] diff --git a/apps/permission/views.py b/apps/permission/views.py index c48215a0..8f498478 100644 --- a/apps/permission/views.py +++ b/apps/permission/views.py @@ -1,6 +1,6 @@ # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later - +from collections import OrderedDict from datetime import date from django.contrib.auth.mixins import LoginRequiredMixin @@ -28,7 +28,7 @@ class ProtectQuerysetMixin: """ def get_queryset(self, filter_permissions=True, **kwargs): qs = super().get_queryset(**kwargs) - return qs.filter(PermissionBackend.filter_queryset(self.request.user, qs.model, "view")).distinct()\ + return qs.filter(PermissionBackend.filter_queryset(self.request, qs.model, "view")).distinct()\ if filter_permissions else qs def get_object(self, queryset=None): @@ -53,7 +53,7 @@ class ProtectQuerysetMixin: # We could also delete the field, but some views might be affected. meta = form.instance._meta for key in form.base_fields: - if not PermissionBackend.check_perm(self.request.user, + if not PermissionBackend.check_perm(self.request, f"{meta.app_label}.change_{meta.model_name}_" + key, self.object): form.fields[key].widget = HiddenInput() @@ -101,7 +101,7 @@ class ProtectedCreateView(LoginRequiredMixin, CreateView): # noinspection PyProtectedMember app_label, model_name = model_class._meta.app_label, model_class._meta.model_name.lower() perm = app_label + ".add_" + model_name - if not PermissionBackend.check_perm(request.user, perm, self.get_sample_object()): + if not PermissionBackend.check_perm(request, perm, self.get_sample_object()): raise PermissionDenied(_("You don't have the permission to add an instance of model " "{app_label}.{model_name}.").format(app_label=app_label, model_name=model_name)) return super().dispatch(request, *args, **kwargs) @@ -143,3 +143,26 @@ class RightsView(TemplateView): prefix="superusers-") return context + + +class ScopesView(LoginRequiredMixin, TemplateView): + template_name = "permission/scopes.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + from oauth2_provider.models import Application + from .scopes import PermissionScopes + + scopes = PermissionScopes() + context["scopes"] = {} + all_scopes = scopes.get_all_scopes() + for app in Application.objects.filter(user=self.request.user).all(): + available_scopes = scopes.get_available_scopes(app) + context["scopes"][app] = OrderedDict() + items = [(k, v) for (k, v) in all_scopes.items() if k in available_scopes] + items.sort(key=lambda x: (int(x[0].split("_")[1]), int(x[0].split("_")[0]))) + for k, v in items: + context["scopes"][app][k] = v + + return context diff --git a/apps/registration/views.py b/apps/registration/views.py index 746c856b..9b385324 100644 --- a/apps/registration/views.py +++ b/apps/registration/views.py @@ -66,9 +66,11 @@ class UserCreateView(CreateView): profile_form.instance.user = user profile = profile_form.save(commit=False) user.profile = profile + user._force_save = True user.save() user.refresh_from_db() profile.user = user + profile._force_save = True profile.save() user.profile.send_email_validation_link() @@ -110,7 +112,9 @@ class UserValidateView(TemplateView): self.validlink = True user.is_active = user.profile.registration_valid or user.is_superuser user.profile.email_confirmed = True + user._force_save = True user.save() + user.profile._force_save = True user.profile.save() return self.render_to_response(self.get_context_data(), status=200 if self.validlink else 400) @@ -230,9 +234,6 @@ class FutureUserDetailView(ProtectQuerysetMixin, LoginRequiredMixin, FormMixin, fee += bde.membership_fee_paid if user.profile.paid else bde.membership_fee_unpaid kfet = Club.objects.get(name="Kfet") fee += kfet.membership_fee_paid if user.profile.paid else kfet.membership_fee_unpaid - # In 2020, for COVID-19 reasons, the BDE offered 80 € to each new member that opens a Sogé account, - # since there is no WEI. - fee += 8000 ctx["total_fee"] = "{:.02f}".format(fee / 100, ) ctx["declare_soge_account"] = SogeCredit.objects.filter(user=user).exists() @@ -387,7 +388,7 @@ class FutureUserInvalidateView(ProtectQuerysetMixin, LoginRequiredMixin, View): Delete the pre-registered user which id is given in the URL. """ user = User.objects.filter(profile__registration_valid=False)\ - .filter(PermissionBackend.filter_queryset(request.user, User, "change", "is_valid"))\ + .filter(PermissionBackend.filter_queryset(request, User, "change", "is_valid"))\ .get(pk=self.kwargs["pk"]) # Delete associated soge credits before SogeCredit.objects.filter(user=user).delete() diff --git a/apps/treasury/models.py b/apps/treasury/models.py index 2e86245f..0b5948fd 100644 --- a/apps/treasury/models.py +++ b/apps/treasury/models.py @@ -303,7 +303,7 @@ class SogeCredit(models.Model): @property def amount(self): return self.credit_transaction.total if self.valid \ - else sum(transaction.total for transaction in self.transactions.all()) + 8000 + else sum(transaction.total for transaction in self.transactions.all()) def invalidate(self): """ diff --git a/apps/treasury/views.py b/apps/treasury/views.py index 47544cc1..b9a7fe7c 100644 --- a/apps/treasury/views.py +++ b/apps/treasury/views.py @@ -107,7 +107,7 @@ class InvoiceListView(LoginRequiredMixin, SingleTableView): name="", address="", ) - if not PermissionBackend.check_perm(self.request.user, "treasury.add_invoice", sample_invoice): + if not PermissionBackend.check_perm(self.request, "treasury.add_invoice", sample_invoice): raise PermissionDenied(_("You are not able to see the treasury interface.")) return super().dispatch(request, *args, **kwargs) @@ -194,7 +194,7 @@ class InvoiceRenderView(LoginRequiredMixin, View): def get(self, request, **kwargs): pk = kwargs["pk"] - invoice = Invoice.objects.filter(PermissionBackend.filter_queryset(request.user, Invoice, "view")).get(pk=pk) + invoice = Invoice.objects.filter(PermissionBackend.filter_queryset(request, Invoice, "view")).get(pk=pk) tex = invoice.tex try: @@ -259,7 +259,7 @@ class RemittanceCreateView(ProtectQuerysetMixin, ProtectedCreateView): context["table"] = RemittanceTable( data=Remittance.objects.filter( - PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all()) + PermissionBackend.filter_queryset(self.request, Remittance, "view")).all()) context["special_transactions"] = SpecialTransactionTable(data=SpecialTransaction.objects.none()) return context @@ -281,7 +281,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView): remittance_type_id=1, comment="", ) - if not PermissionBackend.check_perm(self.request.user, "treasury.add_remittance", sample_remittance): + if not PermissionBackend.check_perm(self.request, "treasury.add_remittance", sample_remittance): raise PermissionDenied(_("You are not able to see the treasury interface.")) return super().dispatch(request, *args, **kwargs) @@ -290,7 +290,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView): opened_remittances = RemittanceTable( data=Remittance.objects.filter(closed=False).filter( - PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(), + PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(), prefix="opened-remittances-", ) opened_remittances.paginate(page=self.request.GET.get("opened-remittances-page", 1), per_page=10) @@ -298,7 +298,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView): closed_remittances = RemittanceTable( data=Remittance.objects.filter(closed=True).filter( - PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(), + PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(), prefix="closed-remittances-", ) closed_remittances.paginate(page=self.request.GET.get("closed-remittances-page", 1), per_page=10) @@ -307,7 +307,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView): no_remittance_tr = SpecialTransactionTable( data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)), specialtransactionproxy__remittance=None).filter( - PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(), + PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(), exclude=('remittance_remove', ), prefix="no-remittance-", ) @@ -317,7 +317,7 @@ class RemittanceListView(LoginRequiredMixin, TemplateView): with_remittance_tr = SpecialTransactionTable( data=SpecialTransaction.objects.filter(source__in=NoteSpecial.objects.filter(~Q(remittancetype=None)), specialtransactionproxy__remittance__closed=False).filter( - PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all(), + PermissionBackend.filter_queryset(self.request, Remittance, "view")).all(), exclude=('remittance_add', ), prefix="with-remittance-", ) @@ -342,7 +342,7 @@ class RemittanceUpdateView(ProtectQuerysetMixin, LoginRequiredMixin, UpdateView) context = super().get_context_data(**kwargs) data = SpecialTransaction.objects.filter(specialtransactionproxy__remittance=self.object).filter( - PermissionBackend.filter_queryset(self.request.user, Remittance, "view")).all() + PermissionBackend.filter_queryset(self.request, Remittance, "view")).all() context["special_transactions"] = SpecialTransactionTable( data=data, exclude=('remittance_add', 'remittance_remove', ) if self.object.closed else ('remittance_add', )) diff --git a/apps/wei/forms/surveys/base.py b/apps/wei/forms/surveys/base.py index ec0bc980..030f9078 100644 --- a/apps/wei/forms/surveys/base.py +++ b/apps/wei/forms/surveys/base.py @@ -53,7 +53,8 @@ class WEIBusInformation: def free_seats(self, surveys: List["WEISurvey"] = None): size = self.bus.size already_occupied = WEIMembership.objects.filter(bus=self.bus).count() - valid_surveys = sum(1 for survey in surveys if survey.information.valid) if surveys else 0 + valid_surveys = sum(1 for survey in surveys if survey.information.valid + and survey.information.get_selected_bus() == self.bus) if surveys else 0 return size - already_occupied - valid_surveys def has_free_seats(self, surveys=None): diff --git a/apps/wei/forms/surveys/wei2021.py b/apps/wei/forms/surveys/wei2021.py index 49c1c628..8d5adfad 100644 --- a/apps/wei/forms/surveys/wei2021.py +++ b/apps/wei/forms/surveys/wei2021.py @@ -16,7 +16,7 @@ WORDS = [ 'Fanfare', 'Fracassage', 'Féria', 'Hard rock', 'Hoeggarden', 'House', 'Huit-six', 'IPA', 'Inclusif', 'Inferno', 'Introverti', 'Jager bomb', 'Jazz', 'Jeux d\'alcool', 'Jeux de rôles', 'Jeux vidéo', 'Jul', 'Jus de fruit', 'Karaoké', 'LGBTQI+', 'Lady Gaga', 'Loup garou', 'Morning beer', 'Métal', 'Nuit blanche', 'Ovalie', 'Psychedelic', - 'Pétanque', 'Rave', 'Reggae', 'Rhum', 'Ricard', 'Rock', 'Rosé', 'Rétro', 'Séducteur', 'Techno', 'Thérapie taxi', + 'Pétanque', 'Rave', 'Reggae', 'Rhum', 'Ricard', 'Rock', 'Rosé', 'Rétro', 'Séducteur', 'Techno', 'Thérapie taxi', 'Théâtre', 'Trap', 'Turn up', 'Underground', 'Volley', 'Wati B', 'Zinédine Zidane', ] @@ -45,9 +45,9 @@ class WEISurveyForm2021(forms.Form): rng = Random(information.seed) words = [] - for _ in range(information.step + 1): + for _ignored in range(information.step + 1): # Generate N times words - words = [rng.choice(WORDS) for _ in range(10)] + words = [rng.choice(WORDS) for _ignored2 in range(10)] words = [(w, w) for w in words] if self.data: self.fields["word"].choices = [(w, w) for w in WORDS] @@ -162,7 +162,7 @@ class WEISurveyAlgorithm2021(WEISurveyAlgorithm): while free_surveys: # Some students are not affected survey = free_surveys[0] buses = survey.ordered_buses() # Preferences of the student - for bus, _ in buses: + for bus, _ignored in buses: if self.get_bus_information(bus).has_free_seats(surveys): # Selected bus has free places. Put student in the bus survey.select_bus(bus) @@ -190,6 +190,9 @@ class WEISurveyAlgorithm2021(WEISurveyAlgorithm): # If it does not exist, choose the next bus. least_preferred_survey.free() least_preferred_survey.save() + free_surveys.append(least_preferred_survey) survey.select_bus(bus) survey.save() break + else: + raise ValueError(f"User {survey.registration.user} has no free seat") diff --git a/apps/wei/management/commands/wei_algorithm.py b/apps/wei/management/commands/wei_algorithm.py index 152ca813..558dfae4 100644 --- a/apps/wei/management/commands/wei_algorithm.py +++ b/apps/wei/management/commands/wei_algorithm.py @@ -5,7 +5,7 @@ from argparse import ArgumentParser, FileType from django.core.management import BaseCommand from django.db import transaction -from wei.forms import CurrentSurvey +from ...forms import CurrentSurvey class Command(BaseCommand): diff --git a/apps/wei/tables.py b/apps/wei/tables.py index 274e8dbd..b2e55508 100644 --- a/apps/wei/tables.py +++ b/apps/wei/tables.py @@ -8,7 +8,7 @@ from django.urls import reverse_lazy from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ from django_tables2 import A -from note_kfet.middlewares import get_current_authenticated_user +from note_kfet.middlewares import get_current_request from permission.backends import PermissionBackend from .models import WEIClub, WEIRegistration, Bus, BusTeam, WEIMembership @@ -85,7 +85,7 @@ class WEIRegistrationTable(tables.Table): def render_validate(self, record): hasperm = PermissionBackend.check_perm( - get_current_authenticated_user(), "wei.add_weimembership", WEIMembership( + get_current_request(), "wei.add_weimembership", WEIMembership( club=record.wei, user=record.user, date_start=date.today(), @@ -110,7 +110,7 @@ class WEIRegistrationTable(tables.Table): f"title=\"{tooltip}\" href=\"{url}\">{text}") def render_delete(self, record): - hasperm = PermissionBackend.check_perm(get_current_authenticated_user(), "wei.delete_weimembership", record) + hasperm = PermissionBackend.check_perm(get_current_request(), "wei.delete_weimembership", record) return _("Delete") if hasperm else format_html("") class Meta: diff --git a/apps/wei/tests/test_wei_algorithm_2021.py b/apps/wei/tests/test_wei_algorithm_2021.py new file mode 100644 index 00000000..e1aab59b --- /dev/null +++ b/apps/wei/tests/test_wei_algorithm_2021.py @@ -0,0 +1,109 @@ +# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +import random + +from django.contrib.auth.models import User +from django.test import TestCase + +from ..forms.surveys.wei2021 import WEIBusInformation2021, WEISurvey2021, WORDS, WEISurveyInformation2021 +from ..models import Bus, WEIClub, WEIRegistration + + +class TestWEIAlgorithm(TestCase): + """ + Run some tests to ensure that the WEI algorithm is working well. + """ + fixtures = ('initial',) + + def setUp(self): + """ + Create some test data, with one WEI and 10 buses with random score attributions. + """ + self.wei = WEIClub.objects.create( + name="WEI 2021", + email="wei2021@example.com", + date_start='2021-09-17', + date_end='2021-09-19', + ) + + self.buses = [] + for i in range(10): + bus = Bus.objects.create(wei=self.wei, name=f"Bus {i}", size=10) + self.buses.append(bus) + information = WEIBusInformation2021(bus) + for word in WORDS: + information.scores[word] = random.randint(0, 101) + information.save() + bus.save() + + def test_survey_algorithm_small(self): + """ + There are only a few people in each bus, ensure that each person has its best bus + """ + # Add a few users + for i in range(10): + user = User.objects.create(username=f"user{i}") + registration = WEIRegistration.objects.create( + user=user, + wei=self.wei, + first_year=True, + birth_date='2000-01-01', + ) + information = WEISurveyInformation2021(registration) + for j in range(1, 21): + setattr(information, f'word{j}', random.choice(WORDS)) + information.step = 20 + information.save(registration) + registration.save() + + # Run algorithm + WEISurvey2021.get_algorithm_class()().run_algorithm() + + # Ensure that everyone has its first choice + for r in WEIRegistration.objects.filter(wei=self.wei).all(): + survey = WEISurvey2021(r) + preferred_bus = survey.ordered_buses()[0][0] + chosen_bus = survey.information.get_selected_bus() + self.assertEqual(preferred_bus, chosen_bus) + + def test_survey_algorithm_full(self): + """ + Buses are full of first year people, ensure that they are happy + """ + # Add a lot of users + for i in range(95): + user = User.objects.create(username=f"user{i}") + registration = WEIRegistration.objects.create( + user=user, + wei=self.wei, + first_year=True, + birth_date='2000-01-01', + ) + information = WEISurveyInformation2021(registration) + for j in range(1, 21): + setattr(information, f'word{j}', random.choice(WORDS)) + information.step = 20 + information.save(registration) + registration.save() + + # Run algorithm + WEISurvey2021.get_algorithm_class()().run_algorithm() + + penalty = 0 + # Ensure that everyone seems to be happy + # We attribute a penalty for each user that didn't have its first choice + # The penalty is the square of the distance between the score of the preferred bus + # and the score of the attributed bus + # We consider it acceptable if the mean of this distance is lower than 5 % + for r in WEIRegistration.objects.filter(wei=self.wei).all(): + survey = WEISurvey2021(r) + chosen_bus = survey.information.get_selected_bus() + buses = survey.ordered_buses() + score = min(v for bus, v in buses if bus == chosen_bus) + max_score = buses[0][1] + penalty += (max_score - score) ** 2 + + self.assertLessEqual(max_score - score, 25) # Always less than 25 % of tolerance + + self.assertLessEqual(penalty / 100, 25) # Tolerance of 5 % diff --git a/apps/wei/tests/test_wei_registration.py b/apps/wei/tests/test_wei_registration.py index 71eded3a..2a49a5d8 100644 --- a/apps/wei/tests/test_wei_registration.py +++ b/apps/wei/tests/test_wei_registration.py @@ -3,7 +3,6 @@ import subprocess from datetime import timedelta, date -from unittest import skip from api.tests import TestAPI from django.conf import settings @@ -813,10 +812,6 @@ class TestWEISurveyAlgorithm(TestCase): ) CurrentSurvey(self.registration).save() - @skip # FIXME Write good unit tests - def test_survey_algorithm(self): - CurrentSurvey.get_algorithm_class()().run_algorithm() - class TestWeiAPI(TestAPI): def setUp(self) -> None: diff --git a/apps/wei/views.py b/apps/wei/views.py index 04efe954..cf7c3911 100644 --- a/apps/wei/views.py +++ b/apps/wei/views.py @@ -57,7 +57,7 @@ class WEIListView(ProtectQuerysetMixin, LoginRequiredMixin, SingleTableView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["can_create_wei"] = PermissionBackend.check_perm(self.request.user, "wei.add_weiclub", WEIClub( + context["can_create_wei"] = PermissionBackend.check_perm(self.request, "wei.add_weiclub", WEIClub( name="", email="weiclub@example.com", year=0, @@ -112,7 +112,7 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): club = context["club"] club_transactions = Transaction.objects.all().filter(Q(source=club.note) | Q(destination=club.note)) \ - .filter(PermissionBackend.filter_queryset(self.request.user, Transaction, "view")) \ + .filter(PermissionBackend.filter_queryset(self.request, Transaction, "view")) \ .order_by('-created_at', '-id') history_table = HistoryTable(club_transactions, prefix="history-") history_table.paginate(per_page=20, page=self.request.GET.get('history-page', 1)) @@ -121,13 +121,13 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): club_member = WEIMembership.objects.filter( club=club, date_end__gte=date.today(), - ).filter(PermissionBackend.filter_queryset(self.request.user, WEIMembership, "view")) + ).filter(PermissionBackend.filter_queryset(self.request, WEIMembership, "view")) membership_table = WEIMembershipTable(data=club_member, prefix="membership-") membership_table.paginate(per_page=20, page=self.request.GET.get('membership-page', 1)) context['member_list'] = membership_table pre_registrations = WEIRegistration.objects.filter( - PermissionBackend.filter_queryset(self.request.user, WEIRegistration, "view")).filter( + PermissionBackend.filter_queryset(self.request, WEIRegistration, "view")).filter( membership=None, wei=club ) @@ -142,7 +142,7 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): my_registration = None context["my_registration"] = my_registration - buses = Bus.objects.filter(PermissionBackend.filter_queryset(self.request.user, Bus, "view")) \ + buses = Bus.objects.filter(PermissionBackend.filter_queryset(self.request, Bus, "view")) \ .filter(wei=self.object).annotate(count=Count("memberships")).order_by("name") bus_table = BusTable(data=buses, prefix="bus-") context['buses'] = bus_table @@ -167,7 +167,7 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): emergency_contact_phone="No", ) context["can_add_first_year_member"] = PermissionBackend \ - .check_perm(self.request.user, "wei.add_weiregistration", empty_fy_registration) + .check_perm(self.request, "wei.add_weiregistration", empty_fy_registration) # Check if the user has the right to create a registration of a random old member. empty_old_registration = WEIRegistration( @@ -180,13 +180,13 @@ class WEIDetailView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): emergency_contact_phone="No", ) context["can_add_any_member"] = PermissionBackend \ - .check_perm(self.request.user, "wei.add_weiregistration", empty_old_registration) + .check_perm(self.request, "wei.add_weiregistration", empty_old_registration) empty_bus = Bus( wei=club, name="", ) - context["can_add_bus"] = PermissionBackend.check_perm(self.request.user, "wei.add_bus", empty_bus) + context["can_add_bus"] = PermissionBackend.check_perm(self.request, "wei.add_bus", empty_bus) context["not_first_year"] = WEIMembership.objects.filter(user=self.request.user).exists() @@ -370,13 +370,13 @@ class BusManageView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): context["club"] = self.object.wei bus = self.object - teams = BusTeam.objects.filter(PermissionBackend.filter_queryset(self.request.user, BusTeam, "view")) \ + teams = BusTeam.objects.filter(PermissionBackend.filter_queryset(self.request, BusTeam, "view")) \ .filter(bus=bus).annotate(count=Count("memberships")).order_by("name") teams_table = BusTeamTable(data=teams, prefix="team-") context["teams"] = teams_table memberships = WEIMembership.objects.filter(PermissionBackend.filter_queryset( - self.request.user, WEIMembership, "view")).filter(bus=bus) + self.request, WEIMembership, "view")).filter(bus=bus) memberships_table = WEIMembershipTable(data=memberships, prefix="membership-") memberships_table.paginate(per_page=20, page=self.request.GET.get("membership-page", 1)) context["memberships"] = memberships_table @@ -469,7 +469,7 @@ class BusTeamManageView(ProtectQuerysetMixin, LoginRequiredMixin, DetailView): context["club"] = self.object.bus.wei memberships = WEIMembership.objects.filter(PermissionBackend.filter_queryset( - self.request.user, WEIMembership, "view")).filter(team=self.object) + self.request, WEIMembership, "view")).filter(team=self.object) memberships_table = WEIMembershipTable(data=memberships, prefix="membership-") memberships_table.paginate(per_page=20, page=self.request.GET.get("membership-page", 1)) context["memberships"] = memberships_table @@ -659,7 +659,7 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update data=self.request.POST if self.request.POST else None) for field_name, field in membership_form.fields.items(): if not PermissionBackend.check_perm( - self.request.user, "wei.change_membership_" + field_name, self.object.membership): + self.request, "wei.change_membership_" + field_name, self.object.membership): field.widget = HiddenInput() del membership_form.fields["credit_type"] del membership_form.fields["credit_amount"] @@ -668,7 +668,7 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update del membership_form.fields["bank"] context["membership_form"] = membership_form elif not self.object.first_year and PermissionBackend.check_perm( - self.request.user, "wei.change_weiregistration_information_json", self.object): + self.request, "wei.change_weiregistration_information_json", self.object): choose_bus_form = WEIChooseBusForm( self.request.POST if self.request.POST else dict( bus=Bus.objects.filter(pk__in=self.object.information["preferred_bus_pk"]).all(), @@ -704,7 +704,7 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update membership_form.save() # If it is not validated and if this is an old member, then we update the choices elif not form.instance.first_year and PermissionBackend.check_perm( - self.request.user, "wei.change_weiregistration_information_json", self.object): + self.request, "wei.change_weiregistration_information_json", self.object): choose_bus_form = WEIChooseBusForm(self.request.POST) if not choose_bus_form.is_valid(): return self.form_invalid(form) @@ -726,7 +726,7 @@ class WEIUpdateRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Update survey = CurrentSurvey(self.object) if not survey.is_complete(): return reverse_lazy("wei:wei_survey", kwargs={"pk": self.object.pk}) - if PermissionBackend.check_perm(self.request.user, "wei.add_weimembership", WEIMembership( + if PermissionBackend.check_perm(self.request, "wei.add_weimembership", WEIMembership( club=self.object.wei, user=self.object.user, date_start=date.today(), @@ -753,7 +753,7 @@ class WEIDeleteRegistrationView(ProtectQuerysetMixin, LoginRequiredMixin, Delete if today > wei.membership_end: return redirect(reverse_lazy('wei:wei_closed', args=(wei.pk,))) - if not PermissionBackend.check_perm(self.request.user, "wei.delete_weiregistration", object): + if not PermissionBackend.check_perm(self.request, "wei.delete_weiregistration", object): raise PermissionDenied(_("You don't have the right to delete this WEI registration.")) return super().dispatch(request, *args, **kwargs) @@ -1049,7 +1049,7 @@ class MemberListRenderView(LoginRequiredMixin, View): """ def get_queryset(self, **kwargs): - qs = WEIMembership.objects.filter(PermissionBackend.filter_queryset(self.request.user, WEIMembership, "view")) + qs = WEIMembership.objects.filter(PermissionBackend.filter_queryset(self.request, WEIMembership, "view")) qs = qs.filter(club__pk=self.kwargs["wei_pk"]).order_by( Lower('bus__name'), Lower('team__name'), diff --git a/docs/external_services/oauth2.rst b/docs/external_services/oauth2.rst index 3f1eee2c..1033030e 100644 --- a/docs/external_services/oauth2.rst +++ b/docs/external_services/oauth2.rst @@ -5,19 +5,10 @@ L'authentification `OAuth2 `_ est supporté Note Kfet. Elle offre l'avantage non seulement d'identifier les utilisateurs, mais aussi de transmettre des informations à un service tiers tels que des informations personnelles, le solde de la note ou encore les adhésions de l'utilisateur, en l'avertissant sur -quelles données sont effectivement collectées. +quelles données sont effectivement collectées. Ainsi, il est possible de développer des +appplications tierces qui peuvent se baser sur les données de la Note Kfet ou encore +faire des transactions. -.. danger:: - L'implémentation actuelle ne permet pas de choisir quels droits on offre. Se connecter - par OAuth2 offre actuellement exactement les mêmes permissions que l'on n'aurait - normalement, avec le masque le plus haut, y compris en écriture. - - Faites alors très attention lorsque vous vous connectez à un service tiers via OAuth2, - et contrôlez bien exactement ce que l'application fait de vos données, à savoir si - elle ignore bien tout ce dont elle n'a pas besoin. - - À l'avenir, la fenêtre d'authentification pourra vous indiquer clairement quels - paramètres sont collectés. Configuration du serveur ------------------------ @@ -44,7 +35,15 @@ l'authentification OAuth2. On adapte alors la configuration pour permettre cela ... } -On ajoute les routes dans ``urls.py`` : +On a ensuite besoin de définir nos propres scopes afin d'avoir des permissions fines : + +.. code:: python + + OAUTH2_PROVIDER = { + 'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes', + } + +On ajoute enfin les routes dans ``urls.py`` : .. code:: python @@ -58,8 +57,7 @@ L'OAuth2 est désormais prêt à être utilisé. Configuration client -------------------- -Contrairement au `CAS `_, n'importe qui peut en théorie créer une application OAuth2. -En théorie, car pour l'instant les permissions ne leur permettent pas. +Contrairement au `CAS `_, n'importe qui peut créer une application OAuth2. Pour créer une application, il faut se rendre à la page `/o/applications/ `_. Dans ``client type``, @@ -72,14 +70,30 @@ Il vous suffit de donner à votre application : * L'identifiant client (client-ID) * La clé secrète -* Les scopes : sous-ensemble de ``[read, write]`` (ignoré pour l'instant, cf premier paragraphe) +* Les scopes, qui peuvent être récupérées sur cette page : ``_ * L'URL d'autorisation : ``_ * L'URL d'obtention de jeton : ``_ -* L'URL de récupération des informations de l'utilisateur : ``_ +* Si besoin, l'URL de récupération des informations de l'utilisateur : ``_ N'hésitez pas à consulter la page ``_ pour s'imprégner du format renvoyé. +.. warning:: + + Un petit mot sur les scopes : tel qu'implémenté, une scope est une permission unitaire + (telle que décrite dans le modèle ``Permission``) associée à un club. Ainsi, un jeton + a accès à une scope si et seulement si le/la propriétaire du jeton dispose d'une adhésion + courante dans le club lié à la scope qui lui octroie cette permission. + + Par exemple, un jeton pourra avoir accès à la permission de créer des transactions en lien + avec un club si et seulement si le propriétaire du jeton est trésorier du club. + + La vérification des droits du propriétaire est faite systématiquement, afin de ne pas + faire confiance au jeton en cas de droits révoqués à son propriétaire. + + Vous pouvez donc contrôler le plus finement possible les permissions octroyées à vos + jetons. + Avec Django-allauth ################### @@ -131,3 +145,97 @@ alors autant le faire via un shell python : Si vous avez bien configuré ``django-allauth``, vous êtes désormais prêts par à vous connecter via la note :) Par défaut, nom, prénom, pseudo et adresse e-mail sont récupérés. Les autres données sont stockées mais inutilisées. + + +Application personnalisée +######################### + +Ce modèle vous permet de créer vos propres applications à interfacer avec la Note Kfet. + +Commencez par créer une application : ``_. +Dans ``Client type``, choisissez ``Confidential`` si des informations confidentielles sont +amenées à transiter, sinon ``public``. Choisissez ``Authorization code`` dans +``Authorization grant type``. + +Dans ``Redirect uris``, vous devez insérer l'ensemble des URL autorisées à être redirigées +à la suite d'une autorisation OAuth2. La première URL entrée sera l'URL par défaut dans le +cas où elle n'est pas explicitement indiquée lors de l'autorisation. + +.. note:: + + À des fins de tests, il est possible de laisser ``_ pour faire des + appels à la main en récupérant le jeton d'autorisation. + +Lorsqu'un client veut s'authentifier via la Note Kfet, il va devoir accéder à une page +d'authentification. La page d'autorisation est ``_, +c'est sur cette page qu'il faut rediriger les utilisateurs. Il faut mettre en paramètre GET : + +* ``client_id`` : l'identifiant client de l'application (public) ; +* ``response_type`` : mettre ``code`` ; +* ``scope`` : l'ensemble des scopes demandés, séparés par des espaces. Ces scopes peuvent + être récupérés sur la page ``_. +* ``redirect_uri`` : l'URL sur laquelle rediriger qui récupérera le code d'accès. Doit être + autorisée par l'application. À des fins de test, peut être ``_. +* ``state`` : optionnel, peut être utilisé pour permettre au client de détecter des requêtes + provenant d'autres sites. + +Sur cette page, les permissions demandées seront listées, et l'utilisateur aura le choix +d'accepter ou non. Dans les deux cas, l'utilisateur sera redirigée vers ``redirect_uri``, +avec pour paramètre GET soit le message d'erreur, soit un paramètre ``code`` correspondant +au code d'autorisation. + +Une fois ce code d'autorisation récupéré, il faut désormais récupérer le jeton d'accès. +Il faut pour cela aller sur l'URL ``_, effectuer une +requête POST avec pour arguments : + +* ``client_id`` ; +* ``client_secret`` ; +* ``grant_type`` : mettre ``authorization_code`` ; +* ``code`` : le code généré. + +À noter que le code fourni n'est disponible que pendant quelques secondes. + +À des fins de tests, on peut envoyer la requête avec ``curl`` : + +.. code:: bash + + curl -X POST https://note.crans.org/o/token/ -d "client_id=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&client_secret=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&grant_type=authorization_code&code=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + +Le serveur renverra si tout se passe bien une réponse JSON : + +.. code:: json + + { + "access_token": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "expires_in": 36000, + "token_type": "Bearer", + "scope": "1_1 1_2", + "refresh_token": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + } + +On note donc 2 jetons différents : un d'accès et un de rafraîchissement. Le jeton d'accès +est celui qui sera donné à l'API pour s'authentifier, et qui expire au bout de quelques +heures. + +Il suffit désormais d'ajouter l'en-tête ``Authorization: Bearer ACCESS_TOKEN`` pour se +connecter à la note grâce à ce jeton d'accès. + +Pour tester : + +.. code:: bash + + curl https://note.crans.org/api/me -H "Authorization: Bearer XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" + +En cas d'expiration de ce jeton d'accès, il est possible de le renouveler grâce au jeton +de rafraichissement à usage unique. Il suffit pour cela de refaire une requête sur la page +``_ avec pour paramètres : + +* ``client_id`` ; +* ``client_secret`` ; +* ``grant_type`` : mettre ``refresh_token`` ; +* ``refresh_token`` : le jeton de rafraîchissement. + +Le serveur vous fournira alors une nouvelle paire de jetons, comme précédemment. +À noter qu'un jeton de rafraîchissement est à usage unique. + +N'hésitez pas à vous renseigner sur OAuth2 pour plus d'informations. diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index f832c148..80ab332a 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-08-29 14:06+0200\n" +"POT-Creation-Date: 2021-06-15 21:17+0200\n" "PO-Revision-Date: 2020-11-16 20:02+0000\n" "Last-Translator: Yohann D'ANELLO \n" "Language-Team: French \n" @@ -540,8 +540,8 @@ msgstr "Taille maximale : 2 Mo" msgid "This image cannot be loaded." msgstr "Cette image ne peut pas être chargée." -#: apps/member/forms.py:141 apps/member/views.py:101 -#: apps/registration/forms.py:33 apps/registration/views.py:258 +#: apps/member/forms.py:141 apps/member/views.py:100 +#: apps/registration/forms.py:33 apps/registration/views.py:254 msgid "An alias with a similar name already exists." msgstr "Un alias avec un nom similaire existe déjà." @@ -900,7 +900,7 @@ msgid "Account #" msgstr "Compte n°" #: apps/member/templates/member/base.html:48 -#: apps/member/templates/member/base.html:62 apps/member/views.py:58 +#: apps/member/templates/member/base.html:62 apps/member/views.py:59 #: apps/registration/templates/registration/future_profile_detail.html:48 #: apps/wei/templates/wei/weimembership_form.html:117 msgid "Update Profile" @@ -932,7 +932,7 @@ msgid "" "Are you sure you want to lock this note? This will prevent any transaction " "that would be performed, until the note is unlocked." msgstr "" -"Êtes-vous sûr de vouloir verrouiller cette note ? Cela empêchera toute " +"Êtes-vous sûr⋅e de vouloir verrouiller cette note ? Cela empêchera toute " "transaction qui devrait être faite, jusqu'à ce qu'elle soit déverrouillée." #: apps/member/templates/member/base.html:104 @@ -956,8 +956,8 @@ msgstr "Verrouiller de force" msgid "" "Are you sure you want to unlock this note? Transactions will be re-enabled." msgstr "" -"Êtes-vous sûr de vouloir déverrouiller cette note ? Les transactions seront " -"à nouveau possible." +"Êtes-vous sûr⋅e de vouloir déverrouiller cette note ? Les transactions " +"seront à nouveau possible." #: apps/member/templates/member/club_alias.html:10 #: apps/member/templates/member/profile_alias.html:10 apps/member/views.py:253 @@ -1053,18 +1053,50 @@ msgstr "Changer le mot de passe" msgid "API token" msgstr "Accès API" -#: apps/member/templates/member/manage_auth_tokens.html:19 +#: apps/member/templates/member/manage_auth_tokens.html:12 +msgid "Token authentication" +msgstr "Authentification par jeton" + +#: apps/member/templates/member/manage_auth_tokens.html:28 msgid "Token" msgstr "Jeton" -#: apps/member/templates/member/manage_auth_tokens.html:26 +#: apps/member/templates/member/manage_auth_tokens.html:35 msgid "Created" msgstr "Créé le" -#: apps/member/templates/member/manage_auth_tokens.html:34 +#: apps/member/templates/member/manage_auth_tokens.html:39 +msgid "Warning" +msgstr "Attention" + +#: apps/member/templates/member/manage_auth_tokens.html:44 msgid "Regenerate token" msgstr "Regénérer le jeton" +#: apps/member/templates/member/manage_auth_tokens.html:53 +msgid "OAuth2 authentication" +msgstr "Authentification OAuth2" + +#: apps/member/templates/member/manage_auth_tokens.html:79 +msgid "Authorization:" +msgstr "Autorisation :" + +#: apps/member/templates/member/manage_auth_tokens.html:83 +msgid "Token:" +msgstr "Jeton :" + +#: apps/member/templates/member/manage_auth_tokens.html:87 +msgid "Revoke Token:" +msgstr "Révoquer le jeton :" + +#: apps/member/templates/member/manage_auth_tokens.html:91 +msgid "Introspect Token:" +msgstr "Introspection :" + +#: apps/member/templates/member/manage_auth_tokens.html:97 +msgid "Show my applications" +msgstr "Voir mes applications" + #: apps/member/templates/member/picture_update.html:35 msgid "Nevermind" msgstr "Annuler" @@ -1097,7 +1129,7 @@ msgstr "Sauvegarder les changements" msgid "Registrations" msgstr "Inscriptions" -#: apps/member/views.py:71 apps/registration/forms.py:23 +#: apps/member/views.py:72 apps/registration/forms.py:23 msgid "This address must be valid." msgstr "Cette adresse doit être valide." @@ -1481,6 +1513,9 @@ msgstr "Pas de motif spécifié" #: apps/treasury/templates/treasury/sogecredit_detail.html:65 #: apps/wei/tables.py:74 apps/wei/tables.py:114 #: apps/wei/templates/wei/weiregistration_confirm_delete.html:31 +#: note_kfet/templates/oauth2_provider/application_confirm_delete.html:18 +#: note_kfet/templates/oauth2_provider/application_detail.html:39 +#: note_kfet/templates/oauth2_provider/authorized-token-delete.html:12 msgid "Delete" msgstr "Supprimer" @@ -1490,6 +1525,7 @@ msgstr "Supprimer" #: apps/wei/templates/wei/bus_detail.html:20 #: apps/wei/templates/wei/busteam_detail.html:20 #: apps/wei/templates/wei/busteam_detail.html:40 +#: note_kfet/templates/oauth2_provider/application_detail.html:38 msgid "Edit" msgstr "Éditer" @@ -1731,7 +1767,7 @@ msgstr "s'applique au club" msgid "role permissions" msgstr "permissions par rôles" -#: apps/permission/signals.py:63 +#: apps/permission/signals.py:67 #, python-brace-format msgid "" "You don't have the permission to change the field {field} on this instance " @@ -1740,7 +1776,7 @@ msgstr "" "Vous n'avez pas la permission de modifier le champ {field} sur l'instance du " "modèle {app_label}.{model_name}." -#: apps/permission/signals.py:73 apps/permission/views.py:105 +#: apps/permission/signals.py:77 apps/permission/views.py:105 #, python-brace-format msgid "" "You don't have the permission to add an instance of model {app_label}." @@ -1749,7 +1785,7 @@ msgstr "" "Vous n'avez pas la permission d'ajouter une instance du modèle {app_label}." "{model_name}." -#: apps/permission/signals.py:102 +#: apps/permission/signals.py:106 #, python-brace-format msgid "" "You don't have the permission to delete this instance of model {app_label}." @@ -1799,6 +1835,25 @@ msgstr "Requête :" msgid "No associated permission" msgstr "Pas de permission associée" +#: apps/permission/templates/permission/scopes.html:8 +msgid "Available scopes" +msgstr "Scopes disponibles" + +#: apps/permission/templates/permission/scopes.html:42 +#: note_kfet/templates/oauth2_provider/application_list.html:24 +msgid "No applications defined" +msgstr "Pas d'application définie" + +#: apps/permission/templates/permission/scopes.html:43 +#: note_kfet/templates/oauth2_provider/application_list.html:25 +msgid "Click here" +msgstr "Cliquez ici" + +#: apps/permission/templates/permission/scopes.html:43 +#: note_kfet/templates/oauth2_provider/application_list.html:25 +msgid "if you want to register a new one" +msgstr "si vous voulez en enregistrer une nouvelle" + #: apps/permission/views.py:72 #, python-brace-format msgid "" @@ -1977,35 +2032,35 @@ msgstr "L'équipe de la Note Kfet." msgid "Register new user" msgstr "Enregistrer un nouvel utilisateur" -#: apps/registration/views.py:93 +#: apps/registration/views.py:95 msgid "Email validation" msgstr "Validation de l'adresse mail" -#: apps/registration/views.py:95 +#: apps/registration/views.py:97 msgid "Validate email" msgstr "Valider l'adresse e-mail" -#: apps/registration/views.py:137 +#: apps/registration/views.py:141 msgid "Email validation unsuccessful" msgstr "La validation de l'adresse mail a échoué" -#: apps/registration/views.py:148 +#: apps/registration/views.py:152 msgid "Email validation email sent" msgstr "L'email de vérification de l'adresse email a bien été envoyé" -#: apps/registration/views.py:156 +#: apps/registration/views.py:160 msgid "Resend email validation link" msgstr "Renvoyer le lien de validation" -#: apps/registration/views.py:174 +#: apps/registration/views.py:178 msgid "Pre-registered users list" msgstr "Liste des utilisateurs en attente d'inscription" -#: apps/registration/views.py:198 +#: apps/registration/views.py:202 msgid "Unregistered users" msgstr "Utilisateurs en attente d'inscription" -#: apps/registration/views.py:211 +#: apps/registration/views.py:215 msgid "Registration detail" msgstr "Détails de l'inscription" @@ -2225,7 +2280,7 @@ msgstr "Cette facture est verrouillée et ne peut pas être supprimée." msgid "" "Are you sure you want to delete this invoice? This action can't be undone." msgstr "" -"Êtes-vous sûr de vouloir supprimer cette facture ? Cette action ne pourra " +"Êtes-vous sûr⋅e de vouloir supprimer cette facture ? Cette action ne pourra " "pas être annulée." #: apps/treasury/templates/treasury/invoice_confirm_delete.html:28 @@ -2863,7 +2918,7 @@ msgid "" "Are you sure you want to delete the registration of %(user)s for the WEI " "%(wei_name)s? This action can't be undone." msgstr "" -"Êtes-vous sûr de vouloir supprimer l'inscription de %(user)s pour le WEI " +"Êtes-vous sûr⋅e de vouloir supprimer l'inscription de %(user)s pour le WEI " "%(wei_name)s ? Cette action ne pourra pas être annulée." #: apps/wei/templates/wei/weiregistration_list.html:19 @@ -3127,6 +3182,113 @@ msgstr "Chercher par un attribut tel que le nom …" msgid "There is no results." msgstr "Il n'y a pas de résultat." +#: note_kfet/templates/oauth2_provider/application_confirm_delete.html:8 +msgid "Are you sure to delete the application" +msgstr "Êtes-vous sûr⋅e de vouloir supprimer l'application" + +#: note_kfet/templates/oauth2_provider/application_confirm_delete.html:17 +#: note_kfet/templates/oauth2_provider/authorize.html:28 +msgid "Cancel" +msgstr "Annuler" + +#: note_kfet/templates/oauth2_provider/application_detail.html:11 +msgid "Client id" +msgstr "ID client" + +#: note_kfet/templates/oauth2_provider/application_detail.html:14 +msgid "Client secret" +msgstr "Secret client" + +#: note_kfet/templates/oauth2_provider/application_detail.html:17 +msgid "Client type" +msgstr "Type de client" + +#: note_kfet/templates/oauth2_provider/application_detail.html:20 +msgid "Authorization Grant Type" +msgstr "Type d'autorisation" + +#: note_kfet/templates/oauth2_provider/application_detail.html:23 +msgid "Redirect Uris" +msgstr "URIs de redirection" + +#: note_kfet/templates/oauth2_provider/application_detail.html:29 +#, python-format +msgid "" +"You can go here to generate authorization " +"link templates and convert permissions to scope numbers with the permissions " +"that you want to grant for your application." +msgstr "" +"Vous pouvez aller ici pour générer des modèles " +"de liens d'autorisation et convertir des permissions en identifiants de " +"scopes avec les permissions que vous souhaitez attribuer à votre application." + +#: note_kfet/templates/oauth2_provider/application_detail.html:37 +#: note_kfet/templates/oauth2_provider/application_form.html:23 +msgid "Go Back" +msgstr "Retour en arrière" + +#: note_kfet/templates/oauth2_provider/application_form.html:12 +msgid "Edit application" +msgstr "Modifier l'application" + +#: note_kfet/templates/oauth2_provider/application_list.html:7 +msgid "Your applications" +msgstr "Vos applications" + +#: note_kfet/templates/oauth2_provider/application_list.html:11 +msgid "" +"You can find on this page the list of the applications that you already " +"registered." +msgstr "" +"Vous pouvez trouver sur cette page la liste des applications que vous avez " +"déjà enregistrées." + +#: note_kfet/templates/oauth2_provider/application_list.html:30 +msgid "New Application" +msgstr "Nouvelle application" + +#: note_kfet/templates/oauth2_provider/application_list.html:31 +msgid "Authorized Tokens" +msgstr "Jetons autorisés" + +#: note_kfet/templates/oauth2_provider/application_registration_form.html:5 +msgid "Register a new application" +msgstr "Enregistrer une nouvelle application" + +#: note_kfet/templates/oauth2_provider/authorize.html:9 +#: note_kfet/templates/oauth2_provider/authorize.html:29 +msgid "Authorize" +msgstr "Autoriser" + +#: note_kfet/templates/oauth2_provider/authorize.html:14 +msgid "Application requires following permissions:" +msgstr "L'application requiert les permissions suivantes :" + +#: note_kfet/templates/oauth2_provider/authorize.html:36 +#: note_kfet/templates/oauth2_provider/authorized-oob.html:15 +msgid "Error:" +msgstr "Erreur :" + +#: note_kfet/templates/oauth2_provider/authorized-oob.html:13 +msgid "Success" +msgstr "Succès" + +#: note_kfet/templates/oauth2_provider/authorized-oob.html:21 +msgid "Please return to your application and enter this code:" +msgstr "Merci de retourner à votre application et entrez ce code :" + +#: note_kfet/templates/oauth2_provider/authorized-token-delete.html:9 +msgid "Are you sure you want to delete this token?" +msgstr "Êtes-vous sûr⋅e de vouloir supprimer ce jeton ?" + +#: note_kfet/templates/oauth2_provider/authorized-tokens.html:7 +msgid "Tokens" +msgstr "Jetons" + +#: note_kfet/templates/oauth2_provider/authorized-tokens.html:22 +msgid "There are no authorized tokens yet." +msgstr "Il n'y a pas encore de jeton autorisé." + #: note_kfet/templates/registration/logged_out.html:13 msgid "Thanks for spending some quality time with the Web site today." msgstr "Merci d'avoir utilisé la Note Kfet." @@ -3168,7 +3330,7 @@ msgid "" "password twice so we can verify you typed it in correctly." msgstr "" "Veuillez entrer votre ancien mot de passe pour des raisons de sécurité, puis " -"renseigner votre nouveau mot de passe à deux reprises, pour être sûr de " +"renseigner votre nouveau mot de passe à deux reprises, pour être sûr⋅e de " "l'avoir tapé correctement." #: note_kfet/templates/registration/password_change_form.html:16 diff --git a/note_kfet/admin.py b/note_kfet/admin.py index 375122d8..dc209c67 100644 --- a/note_kfet/admin.py +++ b/note_kfet/admin.py @@ -6,7 +6,6 @@ from django.contrib.admin import AdminSite from django.contrib.sites.admin import Site, SiteAdmin from member.views import CustomLoginView -from .middlewares import get_current_session class StrongAdminSite(AdminSite): @@ -14,8 +13,7 @@ class StrongAdminSite(AdminSite): """ Authorize only staff that have the correct permission mask """ - session = get_current_session() - return request.user.is_active and request.user.is_staff and session.get("permission_mask", -1) >= 42 + return request.user.is_active and request.user.is_staff and request.session.get("permission_mask", -1) >= 42 def login(self, request, extra_context=None): return CustomLoginView.as_view()(request) diff --git a/note_kfet/middlewares.py b/note_kfet/middlewares.py index fcb84c9d..e763a571 100644 --- a/note_kfet/middlewares.py +++ b/note_kfet/middlewares.py @@ -1,43 +1,23 @@ # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later -from django.conf import settings -from django.contrib.auth import login -from django.contrib.auth.models import AnonymousUser, User -from django.contrib.sessions.backends.db import SessionStore - from threading import local -USER_ATTR_NAME = getattr(settings, 'LOCAL_USER_ATTR_NAME', '_current_user') -SESSION_ATTR_NAME = getattr(settings, 'LOCAL_SESSION_ATTR_NAME', '_current_session') -IP_ATTR_NAME = getattr(settings, 'LOCAL_IP_ATTR_NAME', '_current_ip') +from django.conf import settings +from django.contrib.auth import login +from django.contrib.auth.models import User + +REQUEST_ATTR_NAME = getattr(settings, 'LOCAL_REQUEST_ATTR_NAME', '_current_request') _thread_locals = local() -def _set_current_user_and_ip(user=None, session=None, ip=None): - setattr(_thread_locals, USER_ATTR_NAME, user) - setattr(_thread_locals, SESSION_ATTR_NAME, session) - setattr(_thread_locals, IP_ATTR_NAME, ip) +def _set_current_request(request=None): + setattr(_thread_locals, REQUEST_ATTR_NAME, request) -def get_current_user() -> User: - return getattr(_thread_locals, USER_ATTR_NAME, None) - - -def get_current_session() -> SessionStore: - return getattr(_thread_locals, SESSION_ATTR_NAME, None) - - -def get_current_ip() -> str: - return getattr(_thread_locals, IP_ATTR_NAME, None) - - -def get_current_authenticated_user(): - current_user = get_current_user() - if isinstance(current_user, AnonymousUser): - return None - return current_user +def get_current_request(): + return getattr(_thread_locals, REQUEST_ATTR_NAME, None) class SessionMiddleware(object): @@ -49,8 +29,6 @@ class SessionMiddleware(object): self.get_response = get_response def __call__(self, request): - user = request.user - # If we authenticate through a token to connect to the API, then we query the good user if 'HTTP_AUTHORIZATION' in request.META and request.path.startswith("/api"): token = request.META.get('HTTP_AUTHORIZATION') @@ -60,20 +38,14 @@ class SessionMiddleware(object): if Token.objects.filter(key=token).exists(): token_obj = Token.objects.get(key=token) user = token_obj.user + request.user = user session = request.session session["permission_mask"] = 42 session.save() - if 'HTTP_X_REAL_IP' in request.META: - ip = request.META.get('HTTP_X_REAL_IP') - elif 'HTTP_X_FORWARDED_FOR' in request.META: - ip = request.META.get('HTTP_X_FORWARDED_FOR').split(', ')[0] - else: - ip = request.META.get('REMOTE_ADDR') - - _set_current_user_and_ip(user, request.session, ip) + _set_current_request(request) response = self.get_response(request) - _set_current_user_and_ip(None, None, None) + _set_current_request(None) return response diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index 6cb068a5..a0ece715 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -245,6 +245,11 @@ REST_FRAMEWORK = { 'PAGE_SIZE': 20, } +# OAuth2 Provider +OAUTH2_PROVIDER = { + 'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes', +} + # Take control on how widget templates are sourced # See https://docs.djangoproject.com/en/2.2/ref/forms/renderers/#templatessetting FORM_RENDERER = 'django.forms.renderers.TemplatesSetting' diff --git a/note_kfet/templates/oauth2_provider/application_confirm_delete.html b/note_kfet/templates/oauth2_provider/application_confirm_delete.html new file mode 100644 index 00000000..ec7f19fb --- /dev/null +++ b/note_kfet/templates/oauth2_provider/application_confirm_delete.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block content %} +
+
+

{% trans "Are you sure to delete the application" %} {{ application.name }}?

+
+ +
+{% endblock content %} diff --git a/note_kfet/templates/oauth2_provider/application_detail.html b/note_kfet/templates/oauth2_provider/application_detail.html new file mode 100644 index 00000000..5d3cc008 --- /dev/null +++ b/note_kfet/templates/oauth2_provider/application_detail.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% load i18n %} +{% block content %} +
+
+

{{ application.name }}

+
+
+
+
{% trans "Client id" %}
+
+ +
{% trans "Client secret" %}
+
+ +
{% trans "Client type" %}
+
{{ application.client_type }}
+ +
{% trans "Authorization Grant Type" %}
+
{{ application.authorization_grant_type }}
+ +
{% trans "Redirect Uris" %}
+
+
+ +
+ {% url 'permission:scopes' as scopes_url %} + {% blocktrans trimmed %} + You can go here to generate authorization link templates and convert + permissions to scope numbers with the permissions that you want to grant for your application. + {% endblocktrans %} +
+ +
+ +
+{% endblock content %} diff --git a/note_kfet/templates/oauth2_provider/application_form.html b/note_kfet/templates/oauth2_provider/application_form.html new file mode 100644 index 00000000..aa084018 --- /dev/null +++ b/note_kfet/templates/oauth2_provider/application_form.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} + +{% load i18n %} +{% load crispy_forms_filters %} + +{% block content %} +
+
+
+

+ {% block app-form-title %} + {% trans "Edit application" %} {{ application.name }} + {% endblock app-form-title %} +

+
+
+ {% csrf_token %} + {{ form|crispy }} +
+ +
+
+{% endblock %} diff --git a/note_kfet/templates/oauth2_provider/application_list.html b/note_kfet/templates/oauth2_provider/application_list.html new file mode 100644 index 00000000..0238b345 --- /dev/null +++ b/note_kfet/templates/oauth2_provider/application_list.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% load i18n %} +{% block content %} +
+
+

{% trans "Your applications" %}

+
+
+
+ {% blocktrans trimmed %} + You can find on this page the list of the applications that you already registered. + {% endblocktrans %} +
+ + {% if applications %} + + {% else %} +

+ {% trans "No applications defined" %}. + {% trans "Click here" %} {% trans "if you want to register a new one" %}. +

+ {% endif %} +
+ +
+{% endblock content %} diff --git a/note_kfet/templates/oauth2_provider/application_registration_form.html b/note_kfet/templates/oauth2_provider/application_registration_form.html new file mode 100644 index 00000000..c22eca9e --- /dev/null +++ b/note_kfet/templates/oauth2_provider/application_registration_form.html @@ -0,0 +1,9 @@ +{% extends "oauth2_provider/application_form.html" %} + +{% load i18n %} + +{% block app-form-title %}{% trans "Register a new application" %}{% endblock app-form-title %} + +{% block app-form-action-url %}{% url 'oauth2_provider:register' %}{% endblock app-form-action-url %} + +{% block app-form-back-url %}{% url "oauth2_provider:list" %}"{% endblock app-form-back-url %} diff --git a/note_kfet/templates/oauth2_provider/authorize.html b/note_kfet/templates/oauth2_provider/authorize.html new file mode 100644 index 00000000..543bcf2d --- /dev/null +++ b/note_kfet/templates/oauth2_provider/authorize.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} + +{% load i18n %} +{% load crispy_forms_filters %} + +{% block content %} +
+
+

{% trans "Authorize" %} {{ application.name }} ?

+
+ {% if not error %} +
+
+

{% trans "Application requires following permissions:" %}

+ +
    + {% for scope in scopes_descriptions %} +
  • {{ scope }}
  • + {% endfor %} +
+ + {% csrf_token %} + {{ form|crispy }} +
+ +
+ {% else %} +
+

{% trans "Error:" %} {{ error.error }}

+

{{ error.description }}

+
+ {% endif %} +
+{% endblock %} + +{% block extrajavascript %} + +{% endblock %} diff --git a/note_kfet/templates/oauth2_provider/authorized-oob.html b/note_kfet/templates/oauth2_provider/authorized-oob.html new file mode 100644 index 00000000..c0c3a4f8 --- /dev/null +++ b/note_kfet/templates/oauth2_provider/authorized-oob.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% load i18n %} + +{% block title %} +Success code={{code}} +{% endblock %} + +{% block content %} +
+

+ {% if not error %} + {% trans "Success" %} + {% else %} + {% trans "Error:" %} {{ error.error }} + {% endif %} +

+ +
+ {% if not error %} +

{% trans "Please return to your application and enter this code:" %}

+ +

{{ code }}

+ {% else %} +

{{ error.description }}

+ {% endif %} +
+
+{% endblock %} diff --git a/note_kfet/templates/oauth2_provider/authorized-token-delete.html b/note_kfet/templates/oauth2_provider/authorized-token-delete.html new file mode 100644 index 00000000..8d91bf35 --- /dev/null +++ b/note_kfet/templates/oauth2_provider/authorized-token-delete.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} + +{% load i18n %} +{% block content %} +
+
+ {% csrf_token %} +

+ {% trans "Are you sure you want to delete this token?" %} +

+ +
+
+{% endblock %} diff --git a/note_kfet/templates/oauth2_provider/authorized-tokens.html b/note_kfet/templates/oauth2_provider/authorized-tokens.html new file mode 100644 index 00000000..3d7f40f2 --- /dev/null +++ b/note_kfet/templates/oauth2_provider/authorized-tokens.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% load i18n %} +{% block content %} +
+

+ {% trans "Tokens" %} +

+
+
    + {% for authorized_token in authorized_tokens %} +
  • + {{ authorized_token.application }} + (revoke) +
  • +
      + {% for scope_name, scope_description in authorized_token.scopes.items %} +
    • {{ scope_name }}: {{ scope_description }}
    • + {% endfor %} +
    + {% empty %} +
  • {% trans "There are no authorized tokens yet." %}
  • + {% endfor %} +
+
+
+{% endblock %} diff --git a/note_kfet/views.py b/note_kfet/views.py index 797de4ef..b65bf761 100644 --- a/note_kfet/views.py +++ b/note_kfet/views.py @@ -19,11 +19,11 @@ class IndexView(LoginRequiredMixin, RedirectView): user = self.request.user # The account note will have the consumption page as default page - if not PermissionBackend.check_perm(user, "auth.view_user", user): + if not PermissionBackend.check_perm(self.request, "auth.view_user", user): return reverse("note:consos") # People that can see the alias BDE are Kfet members - if PermissionBackend.check_perm(user, "alias.view_alias", Alias.objects.get(name="BDE")): + if PermissionBackend.check_perm(self.request, "alias.view_alias", Alias.objects.get(name="BDE")): return reverse("note:transfer") # Non-Kfet members will don't see the transfer page, but their profile page