diff --git a/apps/api/serializers.py b/apps/api/serializers.py index d59bdc43..c5e6f2b3 100644 --- a/apps/api/serializers.py +++ b/apps/api/serializers.py @@ -4,10 +4,14 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.auth.models import User -from rest_framework.serializers import ModelSerializer +from django.utils import timezone +from rest_framework import serializers +from member.api.serializers import ProfileSerializer, MembershipSerializer +from note.api.serializers import NoteSerializer +from note.models import Alias -class UserSerializer(ModelSerializer): +class UserSerializer(serializers.ModelSerializer): """ REST API Serializer for Users. The djangorestframework plugin will analyse the model `User` and parse all fields in the API. @@ -22,7 +26,7 @@ class UserSerializer(ModelSerializer): ) -class ContentTypeSerializer(ModelSerializer): +class ContentTypeSerializer(serializers.ModelSerializer): """ REST API Serializer for Users. The djangorestframework plugin will analyse the model `User` and parse all fields in the API. @@ -31,3 +35,42 @@ class ContentTypeSerializer(ModelSerializer): class Meta: model = ContentType fields = '__all__' + + +class OAuthSerializer(serializers.ModelSerializer): + """ + Informations that are transmitted by OAuth. + For now, this includes user, profile and valid memberships. + This should be better managed later. + """ + normalized_name = serializers.SerializerMethodField() + + profile = ProfileSerializer() + + note = NoteSerializer() + + memberships = serializers.SerializerMethodField() + + def get_normalized_name(self, obj): + return Alias.normalize(obj.username) + + def get_memberships(self, obj): + return serializers.ListSerializer(child=MembershipSerializer()).to_representation( + obj.memberships.filter(date_start__lte=timezone.now(), date_end__gte=timezone.now())) + + class Meta: + model = User + fields = ( + 'id', + 'username', + 'normalized_name', + 'first_name', + 'last_name', + 'email', + 'is_superuser', + 'is_active', + 'is_staff', + 'profile', + 'note', + 'memberships', + ) diff --git a/apps/api/urls.py b/apps/api/urls.py index 7131c657..3e61d587 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -5,6 +5,7 @@ from django.conf import settings from django.conf.urls import url, include from rest_framework import routers +from .views import UserInformationView from .viewsets import ContentTypeViewSet, UserViewSet # Routers provide an easy way of automatically determining the URL conf. @@ -47,5 +48,6 @@ app_name = 'api' # Additionally, we include login URLs for the browsable API. urlpatterns = [ url('^', include(router.urls)), + url('^me/', UserInformationView.as_view()), url('^api-auth/', include('rest_framework.urls', namespace='rest_framework')), ] diff --git a/apps/api/views.py b/apps/api/views.py new file mode 100644 index 00000000..9718336d --- /dev/null +++ b/apps/api/views.py @@ -0,0 +1,20 @@ +# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from django.contrib.auth.models import User +from rest_framework.generics import RetrieveAPIView + +from .serializers import OAuthSerializer + + +class UserInformationView(RetrieveAPIView): + """ + These fields are give to OAuth authenticators. + """ + serializer_class = OAuthSerializer + + def get_queryset(self): + return User.objects.filter(pk=self.request.user.pk) + + def get_object(self): + return self.request.user diff --git a/apps/member/auth.py b/apps/member/auth.py new file mode 100644 index 00000000..888adea1 --- /dev/null +++ b/apps/member/auth.py @@ -0,0 +1,17 @@ +# Copyright (C) 2018-2021 by BDE ENS Paris-Saclay +# SPDX-License-Identifier: GPL-3.0-or-later + +from cas_server.auth import DjangoAuthUser # pragma: no cover +from note.models import Alias + + +class CustomAuthUser(DjangoAuthUser): # pragma: no cover + """ + Override Django Auth User model to define a custom Matrix username. + """ + + def attributs(self): + d = super().attributs() + if self.user: + d["normalized_name"] = Alias.normalize(self.user.username) + return d diff --git a/apps/permission/backends.py b/apps/permission/backends.py index cde6998d..4b044d80 100644 --- a/apps/permission/backends.py +++ b/apps/permission/backends.py @@ -134,8 +134,6 @@ class PermissionBackend(ModelBackend): return False sess = get_current_session() - if sess is not None and sess.session_key is None: - return False if user_obj.is_superuser and sess.get("permission_mask", -1) >= 42: return True diff --git a/docs/external_services/cas.rst b/docs/external_services/cas.rst new file mode 100644 index 00000000..36dedf3e --- /dev/null +++ b/docs/external_services/cas.rst @@ -0,0 +1,80 @@ +Service d'Authentification Centralisé (CAS) +=========================================== + +Un `CAS `_ est +déployé sur la Note Kfet. Il est accessible à l'adresse ``_. +Il a pour but uniquement d'authentifier les utilisateurs via la note et ne communique +que peu d'informations. + +Configuration +------------- + +Le serveur CAS utilisé est implémenté grâce au paquet ``django-cas-server``. Il peut être +installé soit par PIP soit sur une machine Debian via +``apt install python3-django-cas-server``. + +On ajoute ensuite ``cas_server`` aux applications Django installées. On n'oublie pas ni +d'appliquer les migrations (``./manage.py migrate``) ni de collecter les fichiers +statiques (``./manage.py collectstatic``). + +On enregistre les routes dans ``note_kfet/urls.py`` : + +.. code:: python + + urlpatterns.append( + path('cas/', include('cas_server.urls', namespace='cas_server')) + ) + +Le CAS est désormais déjà prêt à être utilisé. Toutefois, puisque l'on utilise un site +Django-admin personnalisé, on n'oublie pas d'enregistrer les pages d'administration : + +.. code:: python + + if "cas_server" in settings.INSTALLED_APPS: + from cas_server.admin import * + from cas_server.models import * + admin_site.register(ServicePattern, ServicePatternAdmin) + admin_site.register(FederatedIendityProvider, FederatedIendityProviderAdmin) + +Enfin, on souhaite pouvoir fournir au besoin le pseudo normalisé. Pour cela, on crée une +classe dans ``member.auth`` : + +.. code:: python + + class CustomAuthUser(DjangoAuthUser): + def attributs(self): + d = super().attributs() + if self.user: + d["normalized_name"] = Alias.normalize(self.user.username) + return d + + +Puis on source ce fichier dans les paramètres : + +.. code:: python + + CAS_AUTH_CLASS = 'member.auth.CustomAuthUser' + +Utilisation +----------- +Le service est accessible sur ``_. C'est ce lien qu'il faut +donner à votre application. + +L'application doit néanmoins être autorisée à accéder au CAS. Pour cela, rendez-vous +dans Django-admin (``_), dans +``Service Central d'Authentification/Motifs de services``, ajoutez une nouvelle entrée. +Choisissez votre position favorite puis le nom de l'application. + +Les champs importants sont les deux suivants : + +* **Motif :** il s'agit d'une expression régulière qui doit reconnaitre le site voulu. + Par exemple, pour autoriser Belenios (``_), on rentrera + le motif ``^https?://belenios\.crans\.org/.*$``. +* **Champ d'utilisateur :** C'est le pseudo que renverra le CAS. Par défaut, il s'agira + du nom de note principal, mais il arrive parfois que certains sites supportent mal + d'avoir des caractères UTF-8 dans le pseudo. C'est par exemple le cas de Belenios. + On rentrera alors ``normalized_name`` dans ce champ, qui correspond à la version + normalisée (sans accent ni espace ni aucun caractère non-ASCII) du pseudo, et qui + suffit à identifier une personne. + +On peut également utiliser le ``Single log out`` si besoin. diff --git a/docs/external_services/index.rst b/docs/external_services/index.rst new file mode 100644 index 00000000..ab1539aa --- /dev/null +++ b/docs/external_services/index.rst @@ -0,0 +1,28 @@ +Applications externes +===================== + +.. toctree:: + :maxdepth: 2 + :caption: Applications externes + + cas + oauth2 + +.. warning:: + L'utilisation de la note par des services externes est actuellement en beta. Il est + fort à parier que cette utilisation sera revue et améliorée à l'avenir. + +Puisque la Note Kfet recense tous les comptes des adhérents BDE, les clubs ont alors +la possibilité de développer leurs propres applications et de les interfacer avec la +note. De cette façon, chaque application peut authentifier ses utilisateurs via la note, +et récupérer leurs adhésion, leur nom de note afin d'éventuellement faire des transferts +via l'API. + +Deux protocoles d'authentification sont implémentées : + + * `CAS `_ + * `OAuth2 `_ + +À ce jour, il n'y a pas encore d'exemple d'utilisation d'application qui utilise ce +mécanisme, mais on peut imaginer par exemple que la Mediatek ou l'AMAP implémentent +ces protocoles pour récupérer leurs adhérents. diff --git a/docs/external_services/oauth2.rst b/docs/external_services/oauth2.rst new file mode 100644 index 00000000..3f1eee2c --- /dev/null +++ b/docs/external_services/oauth2.rst @@ -0,0 +1,133 @@ +OAuth2 +====== + +L'authentification `OAuth2 `_ est supportée par la +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. + +.. 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 +------------------------ + +On utilise ``django-oauth-toolkit``, qui peut être installé grâce à PIP ou bien via APT, +via le paquet ``python3-django-oauth-toolkit``. + +On commence par ajouter ``oauth2_provider`` aux applications Django installées. On +n'oublie pas ni d'appliquer les migrations (``./manage.py migrate``) ni de collecter +les fichiers statiques (``./manage.py collectstatic``). + +On souhaite que l'API gérée par ``django-rest-framework`` puisse être accessible via +l'authentification OAuth2. On adapte alors la configuration pour permettre cela : + +.. code:: python + + REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.TokenAuthentication', + 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', + ... + ], + ... + } + +On ajoute les routes dans ``urls.py`` : + +.. code:: python + + urlpatterns.append( + path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')) + ) + +L'OAuth2 est désormais prêt à être utilisé. + + +Configuration client +-------------------- + +Contrairement au `CAS `_, n'importe qui peut en théorie créer une application OAuth2. +En théorie, car pour l'instant les permissions ne leur permettent pas. + +Pour créer une application, il faut se rendre à la page +`/o/applications/ `_. Dans ``client type``, +rentrez ``public`` (ou ``confidential`` selon vos choix), et vous rentrerez +généralement ``authorization-code`` dans ``Authorization Grant Type``. +Le champ ``Redirect Uris`` contient une liste d'adresses URL autorisées pour des +redirections post-connexion. + +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) +* L'URL d'autorisation : ``_ +* L'URL d'obtention de jeton : ``_ +* L'URL de récupération des informations de l'utilisateur : ``_ + +N'hésitez pas à consulter la page ``_ pour s'imprégner +du format renvoyé. + +Avec Django-allauth +################### + +Si vous utilisez Django-allauth pour votre propre application, vous pouvez utiliser +le module pré-configuré disponible ici : +``_. Pour l'installer, vous +pouvez simplement faire : + +.. code:: bash + + $ pip3 install git+https://gitlab.crans.org/bde/allauth-note-kfet.git + +L'installation du module se fera automatiquement. + +Il vous suffit ensuite d'inclure l'application ``allauth_note_kfet`` à vos applications +installées (sur votre propre client), puis de bien ajouter l'application sociale : + +.. code:: python + + SOCIALACCOUNT_PROVIDERS = { + 'notekfet': { + # 'DOMAIN': 'note.crans.org', + }, + ... + } + +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. + +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. + +Vous devrez ensuite enregistrer l'application sociale dans la base de données. +Vous pouvez passer par Django-admin, mais cela peut nécessiter d'avoir déjà un compte, +alors autant le faire via un shell python : + +.. code:: python + + from allauth.socialaccount.models import SocialApp + SocialApp.objects.create( + name="Note Kfet", + provider="notekfet", + client_id="VOTRECLIENTID", + secret="VOTRESECRET", + key="", + ) + +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. diff --git a/docs/index.rst b/docs/index.rst index 766053aa..ac17e171 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,3 +12,4 @@ Des informations complémentaires sont également disponibles sur le `Wiki Crans :caption: Développement de la NK20 apps/index + external_services/index diff --git a/note_kfet/admin.py b/note_kfet/admin.py index fa192b57..06543102 100644 --- a/note_kfet/admin.py +++ b/note_kfet/admin.py @@ -52,3 +52,9 @@ if "rest_framework" in settings.INSTALLED_APPS: from rest_framework.authtoken.admin import * from rest_framework.authtoken.models import * admin_site.register(Token, TokenAdmin) + +if "cas_server" in settings.INSTALLED_APPS: + from cas_server.admin import * + from cas_server.models import * + admin_site.register(ServicePattern, ServicePatternAdmin) + admin_site.register(FederatedIendityProvider, FederatedIendityProviderAdmin) diff --git a/note_kfet/settings/__init__.py b/note_kfet/settings/__init__.py index 3d995367..3aa115f9 100644 --- a/note_kfet/settings/__init__.py +++ b/note_kfet/settings/__init__.py @@ -12,7 +12,7 @@ def read_env(): directory. """ try: - with open('.env') as f: + with open(os.path.join(BASE_DIR, '.env')) as f: content = f.read() except IOError: content = '' @@ -30,6 +30,7 @@ def read_env(): # Try to load environment variables from project .env +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) read_env() # Load base settings diff --git a/note_kfet/settings/base.py b/note_kfet/settings/base.py index 982634c8..67948f09 100644 --- a/note_kfet/settings/base.py +++ b/note_kfet/settings/base.py @@ -239,6 +239,7 @@ REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.TokenAuthentication', + 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', ], 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 20, @@ -273,3 +274,6 @@ PIC_RATIO = 1 # Custom phone number format PHONENUMBER_DB_FORMAT = 'NATIONAL' PHONENUMBER_DEFAULT_REGION = 'FR' + +# We add custom information to CAS, in order to give a normalized name to other services +CAS_AUTH_CLASS = 'member.auth.CustomAuthUser' diff --git a/note_kfet/urls.py b/note_kfet/urls.py index d4341bc6..ab19d763 100644 --- a/note_kfet/urls.py +++ b/note_kfet/urls.py @@ -45,6 +45,11 @@ if "oauth2_provider" in settings.INSTALLED_APPS: path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')) ) +if "cas_server" in settings.INSTALLED_APPS: + urlpatterns.append( + path('cas/', include('cas_server.urls', namespace='cas_server')) + ) + if "debug_toolbar" in settings.INSTALLED_APPS: import debug_toolbar urlpatterns = [ diff --git a/requirements.txt b/requirements.txt index d889dd54..0071bc83 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ beautifulsoup4~=4.7.1 Django~=2.2.15 django-bootstrap-datepicker-plus~=3.0.5 +django-cas-server~=1.2.0 django-colorfield~=0.3.2 django-crispy-forms~=1.7.2 django-extensions~=2.1.4