From de05b4f283abedcd482f15cd8b554d30f9c6c9be Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Wed, 27 Apr 2022 18:00:51 +0200
Subject: [PATCH] auth_oidc: add a STRATEGY_FIND_EMAIL user-matching provider
 option (#63729)

---
 src/authentic2_auth_oidc/backends.py          | 147 +++++++++++-------
 .../migrations/0004_auto_20171017_1522.py     |   4 +
 src/authentic2_auth_oidc/models.py            |   5 +
 tests/test_auth_oidc.py                       |  55 +++++++
 4 files changed, 151 insertions(+), 60 deletions(-)

diff --git a/src/authentic2_auth_oidc/backends.py b/src/authentic2_auth_oidc/backends.py
index af1bded09..d2e940e78 100644
--- a/src/authentic2_auth_oidc/backends.py
+++ b/src/authentic2_auth_oidc/backends.py
@@ -140,38 +140,19 @@ class OIDCBackend(ModelBackend):
             logger.warning('auth_oidc: id_token nonce %r != expected nonce %r', id_token_nonce, nonce)
             return None
 
-        User = get_user_model()
-        user = None
-        if provider.strategy == models.OIDCProvider.STRATEGY_FIND_UUID:
-            # use the OP sub to find an user by UUUID
-            # it means OP and RP share the same account base and OP is passing its UUID as sub
-            try:
-                user = User.objects.get(uuid=id_token.sub, is_active=True)
-            except User.DoesNotExist:
-                pass
-            else:
-                logger.info('auth_oidc: found user using UUID (=sub) "%s": %s', id_token.sub, user)
-        elif provider.strategy == models.OIDCProvider.STRATEGY_FIND_USERNAME:
-            users = User.objects.filter(username=id_token.sub, is_active=True).order_by('pk')
-            if not users:
-                logger.warning('auth_oidc: user with username (=sub) "%s" not found', id_token.sub)
-            else:
-                user = users[0]
-                logger.info('auth_oidc: found user using username (=sub) "%s": %s', id_token.sub, user)
-        else:
-            try:
-                user = User.objects.get(
-                    oidc_account__provider=provider, oidc_account__sub=id_token.sub, is_active=True
-                )
-            except User.DoesNotExist:
-                pass
-            else:
-                logger.info('auth_oidc: found user using with sub "%s": %s', id_token.sub, user)
+        # map claims to attributes or user fields
+        # mapping is done before eventual creation of user as EMAIL_IS_UNIQUE needs to know if the
+        # mapping will provide some mail to us
+        ou_map = {ou.slug: ou for ou in OrganizationalUnit.cached()}
+        user_ou = provider.ou
+        user_info = None
+        save_user = False
+        mappings = []
+        context = id_token_content.copy()
         need_user_info = False
         for claim_mapping in provider.claim_mappings.all():
             need_user_info = need_user_info or not claim_mapping.idtoken_claim
 
-        user_info = None
         if need_user_info:
             if not access_token:
                 logger.warning('auth_oidc: need user info for some claims, but no access token was returned')
@@ -193,38 +174,7 @@ class OIDCBackend(ModelBackend):
                     logger.warning('auth_oidc: bad JSON in user info response, %s (%r)', e, response.content)
                 else:
                     logger.debug('auth_oidc: user_info content %s', user_info)
-
-        # check for required claims
-        for claim_mapping in provider.claim_mappings.all():
-            claim = claim_mapping.claim
-            if claim_mapping.required:
-                if '{{' in claim or '{%' in claim:
-                    logger.warning('claim \'%r\' is templated, it cannot be set as required')
-                elif claim_mapping.idtoken_claim and claim not in id_token:
-                    logger.warning(
-                        'auth_oidc: cannot create user missing required claim %r in id_token (%r)',
-                        claim,
-                        id_token,
-                    )
-                    return None
-                elif not user_info or claim not in user_info:
-                    logger.warning(
-                        'auth_oidc: cannot create user missing required claim %r in user_info (%r)',
-                        claim,
-                        user_info,
-                    )
-                    return None
-
-        # map claims to attributes or user fields
-        # mapping is done before eventual creation of user as EMAIL_IS_UNIQUE needs to know if the
-        # mapping will provide some mail to us
-        ou_map = {ou.slug: ou for ou in OrganizationalUnit.cached()}
-        user_ou = provider.ou
-        save_user = False
-        mappings = []
-        context = id_token_content.copy()
-        if need_user_info:
-            context.update(user_info or {})
+                    context.update(user_info or {})
 
         for claim_mapping in provider.claim_mappings.all():
             claim = claim_mapping.claim
@@ -251,12 +201,89 @@ class OIDCBackend(ModelBackend):
                 verified = True
             mappings.append((attribute, value, verified))
 
+        # check for required claims
+        for claim_mapping in provider.claim_mappings.all():
+            claim = claim_mapping.claim
+            if claim_mapping.required:
+                if '{{' in claim or '{%' in claim:
+                    logger.warning('claim \'%r\' is templated, it cannot be set as required')
+                elif claim_mapping.idtoken_claim and claim not in id_token:
+                    logger.warning(
+                        'auth_oidc: cannot create user missing required claim %r in id_token (%r)',
+                        claim,
+                        id_token,
+                    )
+                    return None
+                elif not user_info or claim not in user_info:
+                    logger.warning(
+                        'auth_oidc: cannot create user missing required claim %r in user_info (%r)',
+                        claim,
+                        user_info,
+                    )
+                    return None
+
         # find en email in mappings
         email = None
         for attribute, value, verified in mappings:
             if attribute == 'email':
                 email = value
 
+        User = get_user_model()
+        user = None
+        if provider.strategy == models.OIDCProvider.STRATEGY_FIND_UUID:
+            # use the OP sub to find an user by UUUID
+            # it means OP and RP share the same account base and OP is passing its UUID as sub
+            try:
+                user = User.objects.get(uuid=id_token.sub, is_active=True)
+            except User.DoesNotExist:
+                pass
+            else:
+                logger.info('auth_oidc: found user using UUID (=sub) "%s": %s', id_token.sub, user)
+        elif provider.strategy == models.OIDCProvider.STRATEGY_FIND_USERNAME:
+            users = User.objects.filter(username=id_token.sub, is_active=True).order_by('pk')
+            if not users:
+                logger.warning('auth_oidc: user with username (=sub) "%s" not found', id_token.sub)
+            else:
+                user = users[0]
+                logger.info('auth_oidc: found user using username (=sub) "%s": %s', id_token.sub, user)
+        elif provider.strategy == models.OIDCProvider.STRATEGY_FIND_EMAIL:
+            if email and id_token.sub != email:
+                logger.warning(
+                    'auth_oidc: email claim (%s) does not match subject identifier (%s) yet STRATEGY_FIND_EMAIL, fallback on email claim.',
+                    email,
+                    id_token.sub,
+                )
+            elif not email:
+                logger.warning(
+                    'auth_oidc: email claim absent yet STRATEGY_FIND_EMAIL is set, using subject identifier (%s) instead',
+                    id_token.sub,
+                )
+                email = id_token.sub
+
+            if not email:
+                logger.error(
+                    'auth_oidc: email lookup activated for provider "%s" yet no email received', provider
+                )
+            users = User.objects.filter(email__iexact=email, is_active=True)
+            if not app_settings.A2_EMAIL_IS_UNIQUE and provider.ou:
+                users = users.filter(ou=provider.ou)
+            Lock.lock_email(email)
+            try:
+                user = users.get()
+            except User.DoesNotExist:
+                logger.warning('auth_oidc: user with email "%s" not found', email)
+            else:
+                logger.info('auth_oidc: found user using email "%s": %s', email, user)
+        else:
+            try:
+                user = User.objects.get(
+                    oidc_account__provider=provider, oidc_account__sub=id_token.sub, is_active=True
+                )
+            except User.DoesNotExist:
+                pass
+            else:
+                logger.info('auth_oidc: found user using with sub "%s": %s', id_token.sub, user)
+
         # eventually create a new user or link to an existing one based on email
         created_user = False
         linked = False
diff --git a/src/authentic2_auth_oidc/migrations/0004_auto_20171017_1522.py b/src/authentic2_auth_oidc/migrations/0004_auto_20171017_1522.py
index f2932491f..d2026e617 100644
--- a/src/authentic2_auth_oidc/migrations/0004_auto_20171017_1522.py
+++ b/src/authentic2_auth_oidc/migrations/0004_auto_20171017_1522.py
@@ -18,6 +18,10 @@ class Migration(migrations.Migration):
                     ('create', 'create if standard account matching failed'),
                     ('find-uuid', 'use sub to find existing user through UUID'),
                     ('find-username', 'use sub to find existing user through username'),
+                    (
+                        'find-email',
+                        'use email claim (or sub if claim is absent) to find existing user through email',
+                    ),
                     ('none', 'none'),
                 ],
             ),
diff --git a/src/authentic2_auth_oidc/models.py b/src/authentic2_auth_oidc/models.py
index feee759b5..944f40270 100644
--- a/src/authentic2_auth_oidc/models.py
+++ b/src/authentic2_auth_oidc/models.py
@@ -44,12 +44,17 @@ class OIDCProvider(BaseAuthenticator):
     STRATEGY_CREATE = 'create'
     STRATEGY_FIND_UUID = 'find-uuid'
     STRATEGY_FIND_USERNAME = 'find-username'
+    STRATEGY_FIND_EMAIL = 'find-email'
     STRATEGY_NONE = 'none'
 
     STRATEGIES = [
         (STRATEGY_CREATE, _('create if standard account matching failed')),
         (STRATEGY_FIND_UUID, _('use sub to find existing user through UUID')),
         (STRATEGY_FIND_USERNAME, _('use sub to find existing user through username')),
+        (
+            STRATEGY_FIND_EMAIL,
+            _('use email claim (or sub if claim is absent) to find existing user through email'),
+        ),
         (STRATEGY_NONE, _('none')),
     ]
     ALGO_NONE = 0
diff --git a/tests/test_auth_oidc.py b/tests/test_auth_oidc.py
index 44ed7f331..cddccb924 100644
--- a/tests/test_auth_oidc.py
+++ b/tests/test_auth_oidc.py
@@ -663,6 +663,61 @@ def test_strategy_find_uuid(app, caplog, code, oidc_provider, oidc_provider_jwks
     assert response.location.startswith('https://server.example.com/logout?')
 
 
+def test_strategy_find_email(app, caplog, code, oidc_provider, oidc_provider_jwkset, simple_user):
+    OIDCClaimMapping.objects.all().delete()
+    OIDCClaimMapping.objects.create(
+        provider=oidc_provider,
+        claim='email',
+        attribute='email',
+        idtoken_claim=False,  # served by user_info endpoint
+    )
+    oidc_provider.strategy = oidc_provider.STRATEGY_FIND_EMAIL
+    oidc_provider.save()
+    oidc_provider.ou.email_is_unique = True
+    oidc_provider.ou.save()
+
+    assert User.objects.count() == 1
+
+    response = app.get('/').maybe_follow()
+    assert oidc_provider.name in response.text
+    response = response.click(oidc_provider.name)
+    location = urllib.parse.urlparse(response.location)
+    query = QueryDict(location.query)
+    state = query['state']
+    nonce = query['nonce']
+
+    with utils.check_log(caplog, 'cannot create user'):
+        with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, nonce=nonce):
+            response = app.get(login_callback_url(oidc_provider), params={'code': code, 'state': state})
+
+    simple_user.email = 'sub@example.com'
+    simple_user.save()
+
+    with utils.check_log(caplog, 'cannot create user'):
+        with oidc_provider_mock(
+            oidc_provider, oidc_provider_jwkset, code, sub='sub@example.com', nonce=nonce
+        ):
+            response = app.get(login_callback_url(oidc_provider), params={'code': code, 'state': state})
+
+    simple_user.email = 'john.doe@example.com'
+    simple_user.save()
+
+    with utils.check_log(caplog, 'found user using email'):
+        with oidc_provider_mock(oidc_provider, oidc_provider_jwkset, code, nonce=nonce):
+            response = app.get(login_callback_url(oidc_provider), params={'code': code, 'state': state})
+
+    assert urllib.parse.urlparse(response['Location']).path == '/'
+    assert User.objects.count() == 1
+    user = User.objects.get()
+    # verify user was not modified
+    assert user.username == 'user'
+    assert user.first_name == 'Jôhn'
+    assert user.last_name == 'Dôe'
+    assert user.email == 'john.doe@example.com'
+    assert user.attributes.first_name == 'Jôhn'
+    assert user.attributes.last_name == 'Dôe'
+
+
 def test_strategy_create(app, caplog, code, oidc_provider, oidc_provider_jwkset):
     oidc_provider.ou.email_is_unique = True
     oidc_provider.ou.save()
-- 
2.36.1

