Merge branch 'cas' into 'beta'

CAS + OAuth2

See merge request bde/nk20!155
This commit is contained in:
ynerant 2021-03-09 16:39:03 +00:00
commit 116451603c
14 changed files with 345 additions and 6 deletions

View File

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

View File

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

20
apps/api/views.py Normal file
View File

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

17
apps/member/auth.py Normal file
View File

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

View File

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

View File

@ -0,0 +1,80 @@
Service d'Authentification Centralisé (CAS)
===========================================
Un `CAS <https://fr.wikipedia.org/wiki/Central_Authentication_Service>`_ est
déployé sur la Note Kfet. Il est accessible à l'adresse `<https://note.crans.org/cas/>`_.
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 `<https://note.crans.org/cas/>`_. 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 (`<https://note.crans.org/cas/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 (`<https://belenios.crans.org>`_), 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.

View File

@ -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 <cas>`_
* `OAuth2 <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.

View File

@ -0,0 +1,133 @@
OAuth2
======
L'authentification `OAuth2 <https://fr.wikipedia.org/wiki/OAuth>`_ 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 <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/ <https://note.crans.org/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 : `<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/>`_
N'hésitez pas à consulter la page `<https://note.crans.org/api/me/>`_ 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 :
`<https://gitlab.crans.org/bde/allauth-note-kfet>`_. 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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