Repository: pennersr/django-allauth
Pull request #3374 (2023-08-08 19:22:33)

feat(socialaccount): Authenticate by email
Consider a scenario where a social login occurs, and the social account comes with a verified email address (verified by the account provider), but that email address is already taken by a local user account. Additionally, assume that the local user account does not have any social account connected. Now, if the provider can be fully trusted, you can argue that we should treat this scenario as a login to the existing local user account even if the local account does not already have the social account connected, because -- according to the provider -- the user logging in has ownership of the email address.  This is how this scenario is handled when `SOCIALACCOUNT_EMAIL_AUTHENTICATION` is set to `True`. As this implies that an untrustworthy provider can login to any local account by fabricating social account data, this setting defaults to `False`. Only set it to `True` if you are using providers that can be fully trusted. 

coveralls: 
[![Coverage Status](https://coveralls.io/builds/61860938/badge)](https://coveralls.io/builds/61860938)

coverage: 91.385% (-0.04%) from 91.425% when pulling **0354c85e4a44d514de3b0eb6d3b1c4e0567fbd90 on feat-socialaccount-email-authentication** into **2600b799e8d806ceb83964fb6968f245b1fc77eb on main**.

svanellewee: :+1: 

Patch:
diff --git a/ChangeLog.rst b/ChangeLog.rst
index 92f95b12..76be9d58 100644
--- a/ChangeLog.rst
+++ b/ChangeLog.rst
@@ -30,6 +30,9 @@ Note worthy changes
 
 - Fixed Twitter OAuth2 authentication by using basic auth and adding scope `tweet.read`.
 
+- Added (optional) support for authentication by email for social logins (see
+  ``SOCIALACCOUNT_EMAIL_AUTHENTICATION``).
+
 
 Security notice
 ---------------
diff --git a/allauth/account/managers.py b/allauth/account/managers.py
index 74638c49..02e89509 100644
--- a/allauth/account/managers.py
+++ b/allauth/account/managers.py
@@ -1,3 +1,4 @@
+import functools
 from datetime import timedelta
 
 from django.db import models
@@ -101,6 +102,13 @@ class EmailAddressManager(models.Manager):
     def is_verified(self, email):
         return self.filter(email__iexact=email, verified=True).exists()
 
+    def lookup(self, emails):
+        q_list = [Q(email__iexact=e) for e in emails]
+        if not q_list:
+            return self.none()
+        q = functools.reduce(lambda a, b: a | b, q_list)
+        return self.filter(q)
+
 
 class EmailConfirmationManager(models.Manager):
     def all_expired(self):
diff --git a/allauth/socialaccount/app_settings.py b/allauth/socialaccount/app_settings.py
index e8ce7e0e..844665e7 100644
--- a/allauth/socialaccount/app_settings.py
+++ b/allauth/socialaccount/app_settings.py
@@ -84,6 +84,25 @@ class AppSettings(object):
 
         return self._setting("EMAIL_VERIFICATION", account_settings.EMAIL_VERIFICATION)
 
+    @property
+    def EMAIL_AUTHENTICATION(self):
+        """Consider a scenario where a social login occurs, and the social
+        account comes with a verified email address (verified by the account
+        provider), but that email address is already taken by a local user
+        account. Additionally, assume that the local user account does not have
+        any social account connected. Now, if the provider can be fully trusted,
+        you can argue that we should treat this scenario as a login to the
+        existing local user account even if the local account does not already
+        have the social account connected, because -- according to the provider
+        -- the user logging in has ownership of the email address.  This is how
+        this scenario is handled when `EMAIL_AUTHENTICATION` is set to
+        `True`. As this implies that an untrustworthy provider can login to any
+        local account by fabricating social account data, this setting defaults
+        to `False`. Only set it to `True` if you are using providers that can be
+        fully trusted.
+        """
+        return self._setting("EMAIL_AUTHENTICATION", False)
+
     @property
     def ADAPTER(self):
         return self._setting(
diff --git a/allauth/socialaccount/models.py b/allauth/socialaccount/models.py
index 58b0cb17..e12374fe 100644
--- a/allauth/socialaccount/models.py
+++ b/allauth/socialaccount/models.py
@@ -253,7 +253,6 @@ class SocialLogin(object):
         Saves a new account. Note that while the account is new,
         the user may be an existing one (when connecting accounts)
         """
-        assert not self.is_existing
         user = self.user
         user.save()
         self.account.user = user
@@ -269,15 +268,22 @@ class SocialLogin(object):
 
     @property
     def is_existing(self):
+        """When `False`, this social login represents a temporary account, not
+        yet backed by a database record.
         """
-        Account is temporary, not yet backed by a database record.
-        """
-        return self.account.pk is not None
+        return self.user.pk is not None
 
     def lookup(self):
+        """Look up the existing local user account to which this social login
+        points, if any.
         """
-        Lookup existing account, if any.
-        """
+        if not self._lookup_by_socialaccount():
+            if app_settings.EMAIL_AUTHENTICATION or app_settings.PROVIDERS.get(
+                self.account.provider, {}
+            ).get("EMAIL_AUTHENTICATION", False):
+                self._lookup_by_email()
+
+    def _lookup_by_socialaccount(self):
         assert not self.is_existing
         try:
             a = SocialAccount.objects.get(
@@ -306,9 +312,20 @@ class SocialLogin(object):
                 except SocialToken.DoesNotExist:
                     self.token.account = a
                     self.token.save()
+            return True
         except SocialAccount.DoesNotExist:
             pass
 
+    def _lookup_by_email(self):
+        emails = [e.email for e in self.email_addresses if e.verified]
+        if not emails:
+            return
+        address = (
+            EmailAddress.objects.lookup(emails).order_by("-verified", "user_id").first()
+        )
+        if address:
+            self.user = address.user
+
     def get_redirect_url(self, request):
         url = self.state.get("next")
         return url
diff --git a/docs/configuration.rst b/docs/configuration.rst
index 874d8be5..fd2a3ac3 100644
--- a/docs/configuration.rst
+++ b/docs/configuration.rst
@@ -298,6 +298,30 @@ SOCIALACCOUNT_AUTO_SIGNUP (=True)
   arises due to a duplicate email address the signup form will still
   kick in.
 
+SOCIALACCOUNT_EMAIL_AUTHENTICATION (=False)
+  Consider a scenario where a social login occurs, and the social account comes
+  with a verified email address (verified by the account provider), but that
+  email address is already taken by a local user account. Additionally, assume
+  that the local user account does not have any social account connected. Now,
+  if the provider can be fully trusted, you can argue that we should treat this
+  scenario as a login to the existing local user account even if the local
+  account does not already have the social account connected, because --
+  according to the provider -- the user logging in has ownership of the email
+  address.  This is how this scenario is handled when
+  ``SOCIALACCOUNT_EMAIL_AUTHENTICATION`` is set to ``True``. As this implies
+  that an untrustworthy provider can login to any local account by fabricating
+  social account data, this setting defaults to ``False``. Only set it to
+  ``True`` if you are using providers that can be fully trusted. Instead of
+  turning this on globally, you can also turn it on selectively per provider,
+  for example::
+
+      SOCIALACCOUNT_PROVIDERS = {
+        'google': {
+            'EMAIL_AUTHENTICATION': True
+        }
+      }
+
+
 SOCIALACCOUNT_EMAIL_VERIFICATION (=ACCOUNT_EMAIL_VERIFICATION)
   As ``ACCOUNT_EMAIL_VERIFICATION``, but for social accounts.
 


Test patch:
diff --git a/allauth/socialaccount/conftest.py b/allauth/socialaccount/conftest.py
index b122a189..a3bf68f8 100644
--- a/allauth/socialaccount/conftest.py
+++ b/allauth/socialaccount/conftest.py
@@ -6,9 +6,9 @@ from allauth.socialaccount.models import SocialAccount, SocialLogin
 
 @pytest.fixture
 def sociallogin_factory(user_factory):
-    def factory(email=None, with_email=True):
+    def factory(email=None, with_email=True, provider="unittest-server", uid="123"):
         user = user_factory(email=email, commit=False, with_email=with_email)
-        account = SocialAccount(provider="unittest-server", uid="123")
+        account = SocialAccount(provider=provider, uid=uid)
         sociallogin = SocialLogin(user=user, account=account)
         if with_email:
             sociallogin.email_addresses = [
diff --git a/allauth/socialaccount/tests/test_login.py b/allauth/socialaccount/tests/test_login.py
new file mode 100644
index 00000000..552cb317
--- /dev/null
+++ b/allauth/socialaccount/tests/test_login.py
@@ -0,0 +1,52 @@
+import uuid
+
+from django.contrib.auth.models import AnonymousUser
+from django.contrib.messages.middleware import MessageMiddleware
+from django.contrib.sessions.middleware import SessionMiddleware
+from django.urls import reverse
+
+import pytest
+
+from allauth.socialaccount.helpers import complete_social_login
+from allauth.socialaccount.models import SocialAccount
+
+
+@pytest.mark.parametrize("setting", ["off", "on-global", "on-provider"])
+def test_email_authentication(
+    db, setting, settings, user_factory, sociallogin_factory, client, rf, mailoutbox
+):
+    """Tests that when an already existing email is given at the social signup
+    form, enumeration preventation kicks in.
+    """
+    settings.ACCOUNT_EMAIL_REQUIRED = True
+    settings.ACCOUNT_UNIQUE_EMAIL = True
+    settings.ACCOUNT_USERNAME_REQUIRED = False
+    settings.ACCOUNT_AUTHENTICATION_METHOD = "email"
+    settings.ACCOUNT_EMAIL_VERIFICATION = "mandatory"
+    settings.SOCIALACCOUNT_AUTO_SIGNUP = True
+    if setting == "on-global":
+        settings.SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
+    elif setting == "on-provider":
+        settings.SOCIALACCOUNT_PROVIDERS["google"] = {"EMAIL_AUTHENTICATION": True}
+    else:
+        settings.SOCIALACCOUNT_EMAIL_AUTHENTICATION = False
+
+    user = user_factory()
+    SocialAccount.objects.create(
+        user=user,
+        provider="google",
+        uid=uuid.uuid4().hex,
+    )
+
+    sociallogin = sociallogin_factory(email=user.email, provider="google")
+
+    request = rf.get("/")
+    SessionMiddleware(lambda request: None).process_request(request)
+    MessageMiddleware(lambda request: None).process_request(request)
+    request.user = AnonymousUser()
+
+    resp = complete_social_login(request, sociallogin)
+    if setting == "off":
+        assert resp["location"] == reverse("account_email_verification_sent")
+    else:
+        assert resp["location"] == "/accounts/profile/"