Merge branch 'beta' into 'master'

OAuth2, tests WEI

See merge request bde/nk20!174
This commit is contained in:
ynerant 2021-09-02 20:49:58 +00:00
commit 5eb3ffca66
48 changed files with 1289 additions and 304 deletions

View File

@ -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]) + ", ..."

View File

@ -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,))]

View File

@ -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):

View File

@ -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):

View File

@ -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)

View File

@ -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("<a href={url}>{name}</a>",
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("<a href={url}>{name}</a>",
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("<a href='" + str(reverse_lazy("member:club_manage_roles", kwargs={"pk": record.pk}))
+ "'>" + s + "</a>")
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("<a href={url}>{name}</a>",
url=reverse_lazy('member:user_detail', kwargs={"pk": value.pk}), name=s)

View File

@ -5,32 +5,98 @@ SPDX-License-Identifier: GPL-3.0-or-later
{% load i18n %}
{% block content %}
<div class="alert alert-info">
<h4>À quoi sert un jeton d'authentification ?</h4>
<div class="row mt-4">
<div class="col-xl-6">
<div class="card">
<div class="card-header text-center">
<h3>{% trans "Token authentication" %}</h3>
</div>
<div class="card-body">
<div class="alert alert-info">
<h4>À quoi sert un jeton d'authentification ?</h4>
Un jeton vous permet de vous connecter à <a href="/api/">l'API de la Note Kfet</a>.<br />
Il suffit pour cela d'ajouter en en-tête de vos requêtes <code>Authorization: Token &lt;TOKEN&gt;</code>
pour pouvoir vous identifier.<br /><br />
Un jeton vous permet de vous connecter à <a href="/api/">l'API de la Note Kfet</a> via votre propre compte
depuis un client externe.<br />
Il suffit pour cela d'ajouter en en-tête de vos requêtes <code>Authorization: Token &lt;TOKEN&gt;</code>
pour pouvoir vous identifier.<br /><br />
Une documentation de l'API arrivera ultérieurement.
La documentation de l'API est disponible ici :
<a href="/doc/api/">{{ request.scheme }}://{{ request.get_host }}/doc/api/</a>.
</div>
<div class="alert alert-info">
<strong>{%trans 'Token' %} :</strong>
{% if 'show' in request.GET %}
{{ token.key }} (<a href="?">cacher</a>)
{% else %}
<em>caché</em> (<a href="?show">montrer</a>)
{% endif %}
<br />
<strong>{%trans 'Created' %} :</strong> {{ token.created }}
</div>
<div class="alert alert-warning">
<strong>{% trans "Warning" %} :</strong> regénérer le jeton va révoquer tout accès autorisé à l'API via ce jeton !
</div>
</div>
<div class="card-footer text-center">
<a href="?regenerate">
<button class="btn btn-primary">{% trans 'Regenerate token' %}</button>
</a>
</div>
</div>
</div>
<div class="col-xl-6">
<div class="card">
<div class="card-header text-center">
<h3>{% trans "OAuth2 authentication" %}</h3>
</div>
<div class="card-header">
<div class="alert alert-info">
<p>
La Note Kfet implémente également le protocole <a href="https://oauth.net/2/">OAuth2</a>, 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.
</p>
<p>
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é.
</p>
<p>
La documentation vis-à-vis de l'usage de ce protocole est disponible ici :
<a href="/doc/external_services/oauth2/">{{ request.scheme }}://{{ request.get_host }}/doc/external_services/oauth2/</a>.
</p>
</div>
Liste des URL à communiquer à votre application :
<ul>
<li>
{% trans "Authorization:" %}
<a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:authorize' %}</a>
</li>
<li>
{% trans "Token:" %}
<a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:token' %}</a>
</li>
<li>
{% trans "Revoke Token:" %}
<a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:revoke-token' %}</a>
</li>
<li>
{% trans "Introspect Token:" %}
<a href="{% url 'oauth2_provider:authorize' %}">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:introspect' %}</a>
</li>
</ul>
</div>
<div class="card-footer text-center">
<a class="btn btn-primary" href="{% url 'oauth2_provider:list' %}">{% trans "Show my applications" %}</a>
</div>
</div>
</div>
</div>
<div class="alert alert-info">
<strong>{%trans 'Token' %} :</strong>
{% if 'show' in request.GET %}
{{ token.key }} (<a href="?">cacher</a>)
{% else %}
<em>caché</em> (<a href="?show">montrer</a>)
{% endif %}
<br />
<strong>{%trans 'Created' %} :</strong> {{ token.created }}
</div>
<div class="alert alert-warning">
<strong>Attention :</strong> regénérer le jeton va révoquer tout accès autorisé à l'API via ce jeton !
</div>
<a href="?regenerate">
<button class="btn btn-primary">{% trans 'Regenerate token' %}</button>
</a>
{% endblock %}

View File

@ -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

View File

@ -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")

View File

@ -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")

View File

@ -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"), )

View File

@ -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"]:

View File

@ -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"))

View File

@ -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.

View File

@ -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.

34
apps/permission/scopes.py Normal file
View File

@ -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')]

View File

@ -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))

View File

@ -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("<a href={url}>{name}</a>",
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("<a href={url}>{name}</a>",
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("<a href='" + str(reverse_lazy("member:club_manage_roles", kwargs={"pk": record.pk}))
+ "'>" + s + "</a>")
return s

View File

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="card">
<div class="card-header text-center">
<h2>{% trans "Available scopes" %}</h2>
</div>
<div class="card-body">
<div class="accordion" id="accordionApps">
{% for app, app_scopes in scopes.items %}
<div class="card">
<div class="card-header" id="app-{{ app.name.lower }}-title">
<a class="text-decoration-none collapsed" href="#" data-toggle="collapse"
data-target="#app-{{ app.name.lower }}" aria-expanded="false"
aria-controls="app-{{ app.name.lower }}">
{{ app.name }}
</a>
</div>
<div class="collapse" id="app-{{ app.name.lower }}" aria-labelledby="app-{{ app.name.lower }}" data-target="#accordionApps">
<div class="card-body">
{% for scope_id, scope_desc in app_scopes.items %}
<div class="form-group">
<label class="form-check-label" for="scope-{{ app.name.lower }}-{{ scope_id }}">
<input type="checkbox" id="scope-{{ app.name.lower }}-{{ scope_id }}"
name="scope-{{ app.name.lower }}" class="checkboxinput form-check-input" value="{{ scope_id }}">
{{ scope_desc }}
</label>
</div>
{% endfor %}
<p id="url-{{ app.name.lower }}">
<a href="{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code" target="_blank">
{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code
</a>
</p>
</div>
</div>
</div>
{% empty %}
<p>
{% trans "No applications defined" %}.
<a href="{% url 'oauth2_provider:register' %}">{% trans "Click here" %}</a> {% trans "if you want to register a new one" %}.
</p>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
{% block extrajavascript %}
<script>
{% for app in scopes.keys %}
let elements = document.getElementsByName("scope-{{ app.name.lower }}");
for (let element of elements) {
element.onchange = function (event) {
let scope = ""
for (let element of elements) {
if (element.checked) {
scope += element.value + " "
}
}
scope = scope.substr(0, scope.length - 1)
document.getElementById("url-{{ app.name.lower }}").innerHTML = 'Scopes : ' + scope
+ '<br><a href="{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code&scope='+ scope.replaceAll(' ', '%20')
+ '" target="_blank">{{ request.scheme }}://{{ request.get_host }}{% url 'oauth2_provider:authorize' %}?client_id={{ app.client_id }}&response_type=code&scope='
+ scope.replaceAll(' ', '%20') + '</a>'
}
}
{% endfor %}
</script>
{% endblock %}

View File

@ -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()

View File

@ -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

View File

@ -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"),
]

View File

@ -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

View File

@ -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()

View File

@ -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):
"""

View File

@ -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', ))

View File

@ -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):

View File

@ -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")

View File

@ -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):

View File

@ -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}</a>")
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("<span class='no-perm'></span>")
class Meta:

View File

@ -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 %

View File

@ -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:

View File

@ -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'),

View File

@ -5,19 +5,10 @@ L'authentification `OAuth2 <https://fr.wikipedia.org/wiki/OAuth>`_ 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 <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 <cas>`_, n'importe qui peut créer une application OAuth2.
Pour créer une application, il faut se rendre à la page
`/o/applications/ <https://note.crans.org/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 : `<https://note.crans.org/permission/scopes/>`_
* L'URL d'autorisation : `<https://note.crans.org/o/authorize/>`_
* L'URL d'obtention de jeton : `<https://note.crans.org/o/token/>`_
* L'URL de récupération des informations de l'utilisateur : `<https://note.crans.org/api/me/>`_
* Si besoin, l'URL de récupération des informations de l'utilisateur : `<https://note.crans.org/api/me/>`_
N'hésitez pas à consulter la page `<https://note.crans.org/api/me/>`_ 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 : `<https://note.crans.org/o/applications/register>`_.
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 `<http://localhost/>`_ 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 `<https://note.crans.org/o/authorize/>`_,
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 `<https://note.crans.org/permission/scopes/>`_.
* ``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 `<http://localhost/>`_.
* ``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 `<https://note.crans.org/o/token/>`_, 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
`<https://note.crans.org/o/token/>`_ 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.

View File

@ -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 <ynerant@crans.org>\n"
"Language-Team: French <http://translate.ynerant.fr/projects/nk20/nk20/fr/>\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 <a href=\"%(scopes_url)s\">here</a> 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 <a href=\"%(scopes_url)s\">ici</a> 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

View File

@ -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)

View File

@ -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

View File

@ -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'

View File

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="card">
<div class="card-header text-center">
<h3>{% trans "Are you sure to delete the application" %} {{ application.name }}?</h3>
</div>
<div class="card-footer text-center">
<form method="post" action="{% url 'oauth2_provider:delete' application.pk %}">
{% csrf_token %}
<div class="control-group">
<div class="controls">
<a class="btn btn-secondary btn-large" href="{% url "oauth2_provider:list" %}">{% trans "Cancel" %}</a>
<input type="submit" class="btn btn-large btn-danger" name="allow" value="{% trans "Delete" %}"/>
</div>
</div>
</form>
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="card">
<div class="card-header text-center">
<h3>{{ application.name }}</h3>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-xl-6">{% trans "Client id" %}</dt>
<dd class="col-xl-6"><input class="form-control" type="text" value="{{ application.client_id }}" readonly></dd>
<dt class="col-xl-6">{% trans "Client secret" %}</dt>
<dd class="col-xl-6"><input class="form-control" type="text" value="****************************************************************" readonly></dd>
<dt class="col-xl-6">{% trans "Client type" %}</dt>
<dd class="col-xl-6">{{ application.client_type }}</dd>
<dt class="col-xl-6">{% trans "Authorization Grant Type" %}</dt>
<dd class="col-xl-6">{{ application.authorization_grant_type }}</dd>
<dt class="col-xl-6">{% trans "Redirect Uris" %}</dt>
<dd class="col-xl-6"><textarea class="form-control" readonly>{{ application.redirect_uris }}</textarea></dd>
</dl>
<div class="alert alert-info">
{% url 'permission:scopes' as scopes_url %}
{% blocktrans trimmed %}
You can go <a href="{{ scopes_url }}">here</a> to generate authorization link templates and convert
permissions to scope numbers with the permissions that you want to grant for your application.
{% endblocktrans %}
</div>
</div>
<div class="card-footer text-center">
<a class="btn btn-secondary" href="{% url "oauth2_provider:list" %}">{% trans "Go Back" %}</a>
<a class="btn btn-primary" href="{% url "oauth2_provider:update" application.id %}">{% trans "Edit" %}</a>
<a class="btn btn-danger" href="{% url "oauth2_provider:delete" application.id %}">{% trans "Delete" %}</a>
</div>
</div>
{% endblock content %}

View File

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% load i18n %}
{% load crispy_forms_filters %}
{% block content %}
<form class="form-horizontal" method="post" action="{% block app-form-action-url %}{% url 'oauth2_provider:update' application.id %}{% endblock app-form-action-url %}">
<div class="card">
<div class="card-header text-center">
<h3 class="block-center-heading">
{% block app-form-title %}
{% trans "Edit application" %} {{ application.name }}
{% endblock app-form-title %}
</h3>
</div>
<div class="card-body">
{% csrf_token %}
{{ form|crispy }}
</div>
<div class="card-footer text-center control-group">
<div class="controls">
<a class="btn btn-secondary" href="{% block app-form-back-url %}{% url "oauth2_provider:detail" application.id %}{% endblock app-form-back-url %}">
{% trans "Go Back" %}
</a>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</div>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="card">
<div class="card-header text-center">
<h3>{% trans "Your applications" %}</h3>
</div>
<div class="card-body">
<div class="alert alert-info">
{% blocktrans trimmed %}
You can find on this page the list of the applications that you already registered.
{% endblocktrans %}
</div>
{% if applications %}
<ul>
{% for application in applications %}
<li><a href="{{ application.get_absolute_url }}">{{ application.name }}</a></li>
{% endfor %}
</ul>
{% else %}
<p>
{% trans "No applications defined" %}.
<a href="{% url 'oauth2_provider:register' %}">{% trans "Click here" %}</a> {% trans "if you want to register a new one" %}.
</p>
{% endif %}
</div>
<div class="card-footer text-center">
<a class="btn btn-success" href="{% url "oauth2_provider:register" %}">{% trans "New Application" %}</a>
<a class="btn btn-secondary" href="{% url "oauth2_provider:authorized-token-list" %}">{% trans "Authorized Tokens" %}</a>
</div>
</div>
{% endblock content %}

View File

@ -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 %}

View File

@ -0,0 +1,49 @@
{% extends "base.html" %}
{% load i18n %}
{% load crispy_forms_filters %}
{% block content %}
<div class="card">
<div class="card-header text-center">
<h3>{% trans "Authorize" %} {{ application.name }} ?</h3>
</div>
{% if not error %}
<form id="authorizationForm" method="post">
<div class="card-body">
<p>{% trans "Application requires following permissions:" %}</p>
<ul>
{% for scope in scopes_descriptions %}
<li>{{ scope }}</li>
{% endfor %}
</ul>
{% csrf_token %}
{{ form|crispy }}
</div>
<div class="card-footer text-center">
<div class="control-group">
<div class="controls">
<input type="submit" class="btn btn-large btn-danger" value="{% trans "Cancel" %}"/>
<input type="submit" class="btn btn-large btn-primary" name="allow" value="{% trans "Authorize" %}"/>
</div>
</div>
</div>
</form>
{% else %}
<div class="card-body">
<h2>{% trans "Error:" %} {{ error.error }}</h2>
<p>{{ error.description }}</p>
</div>
{% endif %}
</div>
{% endblock %}
{% block extrajavascript %}
<script>
{# Small hack to have the remove the allow checkbox and replace it with the button #}
{# Django oauth toolkit does simply not render the wdiget since it is not hidden, and create directly the button #}
document.getElementById('div_id_allow').parentElement.remove()
</script>
{% endblock %}

View File

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}
Success code={{code}}
{% endblock %}
{% block content %}
<div class="card">
<h3 class="card-header text-center">
{% if not error %}
{% trans "Success" %}
{% else %}
{% trans "Error:" %} {{ error.error }}
{% endif %}
</h3>
<div class="card-body">
{% if not error %}
<p>{% trans "Please return to your application and enter this code:" %}</p>
<p><code>{{ code }}</code></p>
{% else %}
<p>{{ error.description }}</p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="card">
<form action="" method="post">
{% csrf_token %}
<h3 class="card-header text-center">
{% trans "Are you sure you want to delete this token?" %}
</h3>
<div class="card-footer text-center">
<input type="submit" value="{% trans "Delete" %}" />
</div>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="card">
<h3 class="card-header text-center">
{% trans "Tokens" %}
</h3>
<div class="card-body">
<ul>
{% for authorized_token in authorized_tokens %}
<li>
{{ authorized_token.application }}
(<a href="{% url 'oauth2_provider:authorized-token-delete' authorized_token.pk %}">revoke</a>)
</li>
<ul>
{% for scope_name, scope_description in authorized_token.scopes.items %}
<li>{{ scope_name }}: {{ scope_description }}</li>
{% endfor %}
</ul>
{% empty %}
<li>{% trans "There are no authorized tokens yet." %}</li>
{% endfor %}
</ul>
</div>
</div>
{% endblock %}

View File

@ -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