From 9119ad38b026a644edbccd494578552555e1cd13 Mon Sep 17 00:00:00 2001 From: Tom Hubrecht Date: Thu, 26 Sep 2024 12:04:38 +0200 Subject: [PATCH] feat(accounts): Allow divergence between cas_login and username - Adds a Translation table between cas_login and the effective username - Show more descriptive errors when the connection cannot happen TODO: The translation mechanism is currently fragile, we need to update usernames when a translation is created/deleted and also disable updates to a translation --- src/dgsi/admin.py | 4 +- src/dgsi/migrations/0006_translation.py | 32 +++++ src/dgsi/models.py | 12 ++ src/shared/account.py | 110 ++++++++++++++---- .../accounts/forbidden_category.html | 2 +- 5 files changed, 134 insertions(+), 26 deletions(-) create mode 100644 src/dgsi/migrations/0006_translation.py diff --git a/src/dgsi/admin.py b/src/dgsi/admin.py index 8c1fb0d..a12277d 100644 --- a/src/dgsi/admin.py +++ b/src/dgsi/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin from unfold.admin import ModelAdmin -from dgsi.models import Bylaws, Service, Statutes, User +from dgsi.models import Bylaws, Service, Statutes, Translation, User @admin.register(User) @@ -10,6 +10,6 @@ class UserAdmin(BaseUserAdmin, ModelAdmin): pass -@admin.register(Bylaws, Service, Statutes) +@admin.register(Bylaws, Service, Statutes, Translation) class AdminClass(ModelAdmin): compressed_fields = True diff --git a/src/dgsi/migrations/0006_translation.py b/src/dgsi/migrations/0006_translation.py new file mode 100644 index 0000000..df54ca2 --- /dev/null +++ b/src/dgsi/migrations/0006_translation.py @@ -0,0 +1,32 @@ +# Generated by Django 4.2.12 on 2024-09-26 09:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("dgsi", "0005_alter_bylaws_options_alter_statutes_options"), + ] + + operations = [ + migrations.CreateModel( + name="Translation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("cas_login", models.CharField(max_length=255, unique=True)), + ("username", models.CharField(max_length=255, unique=True)), + ], + options={ + "verbose_name": "Correspondance de login", + "verbose_name_plural": "Correspondances de login", + }, + ), + ] diff --git a/src/dgsi/models.py b/src/dgsi/models.py index 59c5f4d..da77aad 100644 --- a/src/dgsi/models.py +++ b/src/dgsi/models.py @@ -69,6 +69,18 @@ class Bylaws(LegalDocument): verbose_name_plural = _("Règlements Intérieurs") +class Translation(models.Model): + cas_login = models.CharField(max_length=255, unique=True) + username = models.CharField(max_length=255, unique=True) + + def __str__(self) -> str: + return f"{self.cas_login} → {self.username}" + + class Meta: # pyright: ignore + verbose_name = _("Correspondance de login") + verbose_name_plural = _("Correspondances de login") + + @dataclass class KanidmProfile: person: Person diff --git a/src/shared/account.py b/src/shared/account.py index 863d1c7..f3f91bb 100644 --- a/src/shared/account.py +++ b/src/shared/account.py @@ -1,10 +1,15 @@ +from functools import lru_cache +from typing import Optional + from allauth.core.exceptions import ImmediateHttpResponse from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from allauth.socialaccount.models import SocialLogin -from django.http import HttpResponseRedirect +from django.contrib import messages +from django.http import HttpRequest, HttpResponseRedirect from django.urls import reverse +from django.utils.translation import gettext_lazy as _ -from dgsi.models import User +from dgsi.models import Translation, User class SharedAccountAdapter(DefaultSocialAccountAdapter): @@ -12,42 +17,101 @@ class SharedAccountAdapter(DefaultSocialAccountAdapter): Overrides the Account Adapter, to allow a simpler connection via CAS. """ - def pre_social_login(self, request, sociallogin): - match sociallogin.account.provider: - # TODO: Add a correspondance table between ENS logins and ours - case "ens_cas": - # In this case, the username is located in extra_data["uid"] - username = sociallogin.account.extra_data["uid"] + @lru_cache + def _get_username(self, request: HttpRequest, sociallogin: SocialLogin) -> str: + """ + Returns the required username + """ - # Validate that the user is a regular one + match sociallogin.account.provider: + case "ens_cas": + cas_login = sociallogin.account.extra_data["uid"] + + # Verify that this user can indeed connect to the website home = sociallogin.account.extra_data["homeDirectory"].split("/") if (home[1] != "users") or ( home[2] in ["absint", "algo", "grecc", "guests", "spi", "spi1", "staffs"] ): + messages.error(request, _("Catégorie de compte ENS interdite.")) raise ImmediateHttpResponse( HttpResponseRedirect(reverse("dgsi:dgn-forbidden_account")) ) + # Continue with the login flow + try: + return Translation.objects.get(cas_login=cas_login).username + except Translation.DoesNotExist: + return cas_login + case "kanidm": - username = sociallogin.account.extra_data["preferred_username"] - case _p: - raise KeyError(f"No sociallogin '{_p}' is supposed to exist.") + return sociallogin.account.extra_data["preferred_username"] - try: - # Connect an existing user if the login already exists, even if it - # with another social method - user = User.objects.get(username=username) - sociallogin.connect(request, user) - except User.DoesNotExist: - return + case _: + # INFO: This should never happen + messages.error(request, _("Méthode de connexion invalide.")) + raise ImmediateHttpResponse( + HttpResponseRedirect(reverse("dgsi:dgn-forbidden_account")) + ) - # We now know that a user exists, copy the distant attributes - user.is_staff = user.is_admin - user.is_superuser = user.is_admin + def _get_user( + self, request: HttpRequest, sociallogin: SocialLogin + ) -> Optional[User]: + """ + Returns the required user for completing the login + """ - user.save() + # The user is already linked to the social login, no reason to change it + if sociallogin.is_existing: + return sociallogin.user + + # No user is currently linked to this social login, either the user has already + # logged in with another method, or it truly does not exist + return User.objects.filter( + username=self._get_username(request, sociallogin) + ).first() + + def _update_user(self, request: HttpRequest, sociallogin: SocialLogin): + """ + Updates the required attributes of the user: + - username + - permissions + """ + + u = sociallogin.user + + assert isinstance(u, User) + + # Update the username first, so that calls to kanidm return the correct information + u.username = self._get_username(request, sociallogin) + + # Update the global permissions + u.is_staff = u.is_admin + u.is_superuser = u.is_admin + + # Save the updated user if needed + if sociallogin.is_existing: + u.save() + + def pre_social_login(self, request, sociallogin: SocialLogin): + ### + # The flow is the following: + # - Get the correct user + # - Do the connection if possible + # - Update the required attributes + + user = self._get_user(request, sociallogin) + + if user is not None: + sociallogin.user = user + + # If the user exists, connect to it + # FIXME: May not be necessary + # if sociallogin.is_existing: + # sociallogin.connect(request, sociallogin.user) + + self._update_user(request, sociallogin) def populate_user(self, request, sociallogin, data): return super().populate_user(request, sociallogin, data) diff --git a/src/shared/templates/accounts/forbidden_category.html b/src/shared/templates/accounts/forbidden_category.html index 887aa24..e400a6e 100644 --- a/src/shared/templates/accounts/forbidden_category.html +++ b/src/shared/templates/accounts/forbidden_category.html @@ -7,6 +7,6 @@
- {% blocktrans %}Votre catégorie de compte ENS ne permet pas de vous identifier auprès de la DGNum.
Si vous pensez qu'il s'agit une erreur, merci de contacter la DGNum à l'adresse : contact@dgnum.eu{% endblocktrans %} + {% blocktrans %}Vos informations ne permettent pas de vous identifier auprès de la DGNum.
Si vous pensez qu'il s'agit une erreur, merci de nous contacter à l'adresse : contact@dgnum.eu{% endblocktrans %}
{% endblock content %}