Repository: mdn/kuma

Issue:
Implement automatic merging of Persona/GitHub/Google accounts on signup
### Summary
When creating an MDN account via GitHub/Google social login, if one of the verified GitHub/Google email addresses matches an existing Persona/GitHub/Google social account associated with an existing user, automatically recover that user instead of creating a new one, skipping the usual `/<locale>/users/account/signup` page step (where the user selects a username and email address).

See https://github.com/mdn/kuma/issues/6037 for context.

### Acceptance Criteria
- [ ] When signing-up for an MDN account via GitHub/Google social login, a user with an existing Persona/GitHub/Google account (i.e., an existing MDN user account with an associated Persona/GitHub/Google social account) that matches one of the verified GitHub/Google email addresses is automatically recovered without having to go through the `/<locale>/users/account/signup` page step in order to select a username and email address.
- [ ] After recovery, if the user has any associated Personal social accounts, they are deleted, so that only any associated GitHub/Google social accounts remain.



Discussion:

Resolving patch:
diff --git a/kuma/users/adapters.py b/kuma/users/adapters.py
--- a/kuma/users/adapters.py
+++ b/kuma/users/adapters.py
@@ -1,10 +1,13 @@
 from allauth.account.adapter import DefaultAccountAdapter, get_adapter
+from allauth.account.models import EmailAddress
+from allauth.account.utils import cleanup_email_addresses
 from allauth.exceptions import ImmediateHttpResponse
 from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
 from allauth.socialaccount.models import SocialLogin
 from django import forms
 from django.contrib import messages
 from django.contrib.auth import get_user_model
+from django.db.models import Q
 from django.shortcuts import redirect, render
 from django.utils.cache import add_never_cache_headers
 from django.utils.translation import ugettext_lazy as _
@@ -207,17 +210,89 @@ def get_connect_redirect_url(self, request, socialaccount):
                            kwargs={'username': request.user.username})
         return user_url
 
-    def save_user(self, request, sociallogin, form):
+    def save_user(self, request, sociallogin, form=None):
         """
-        Update the session after creating a new user account.
+        Checks for an existing user (via verified email addresses within the
+        social login object) and, if one is found, associates the incoming
+        social account with that existing user instead of a new user.
 
-        If the socialaccount_sociallogin remains in the session, then the user
-        will be unable to connect a second account unless they log out and
-        log in again.
+        It also removes the "socialaccount_sociallogin" key from the session.
+        If the "socialaccount_sociallogin" key remains in the session, then the
+        user will be unable to connect a second account unless they log out and
+        log in again. (TODO: Check if this part of the method is still
+        needed/used. I suspect not.)
         """
-        super(KumaSocialAccountAdapter, self).save_user(request, sociallogin,
-                                                        form)
+        # We have to call get_existing_user() again. The result of the earlier
+        # call (within the is_auto_signup_allowed() method), can't be cached as
+        # an attribute on the instance because a different instance of this
+        # class is used when calling this method from the one used when calling
+        # is_auto_signup_allowed().
+        user = get_existing_user(sociallogin)
+        if user:
+            # We can re-use an existing user instead of creating a new one.
+            # Let's guarantee this user has an unusable password, just in case
+            # we're recovering an old user that has never had this done before.
+            user.set_unusable_password()
+            # This associates this new social account with the existing user.
+            sociallogin.connect(request, user)
+            # Since the "connect" call above does not add any email addresses
+            # from the social login that are missing from the user's current
+            # associated set, let's add them here.
+            add_user_email(request, user, sociallogin.email_addresses)
+            # Now that we've successfully associated a GitHub/Google social
+            # account with this existing user, let's delete all of the user's
+            # associated Persona social accounts (if any). Users may have
+            # multiple associated Persona social accounts (each identified
+            # by a unique email address).
+            user.socialaccount_set.filter(provider='persona').delete()
+        else:
+            user = super().save_user(request, sociallogin, form)
+
         try:
             del request.session['socialaccount_sociallogin']
         except KeyError:  # pragma: no cover
             pass
+
+        return user
+
+    def is_auto_signup_allowed(self, request, sociallogin):
+        """
+        We allow "auto-signup" (basically skipping the sign-up form page) only
+        if there is an existing user that we can re-use instead of creating
+        a new one.
+        """
+        return bool(get_existing_user(sociallogin))
+
+
+def get_existing_user(sociallogin):
+    """
+    Attempts to find an existing user that is associated with a verified email
+    address that matches one of the verified email addresses within the
+    "sociallogin" object.
+    """
+    emails = Q()
+    for email_address in sociallogin.email_addresses:
+        if email_address.verified:
+            emails |= Q(emailaddress__email=email_address.email)
+    if emails:
+        # Users can have multiple associated EmailAddress objects, so
+        # let's use "distinct()" to remove any duplicate users.
+        users = list(get_user_model().objects
+                                     .filter(emails,
+                                             emailaddress__verified=True)
+                                     .distinct())
+        # For now, we're only going to return a user if there's only one.
+        if len(users) == 1:
+            return users[0]
+    return None
+
+
+def add_user_email(request, user, addresses):
+    """
+    This is based on allauth.account.utils.setup_user_email, but targets
+    the addition of email-address objects to an existing user.
+    """
+    for a in cleanup_email_addresses(request, addresses)[0]:
+        if not EmailAddress.objects.filter(user=user, email=a.email).exists():
+            a.user = user
+            a.save()
diff --git a/kuma/users/providers/github/provider.py b/kuma/users/providers/github/provider.py
--- a/kuma/users/providers/github/provider.py
+++ b/kuma/users/providers/github/provider.py
@@ -29,17 +29,17 @@ class KumaGitHubProvider(GitHubProvider):
     account_class = KumaGitHubAccount
 
     def extract_email_addresses(self, data):
-        email_addresses = []
-        for email_address in data.get('email_addresses', []):
-            # let's ignore all email address that have not been verified at
-            # GitHub's side
-            if (not email_address.get('verified', False) or
-                    not email_address.get('email', '').strip()):
-                continue
-            email_addresses.append(EmailAddress(email=email_address['email'],
-                                                verified=True,
-                                                primary=email_address['primary']))
-        return email_addresses
+        result = []
+        for email_address in data.get('email_addresses', ()):
+            # Ignore all email addresses that have not been verified by GitHub.
+            email = email_address.get('email', '').strip()
+            if email and email_address.get('verified'):
+                result.append(EmailAddress(
+                    email=email,
+                    verified=True,
+                    primary=email_address['primary']
+                ))
+        return result
 
 
 provider_classes = [KumaGitHubProvider]


Test patch:
diff --git a/kuma/users/tests/test_adapters.py b/kuma/users/tests/test_adapters.py
--- a/kuma/users/tests/test_adapters.py
+++ b/kuma/users/tests/test_adapters.py
@@ -1,7 +1,7 @@
-
-
 import pytest
+from allauth.account.models import EmailAddress
 from allauth.exceptions import ImmediateHttpResponse
+from allauth.socialaccount.helpers import complete_social_login
 from allauth.socialaccount.models import SocialAccount, SocialLogin
 from django.contrib import messages as django_messages
 from django.contrib.auth.models import AnonymousUser
@@ -223,3 +223,140 @@ def test_extra_tags(self):
         messages = self.test_account_connected_message(next_url, True,
                                                        extra_tags='congrats')
         assert messages[0].tags == 'congrats account success'
+
+
+@pytest.mark.parametrize('provider', ('github', 'google'))
+@pytest.mark.parametrize('case', ('match',
+                                  'nomatch_address',
+                                  'nomatch_user_unverified',
+                                  'nomatch_social_unverified'))
+def test_user_reuse_on_social_login(wiki_user, client, rf, case, provider):
+    """
+    Test all cases related to re-using an existing user on social login.
+    """
+    # Make explicit the assumption that the social-login provider which we're
+    # signing-up with is not yet associated with the existing user.
+    wiki_user_social_account = (
+        SocialAccount.objects.filter(user=wiki_user, provider=provider))
+    assert not wiki_user_social_account.exists()
+
+    # Let's give the user a usable password so we can check later if that's
+    # been remedied if we find a match.
+    wiki_user.set_password('qwerty')
+    wiki_user.save()
+
+    social_email_address = ('user@nomatch.com'
+                            if case == 'nomatch_address' else
+                            wiki_user.email)
+    user_email_verified = case != 'nomatch_user_unverified'
+    social_email_verified = case != 'nomatch_social_unverified'
+
+    primary_social_email = {
+        'email': social_email_address,
+        'primary': True,
+        'verified': social_email_verified,
+        'visibility': 'public'
+    }
+    extra_social_email = {
+        'email': 'user@junk.com',
+        'primary': False,
+        'verified': False,
+        'visibility': None
+    }
+    social_emails = [primary_social_email, extra_social_email]
+
+    if provider == 'github':
+        other_provider = 'google'
+        social_login = SocialLogin(
+            user=User(username='new_user'),
+            account=SocialAccount(
+                uid=1234567,
+                provider='github',
+                extra_data={
+                    'email': None,
+                    'login': 'new_user',
+                    'name': 'Paul McCartney',
+                    'avatar_url': 'https://yada/yada',
+                    'html_url': 'https://github.com/new_user',
+                    'email_addresses': social_emails
+                }
+            ),
+            email_addresses=[EmailAddress(
+                email=a['email'],
+                verified=a['verified'],
+                primary=a['primary']
+            ) for a in social_emails]
+        )
+    else:
+        other_provider = 'github'
+        social_login = SocialLogin(
+            user=User(username='new_user'),
+            account=SocialAccount(
+                uid=123456789012345678901,
+                provider='google',
+                extra_data={
+                    'email': primary_social_email['email'],
+                    'verified_email': primary_social_email['verified'],
+                    'name': 'Paul McCartney',
+                    'given_name': 'Paul',
+                    'family_name': 'McCartney',
+                    'picture': 'https://yada/yada'
+                }
+            ),
+            email_addresses=[EmailAddress(
+                email=primary_social_email['email'],
+                verified=primary_social_email['verified'],
+                primary=primary_social_email['primary']
+            )]
+        )
+
+    # Associate social accounts with the wiki_user other than the social-login
+    # provider we're signing-up with.
+    for prov in ('persona', other_provider):
+        SocialAccount.objects.create(user=wiki_user, provider=prov)
+    # Associate an email address with the wiki_user.
+    EmailAddress.objects.create(
+        user=wiki_user,
+        email=wiki_user.email,
+        verified=user_email_verified
+    )
+
+    request = rf.get(f'/users/{provider}/login')
+    request.user = AnonymousUser()
+    request.session = client.session
+
+    # Run through the same steps that django-allauth takes after the OAuth2
+    # dance has completed. Doing it this way avoids having to mock the entire
+    # OAuth2 dance.
+    response = complete_social_login(request, social_login)
+
+    # The response should be an instance of HttpResponseRedirect.
+    assert response.status_code == 302
+    # If we found a matching user, we should have been logged-in without
+    # any further steps, and redirected to the home page. Otherwise we should
+    # have been redirected to the account-signup page to continue the process.
+    assert response.url == ('/en-US/'
+                            if case == 'match' else
+                            '/en-US/users/account/signup')
+    # The wiki_user's password should have been made unusable but only if we
+    # found a match.
+    wiki_user.refresh_from_db()
+    assert wiki_user.has_usable_password() == (case != 'match')
+
+    # A new GitHub social account should have been associated with the existing
+    # wiki_user only if we found a match.
+    assert wiki_user_social_account.exists() == (case == 'match')
+    if provider == 'github':
+        # The extra social-login email address not yet associated with the
+        # existing wiki_user should have been created only if we found a match.
+        assert (EmailAddress.objects
+                            .filter(user=wiki_user,
+                                    email=extra_social_email['email'],
+                                    verified=extra_social_email['verified'])
+                            .exists()) == (case == 'match')
+    # The associated Persona social account should have been deleted only if
+    # we found a match.
+    assert (SocialAccount.objects
+                         .filter(user=wiki_user,
+                                 provider='persona')
+                         .exists()) == (case != 'match')