From 5c95a01996f6b4df8c767395565a5d8999c0107d Mon Sep 17 00:00:00 2001
From: Art Lukyanchyk <artiom.lukyanchyk@hs-hannover.de>
Date: Mon, 18 Mar 2024 17:13:52 +0100
Subject: [PATCH] Use a generic unique ID attribute instead of UUID

---
 ssoauth/app_settings/defaults.py             | 16 +++++-----
 ssoauth/auth_utils.py                        | 32 +++++++++-----------
 ssoauth/migrations/0003_replace_unique_id.py | 23 ++++++++++++++
 ssoauth/models.py                            |  5 +--
 ssoauth/views.py                             |  6 ++--
 5 files changed, 51 insertions(+), 31 deletions(-)
 create mode 100644 ssoauth/migrations/0003_replace_unique_id.py

diff --git a/ssoauth/app_settings/defaults.py b/ssoauth/app_settings/defaults.py
index 1325de5..6b1586e 100644
--- a/ssoauth/app_settings/defaults.py
+++ b/ssoauth/app_settings/defaults.py
@@ -58,6 +58,14 @@ PREDEFINED_GROUPS = {
 CLEANUP_DEACTIVATE_AFTER = timedelta(days=7)  # people are getting suspicious because of the old users that still seem active according to django
 CLEANUP_DELETE_USER_AFTER = timedelta(days=180)
 
+# the block below defines from which SAML2 attributes this SP receives user data
+ATTRIBUTE_USERNAME = "urn:oid:2.5.4.3"  # cn (alternatively for example uid "urn:oid:0.9.2342.19200300.100.1.1")
+ATTRIBUTE_EMAIL = "urn:oid:0.9.2342.19200300.100.1.3"  # "mail"
+ATTRIBUTE_FORENAME = "urn:oid:2.5.4.42"  # "givenName"
+ATTRIBUTE_SURNAME = "urn:oid:2.5.4.4"  # "sn"
+ATTRIBUTE_UNIQUE_ID = "urn:oid:1.3.6.1.4.1.5923.1.1.1.6"  # "eduPersonPrincipalName" (expected to be permanent)
+ATTRIBUTE_GROUPS = "ssoGroup"  # HsH custom attribute (alternatively something like "urn:oid:1.3.6.1.4.1.5923.1.5.1.1" which is "isMemberOf")
+
 
 """
 Settings you might want to change on development (don't change them for production):
@@ -79,14 +87,6 @@ PROJECT_NAME = os.environ.get('DJANGO_SETTINGS_MODULE').split('.')[0]
 
 PRETEND_AUTH_BACKEND = django_settings.AUTHENTICATION_BACKENDS[0]  # pretend to be this backend; django does not expect that it is possible to log in without an authentication backend
 
-# the block below defines from which SAML2 attributes this SP receives user data
-ATTRIBUTE_USERNAME = "urn:oid:0.9.2342.19200300.100.1.1"  # "uid"
-ATTRIBUTE_EMAIL = "urn:oid:0.9.2342.19200300.100.1.3"  # "mail"
-ATTRIBUTE_FORENAME = "urn:oid:2.5.4.42"  # "givenName"
-ATTRIBUTE_SURNAME = "urn:oid:2.5.4.4"  # "sn"
-ATTRIBUTE_ACCOUNT_UUID = "UUID"  # custom stuff, this one has no OID
-ATTRIBUTE_GROUPS = "urn:oid:1.3.6.1.4.1.5923.1.5.1.1"  # "isMemberOf"
-
 GROUP_RESOLVER = None  # deprecated
 
 """
diff --git a/ssoauth/auth_utils.py b/ssoauth/auth_utils.py
index f8323bc..a83c3ed 100644
--- a/ssoauth/auth_utils.py
+++ b/ssoauth/auth_utils.py
@@ -1,4 +1,4 @@
-from uuid import UUID, uuid4
+import secrets
 from django.db import transaction
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import Group
@@ -16,19 +16,17 @@ def _validate_username(username):
         raise ValueError("Username must be lowere case")
 
 
-def get_user(uuid=None, username=None):
+def get_user(unique_id=None, username=None):
     """ This helper just gets a user instance. """
-    assert bool(uuid) ^ bool(username), "Need uuid OR username"
-    if uuid:
-        assert isinstance(uuid, UUID), "Not an UUID instance"
-        return models.UserMapping.objects.get(uuid=uuid).user
+    assert bool(unique_id) ^ bool(username), "Need unique_id OR username"
+    if unique_id:
+        return models.UserMapping.objects.get(unique_id=unique_id).user
     elif username:
-        assert isinstance(username, str), "Seriously?"
         return get_user_model().objects.get(username__iexact=username.lower())
 
 
 @transaction.atomic()
-def get_or_create_user(uuid, username):
+def get_or_create_user(unique_id, username):
     """
     Returns a user.
     Should be able to process a bunch of weird cases like changed username or duplicate usernames.
@@ -40,14 +38,14 @@ def get_or_create_user(uuid, username):
             other_user = get_user(username=username)
         except get_user_model().DoesNotExist:
             return
-        new_username = "{0}_OLD_{1}".format(other_user.username, uuid4())
+        new_username = "{0}_OLD_{1}".format(other_user.username, secrets.token_hex())
         logger.warning("Found another user with username {old_username}. Renaming {old_username} to {new_username}".format(old_username=other_user.username, new_username=new_username))
         other_user.username = new_username
         other_user.save()
 
-    def get_existing_user(uuid, username):
+    def get_existing_user(unique_id, username):
         try:
-            user = get_user(uuid=uuid)
+            user = get_user(unique_id=unique_id)
             if user.username != username:
                 free_up_username(username)
                 logger.warning("Username has changed. Renaming locally from {0} to {1}.".format(user.username, username))
@@ -57,23 +55,21 @@ def get_or_create_user(uuid, username):
         except models.UserMapping.DoesNotExist:
             return None
 
-    def create_user(uuid, username):
+    def create_user(unique_id, username):
         _validate_username(username)
         free_up_username(username)
         user = get_user_model().objects.create(username=username, is_staff=False)
         user.set_unusable_password()
         user.save()
-        models.UserMapping.objects.create(user=user, uuid=uuid)
-        logger.info("Created user: {username} {uuid}".format(**locals()))
+        models.UserMapping.objects.create(user=user, unique_id=unique_id)
+        logger.info("Created user: {username} {unique_id}".format(**locals()))
         return user
 
     # prepare
-    if isinstance(uuid, str):
-        uuid = UUID(uuid)
-    assert isinstance(uuid, UUID) and isinstance(username, str)
+    assert isinstance(unique_id, str) and isinstance(username, str)
     username = username.lower()
     # get or create
-    user = get_existing_user(uuid, username) or create_user(uuid, username)
+    user = get_existing_user(unique_id, username) or create_user(unique_id, username)
     # just in case, ensure the user object complies with the security rules
     cleanup_direct_permissions(user)
     return user
diff --git a/ssoauth/migrations/0003_replace_unique_id.py b/ssoauth/migrations/0003_replace_unique_id.py
new file mode 100644
index 0000000..874e33a
--- /dev/null
+++ b/ssoauth/migrations/0003_replace_unique_id.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.2.7 on 2024-03-18 16:11
+
+from django.db import migrations, models
+import secrets
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ssoauth', '0002_auto_20190306_1823'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='usermapping',
+            name='uuid',
+        ),
+        migrations.AddField(
+            model_name='usermapping',
+            name='unique_id',
+            field=models.TextField(default=secrets.token_hex),
+        ),
+    ]
diff --git a/ssoauth/models.py b/ssoauth/models.py
index 5bc2b06..753a659 100644
--- a/ssoauth/models.py
+++ b/ssoauth/models.py
@@ -1,13 +1,14 @@
+import secrets
 from django.db import models
 from django import conf
 
 
 class UserMapping(models.Model):
     user = models.OneToOneField(conf.settings.AUTH_USER_MODEL, primary_key=True, on_delete=models.CASCADE, related_name="sso_mapping")
-    uuid = models.UUIDField(null=False)
+    unique_id = models.TextField(null=False, default=secrets.token_hex)
     anonymized = models.BooleanField(null=False, default=False)
     imported_on = models.DateField(null=False, auto_now_add=True)
 
     def __str__(self):
-        return "{user} <-> {uuid}".format(user=self.user, uuid=self.uuid)
+        return "{user} <-> {unique_id}".format(user=self.user, unique_id=self.unique_id)
 
diff --git a/ssoauth/views.py b/ssoauth/views.py
index 62babb9..a80172a 100644
--- a/ssoauth/views.py
+++ b/ssoauth/views.py
@@ -1,3 +1,4 @@
+import secrets
 from django.views.generic import View, FormView, TemplateView, RedirectView
 from django import http
 from django import urls
@@ -192,7 +193,7 @@ class ACSAuthNView(SAMLMixin, View):
         logger.debug("Synchronizing user using SAML2 data: {}".format(auth.get_attributes()))
         # get the user
         user = auth_utils.get_or_create_user(
-            uuid=get_attr(app_settings.ATTRIBUTE_ACCOUNT_UUID),
+            unique_id=get_attr(app_settings.ATTRIBUTE_UNIQUE_ID),
             username=get_attr(app_settings.ATTRIBUTE_USERNAME),
         )
         # update user data
@@ -338,8 +339,7 @@ class DevView(FormView):
             try:
                 user = auth_utils.get_user(username=log_in_as_username)
             except exceptions.ObjectDoesNotExist:
-                import uuid
-                user = auth_utils.get_or_create_user(username=log_in_as_username, uuid=uuid.uuid4())
+                user = auth_utils.get_or_create_user(username=log_in_as_username, unique_id=secrets.token_hex())
             user.backend = app_settings.PRETEND_AUTH_BACKEND
             self.request.user = user
             contrib_auth.login(request=self.request, user=user)
-- 
GitLab