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
This commit is contained in:
Tom Hubrecht 2024-09-26 12:04:38 +02:00
parent 5381b0379b
commit 9119ad38b0
Signed by: thubrecht
SSH key fingerprint: SHA256:r+nK/SIcWlJ0zFZJGHtlAoRwq1Rm+WcKAm5ADYMoQPc
5 changed files with 134 additions and 26 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,6 @@
<hr>
<div class="notification is-warning is-light px-5 py-5 has-text-centered is-size-5">
{% blocktrans %}Votre catégorie de compte ENS ne permet pas de vous identifier auprès de la DGNum.<br>Si vous pensez qu'il s'agit une erreur, merci de contacter la DGNum à l'adresse : <a href="mailto:contact@dgnum.eu">contact@dgnum.eu</a>{% endblocktrans %}
{% blocktrans %}Vos informations ne permettent pas de vous identifier auprès de la DGNum.<br>Si vous pensez qu'il s'agit une erreur, merci de nous contacter à l'adresse : <a href="mailto:contact@dgnum.eu">contact@dgnum.eu</a>{% endblocktrans %}
</div>
{% endblock content %}