diff --git a/apps/api/serializers.py b/apps/api/serializers.py index f91132d5..d6403dd1 100644 --- a/apps/api/serializers.py +++ b/apps/api/serializers.py @@ -7,8 +7,11 @@ from django.contrib.auth.models import User from django.utils import timezone from rest_framework import serializers from member.api.serializers import ProfileSerializer, MembershipSerializer +from member.models import Membership from note.api.serializers import NoteSerializer from note.models import Alias +from note_kfet.middlewares import get_current_request +from permission.backends import PermissionBackend class UserSerializer(serializers.ModelSerializer): @@ -45,18 +48,30 @@ class OAuthSerializer(serializers.ModelSerializer): """ normalized_name = serializers.SerializerMethodField() - profile = ProfileSerializer() + profile = serializers.SerializerMethodField() - note = NoteSerializer() + note = serializers.SerializerMethodField() memberships = serializers.SerializerMethodField() def get_normalized_name(self, obj): return Alias.normalize(obj.username) + def get_profile(self, obj): + # Display the profile of the user only if we have rights to see it. + return ProfileSerializer().to_representation(obj.profile) \ + if PermissionBackend.has_perm(get_current_request(), obj.profile, 'view') else None + + def get_note(self, obj): + # Display the note of the user only if we have rights to see it. + return NoteSerializer().to_representation(obj.note) \ + if PermissionBackend.has_perm(get_current_request(), obj.note, 'view') else None + def get_memberships(self, obj): + # Display only memberships that we are allowed to see. return serializers.ListSerializer(child=MembershipSerializer()).to_representation( - obj.memberships.filter(date_start__lte=timezone.now(), date_end__gte=timezone.now())) + obj.memberships.filter(date_start__lte=timezone.now(), date_end__gte=timezone.now()) + .filter(PermissionBackend.filter_queryset(get_current_request(), Membership, 'view'))) class Meta: model = User diff --git a/apps/permission/scopes.py b/apps/permission/scopes.py index bf74cb81..65242804 100644 --- a/apps/permission/scopes.py +++ b/apps/permission/scopes.py @@ -1,6 +1,6 @@ # Copyright (C) 2018-2021 by BDE ENS Paris-Saclay # SPDX-License-Identifier: GPL-3.0-or-later - +from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.scopes import BaseScopes from member.models import Club from note_kfet.middlewares import get_current_request @@ -32,3 +32,26 @@ class PermissionScopes(BaseScopes): return [] return [f"{p.id}_{p.membership.club.id}" for p in PermissionBackend.get_raw_permissions(get_current_request(), 'view')] + + +class PermissionOAuth2Validator(OAuth2Validator): + def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): + """ + User can request as many scope as he wants, including invalid scopes, + but it will have only the permissions he has. + + This allows clients to request more permission to get finally a + subset of permissions. + """ + + valid_scopes = set() + + for t in Permission.PERMISSION_TYPES: + for p in PermissionBackend.get_raw_permissions(get_current_request(), t[0]): + scope = f"{p.id}_{p.membership.club.id}" + if scope in scopes: + valid_scopes.add(scope) + + request.scopes = valid_scopes + + return valid_scopes diff --git a/docs/external_services/oauth2.rst b/docs/external_services/oauth2.rst index 1033030e..6ee89621 100644 --- a/docs/external_services/oauth2.rst +++ b/docs/external_services/oauth2.rst @@ -41,8 +41,14 @@ On a ensuite besoin de définir nos propres scopes afin d'avoir des permissions OAUTH2_PROVIDER = { 'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes', + 'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator", + 'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14), } +Cela a pour effet d'avoir des scopes sous la forme ``PERMISSION_CLUB``, +et de demander des scopes facultatives (voir plus bas). +Un jeton de rafraîchissement expire de plus au bout de 14 jours, si non-renouvelé. + On ajoute enfin les routes dans ``urls.py`` : .. code:: python @@ -94,6 +100,27 @@ du format renvoyé. Vous pouvez donc contrôler le plus finement possible les permissions octroyées à vos jetons. +.. danger:: + + Demander des scopes n'implique pas de les avoir. + + Lorsque des scopes sont demandées par un client, la Note + va considérer l'ensemble des permissions accessibles parmi + ce qui est demandé. Dans vos programmes, vous devrez donc + vérifier les permissions acquises (communiquées lors de la + récupération du jeton d'accès à partir du grant code), + et prévoir un comportement dans le cas où des permissions + sont manquantes. + + Cela offre un intérêt supérieur par rapport au protocole + OAuth2 classique, consistant à demander trop de permissions + et agir en conséquence. + + Par exemple, vous pourriez demander la permission d'accéder + aux membres d'un club ou de faire des transactions, et agir + uniquement dans le cas où l'utilisateur connecté possède la + permission problématique. + Avec Django-allauth ################### @@ -116,6 +143,7 @@ installées (sur votre propre client), puis de bien ajouter l'application social SOCIALACCOUNT_PROVIDERS = { 'notekfet': { # 'DOMAIN': 'note.crans.org', + 'SCOPE': ['1_1', '2_1'], }, ... } @@ -123,6 +151,10 @@ installées (sur votre propre client), puis de bien ajouter l'application social Le paramètre ``DOMAIN`` permet de changer d'instance de Note Kfet. Par défaut, il se connectera à ``note.crans.org`` si vous ne renseignez rien. +Le paramètre ``SCOPE`` permet de définir les scopes à demander. +Dans l'exemple ci-dessous, les permissions d'accéder à l'utilisateur +et au profil sont demandées. + En créant l'application sur la note, vous pouvez renseigner ``https://monsite.example.com/accounts/notekfet/login/callback/`` en URL de redirection, à adapter selon votre configuration. diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index a0ece715..2ee10c25 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -7,6 +7,8 @@ import os # Build paths inside the project like this: os.path.join(BASE_DIR, ...) +from datetime import timedelta + BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Quick-start development settings - unsuitable for production @@ -248,6 +250,8 @@ REST_FRAMEWORK = { # OAuth2 Provider OAUTH2_PROVIDER = { 'SCOPES_BACKEND_CLASS': 'permission.scopes.PermissionScopes', + 'OAUTH2_VALIDATOR_CLASS': "permission.scopes.PermissionOAuth2Validator", + 'REFRESH_TOKEN_EXPIRE_SECONDS': timedelta(days=14), } # Take control on how widget templates are sourced