From 8872596b58f11a332bdbf9f96134bec8958c30ec Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Sun, 13 Feb 2022 09:02:59 +0100
Subject: [PATCH] idp_oidc: make sub depend on user's profile choice during
 authz (#58556)

---
 src/authentic2/app_settings.py                |  4 +++
 src/authentic2_idp_oidc/apps.py               |  4 +--
 .../migrations/0015_oidccode_profile.py       | 25 +++++++++++++
 src/authentic2_idp_oidc/models.py             |  4 +++
 .../authentic2_idp_oidc/authorization.html    | 13 +++++++
 src/authentic2_idp_oidc/utils.py              | 35 +++++++++++--------
 src/authentic2_idp_oidc/views.py              | 26 ++++++++++++--
 7 files changed, 93 insertions(+), 18 deletions(-)
 create mode 100644 src/authentic2_idp_oidc/migrations/0015_oidccode_profile.py

diff --git a/src/authentic2/app_settings.py b/src/authentic2/app_settings.py
index 34407de8..b034583b 100644
--- a/src/authentic2/app_settings.py
+++ b/src/authentic2/app_settings.py
@@ -333,6 +333,10 @@ default_settings = dict(
         default=250,
         definition='Maximum number of mails to send per period',
     ),
+    A2_USER_PROFILE_MANAGEMENT=Setting(
+        default=False,
+        definition='Activate user profiles for juridical entity management.',
+    ),
 )
 
 app_settings = AppSettings(default_settings)
diff --git a/src/authentic2_idp_oidc/apps.py b/src/authentic2_idp_oidc/apps.py
index cf79882f..dc564da8 100644
--- a/src/authentic2_idp_oidc/apps.py
+++ b/src/authentic2_idp_oidc/apps.py
@@ -88,7 +88,7 @@ class AppConfig(django.apps.AppConfig):
         sub = smart_bytes(view.kwargs[lookup_url_kwarg])
         decrypted = utils.reverse_pairwise_sub(client, sub)
         if decrypted:
-            view.kwargs[lookup_url_kwarg] = uuid.UUID(bytes=decrypted).hex
+            view.kwargs[lookup_url_kwarg] = uuid.UUID(bytes=decrypted.split(b'#')[0]).hex
 
     def a2_hook_api_modify_serializer_after_validation(self, view, serializer):
         import uuid
@@ -111,7 +111,7 @@ class AppConfig(django.apps.AppConfig):
         for u in serializer.validated_data['known_uuids']:
             decrypted = utils.reverse_pairwise_sub(client, smart_bytes(u))
             if decrypted:
-                new_known_uuid = uuid.UUID(bytes=decrypted).hex
+                new_known_uuid = uuid.UUID(bytes=decrypted.split(b'#')[0]).hex
                 new_known_uuids.append(new_known_uuid)
                 uuid_map[new_known_uuid] = u
             else:
diff --git a/src/authentic2_idp_oidc/migrations/0015_oidccode_profile.py b/src/authentic2_idp_oidc/migrations/0015_oidccode_profile.py
new file mode 100644
index 00000000..2788047f
--- /dev/null
+++ b/src/authentic2_idp_oidc/migrations/0015_oidccode_profile.py
@@ -0,0 +1,25 @@
+# Generated by Django 2.2.24 on 2022-02-15 09:50
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('custom_user', '0030_profiletype_allowed_services'),
+        ('authentic2_idp_oidc', '0014_auto_20201126_1812'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='oidccode',
+            name='profile',
+            field=models.ForeignKey(
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                to='custom_user.Profile',
+                verbose_name='user selected profile',
+            ),
+        ),
+    ]
diff --git a/src/authentic2_idp_oidc/models.py b/src/authentic2_idp_oidc/models.py
index 56088ad8..fa9b8c46 100644
--- a/src/authentic2_idp_oidc/models.py
+++ b/src/authentic2_idp_oidc/models.py
@@ -27,6 +27,7 @@ from django.utils.timezone import now
 from django.utils.translation import ugettext_lazy as _
 
 from authentic2.a2_rbac.models import OrganizationalUnit
+from authentic2.custom_user.models import Profile
 from authentic2.models import Service
 
 from . import app_settings, managers, utils
@@ -288,6 +289,9 @@ class OIDCCode(SessionMixin, models.Model):
     uuid = models.CharField(max_length=128, verbose_name=_('uuid'), default=generate_uuid)
     client = models.ForeignKey(to=OIDCClient, verbose_name=_('client'), on_delete=models.CASCADE)
     user = models.ForeignKey(to=settings.AUTH_USER_MODEL, verbose_name=_('user'), on_delete=models.CASCADE)
+    profile = models.ForeignKey(
+        to=Profile, verbose_name=_('user selected profile'), null=True, on_delete=models.CASCADE
+    )
     scopes = models.TextField(verbose_name=_('scopes'))
     state = models.TextField(null=True, verbose_name=_('state'))
     nonce = models.TextField(null=True, verbose_name=_('nonce'))
diff --git a/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/authorization.html b/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/authorization.html
index f6242fca..6a5b48d0 100644
--- a/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/authorization.html
+++ b/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/authorization.html
@@ -16,6 +16,19 @@
         {% endfor %}
       </ul>
     {% endif %}
+    {% if needs_profile_validation %}
+     <p>{% trans "Additionally, you may authenticate as owner of the following juridical entity management profile, which may change the aforementioned information:" %}</p>
+      <div id="profile-validation-none">
+        <input type="radio" id="_none" name="profile-validation" value="">
+        <label for="_none">{% trans "Keep my default user profile." %}</label>
+      </div>
+      {% for profile in user.profiles %}
+        <div id="profile-validation-{{ profile.id }}">
+          <input type="radio" id="{{ profile.id }}" name="profile-validation" value="{{ profile.id }}">
+          <label for="{{ profile.slug }}">{% blocktrans with type_name=profile.profile_type.name %}Profile of type {{ type_name }}.{% endblocktrans %}</label>
+        </div>
+      {% endfor %}
+    {% endif %}
     {% csrf_token %}
     <p class="a2-oidc-authorization-form--do-not-ask-again">
       <label for="id_do_not_ask_again"><input id="id_do_not_ask_again" type="checkbox" name="do_not_ask_again" value="on"/><span>{% trans "Do not ask again" %}</span></label>
diff --git a/src/authentic2_idp_oidc/utils.py b/src/authentic2_idp_oidc/utils.py
index 810066ca..b856dbb0 100644
--- a/src/authentic2_idp_oidc/utils.py
+++ b/src/authentic2_idp_oidc/utils.py
@@ -121,9 +121,9 @@ def url_domain(url):
     return urllib.parse.urlparse(url).netloc.split(':')[0]
 
 
-def make_sub(client, user):
+def make_sub(client, user, profile=None):
     if client.identifier_policy in (client.POLICY_PAIRWISE, client.POLICY_PAIRWISE_REVERSIBLE):
-        return make_pairwise_sub(client, user)
+        return make_pairwise_sub(client, user, profile=profile)
     elif client.identifier_policy == client.POLICY_UUID:
         return force_text(user.uuid)
     elif client.identifier_policy == client.POLICY_EMAIL:
@@ -132,36 +132,43 @@ def make_sub(client, user):
         raise NotImplementedError
 
 
-def make_pairwise_sub(client, user):
+def make_pairwise_sub(client, user, profile=None):
     '''Make a pairwise sub'''
     if client.identifier_policy == client.POLICY_PAIRWISE:
-        return make_pairwise_unreversible_sub(client, user)
+        return make_pairwise_unreversible_sub(client, user, profile=profile)
     elif client.identifier_policy == client.POLICY_PAIRWISE_REVERSIBLE:
-        return make_pairwise_reversible_sub(client, user)
+        return make_pairwise_reversible_sub(client, user, profile=profile)
     else:
         raise NotImplementedError('unknown pairwise client.identifier_policy %s' % client.identifier_policy)
 
 
-def make_pairwise_unreversible_sub(client, user):
+def make_pairwise_unreversible_sub(client, user, profile=None):
     sector_identifier = client.get_sector_identifier()
     sub = sector_identifier + str(user.uuid) + settings.SECRET_KEY
+    if profile:
+        sub += str(profile.id)
     sub = base64.b64encode(hashlib.sha256(sub.encode('utf-8')).digest())
     return sub.decode('utf-8')
 
 
-def make_pairwise_reversible_sub(client, user):
-    return make_pairwise_reversible_sub_from_uuid(client, user.uuid)
+def make_pairwise_reversible_sub(client, user, profile=None):
+    return make_pairwise_reversible_sub_from_uuid(client, user.uuid, profile=profile)
 
 
-def make_pairwise_reversible_sub_from_uuid(client, user_uuid):
+def make_pairwise_reversible_sub_from_uuid(client, user_uuid, profile=None):
     try:
         identifier = uuid.UUID(user_uuid).bytes
     except ValueError:
         return None
     sector_identifier = client.get_sector_identifier()
-    return crypto.aes_base64url_deterministic_encrypt(
-        settings.SECRET_KEY.encode('utf-8'), identifier, sector_identifier
-    ).decode('utf-8')
+    cipher_args = [
+        settings.SECRET_KEY.encode('utf-8'),
+        identifier,
+        sector_identifier,
+    ]
+    if profile:
+        cipher_args[1] += '#%s' % str(profile.id)
+    return crypto.aes_base64url_deterministic_encrypt(*cipher_args).decode('utf-8')
 
 
 def reverse_pairwise_sub(client, sub):
@@ -184,11 +191,11 @@ def clean_claim_value(value):
     return str(value)
 
 
-def create_user_info(request, client, user, scope_set, id_token=False):
+def create_user_info(request, client, user, scope_set, id_token=False, profile=None):
     '''Create user info dictionary'''
     user_info = {}
     if 'openid' in scope_set:
-        user_info['sub'] = make_sub(client, user)
+        user_info['sub'] = make_sub(client, user, profile=profile)
     attributes = get_attributes(
         {
             'user': user,
diff --git a/src/authentic2_idp_oidc/views.py b/src/authentic2_idp_oidc/views.py
index 6272e4c1..22fe9403 100644
--- a/src/authentic2_idp_oidc/views.py
+++ b/src/authentic2_idp_oidc/views.py
@@ -38,6 +38,7 @@ from ratelimit.utils import is_ratelimited
 from authentic2 import app_settings as a2_app_settings
 from authentic2 import hooks
 from authentic2.a2_rbac.models import OrganizationalUnit
+from authentic2.custom_user.models import Profile
 from authentic2.decorators import setting_enabled
 from authentic2.exponential_retry_timeout import ExponentialRetryTimeout
 from authentic2.utils.misc import last_authentication_event, login_require, make_url, redirect
@@ -359,6 +360,10 @@ def authorize_for_client(request, client, redirect_uri):
 
     iat = now()  # iat = issued at
 
+    needs_profile_validation = False
+    profile = None
+    if request.user.subprofiles and a2_app_settings.A2_USER_PROFILE_MANAGEMENT:
+        needs_profile_validation = True
     if client.authorization_mode != client.AUTHORIZATION_MODE_NONE or 'consent' in prompt:
         # authorization by user is mandatory, as per local configuration or per explicit request by
         # the RP
@@ -381,10 +386,18 @@ def authorize_for_client(request, client, redirect_uri):
         authorized_scopes = set()
         for authorization in qs:
             authorized_scopes |= authorization.scope_set()
-        if (authorized_scopes & scopes) < scopes:
+        if (authorized_scopes & scopes) < scopes or needs_profile_validation:
             if 'none' in prompt:
                 raise ConsentRequired(_('Consent is required but prompt parameter is "none"'))
             if request.method == 'POST':
+                if request.POST.get('profile-validation', ''):
+                    try:
+                        profile = Profile.objects.get(
+                            user=request.user,
+                            slug=request.POST['profile-validation'],
+                        )
+                    except Profile.DoesNotExist:
+                        pass
                 if 'accept' in request.POST:
                     if 'do_not_ask_again' in request.POST:
                         pk_to_deletes = []
@@ -417,6 +430,7 @@ def authorize_for_client(request, client, redirect_uri):
                     request,
                     'authentic2_idp_oidc/authorization.html',
                     {
+                        'needs_profile_validation': needs_profile_validation,
                         'client': client,
                         'scopes': scopes - {'openid'},
                     },
@@ -425,6 +439,7 @@ def authorize_for_client(request, client, redirect_uri):
         code = models.OIDCCode.objects.create(
             client=client,
             user=request.user,
+            profile=profile,
             scopes=' '.join(scopes),
             state=state,
             nonce=nonce,
@@ -721,7 +736,14 @@ def tokens_from_authz_code(request):
     ):
         acr = '1'
     # prefill id_token with user info
-    id_token = utils.create_user_info(request, client, oidc_code.user, oidc_code.scope_set(), id_token=True)
+    id_token = utils.create_user_info(
+        request,
+        client,
+        oidc_code.user,
+        oidc_code.scope_set(),
+        id_token=True,
+        profile=oidc_code.profile,
+    )
     exp = start + idtoken_duration(client)
     id_token.update(
         {
-- 
2.34.1

