Repository: ckan/ckan

Issue:
Users should be able to login with email address
**CKAN version**
master

Email addresses have been unique since 2.9 and password resets can be done with email addresses. Currently it's confusing for users who reset their passwords using email address but need to know their user names when logging in. There should not be any issues enabling login with email address.


Discussion:

Resolving patch:
diff --git a/ckan/lib/authenticator.py b/ckan/lib/authenticator.py
--- a/ckan/lib/authenticator.py
+++ b/ckan/lib/authenticator.py
@@ -23,9 +23,11 @@ def authenticate(
 
         login = identity['login']
         user = User.by_name(login)
+        if not user:
+            user = User.by_email(login)
 
         if user is None:
-            log.debug('Login failed - username %r not found', login)
+            log.debug('Login failed - username or email %r not found', login)
         elif not user.is_active():
             log.debug('Login as %r failed - user isn\'t active', login)
         elif not user.validate_password(identity['password']):
diff --git a/ckan/model/user.py b/ckan/model/user.py
--- a/ckan/model/user.py
+++ b/ckan/model/user.py
@@ -9,7 +9,7 @@
 import six
 
 import passlib.utils
-from passlib.hash import pbkdf2_sha512  # type: ignore
+from passlib.hash import pbkdf2_sha512 # type: ignore
 from sqlalchemy.sql.expression import or_
 from sqlalchemy.orm import synonym
 from sqlalchemy import types, Column, Table, func
@@ -87,8 +87,8 @@ class User(core.StatefulObjectMixin,
     DOUBLE_SLASH = re.compile(r':\/([^/])')
 
     @classmethod
-    def by_email(cls: Type[TUser], email: str) -> list["TUser"]:
-        return meta.Session.query(cls).filter_by(email=email).all()
+    def by_email(cls: Type[TUser], email: str) -> Optional["User"]:
+        return meta.Session.query(cls).filter_by(email=email).first()
 
     @classmethod
     def get(cls, user_reference: Optional[str]) -> Optional["User"]:


Test patch:
diff --git a/ckan/tests/controllers/test_group.py b/ckan/tests/controllers/test_group.py
--- a/ckan/tests/controllers/test_group.py
+++ b/ckan/tests/controllers/test_group.py
@@ -416,10 +416,8 @@ def test_membership_add_by_email(self, app, mail_server):
             status=200
         )
         assert len(mail_server.get_smtp_messages()) == 1
-        users = model.User.by_email(email)
-        assert len(users) == 1, users
-        user = users[0]
-        assert user.email == email, user
+        user = model.User.by_email(email)
+        assert user.email == email
         assert group["id"] in user.get_group_ids(capacity="member")
 
     def test_membership_edit_page(self, app):
diff --git a/ckan/tests/lib/test_authenticator.py b/ckan/tests/lib/test_authenticator.py
--- a/ckan/tests/lib/test_authenticator.py
+++ b/ckan/tests/lib/test_authenticator.py
@@ -7,27 +7,26 @@
 
 @pytest.mark.usefixtures("non_clean_db")
 class TestUsernamePasswordAuthenticator(object):
-    def test_succeeds_if_login_and_password_are_correct(self):
-        password = "somepass"
-        user = factories.User(password=password)
-        identity = {"login": user["name"], "password": password}
+    password = 'somepass'
+
+    def test_succeeds_if_username_and_password_are_correct(self):
+        user = factories.User(password=self.password)
+        identity = {"login": user["name"], "password": self.password}
         assert (
             UsernamePasswordAuthenticator().authenticate({}, identity)
             == user["name"]
         )
 
     def test_fails_if_user_is_deleted(self):
-        password = "somepass"
-        user = factories.User(password=password, state="deleted")
-        identity = {"login": user["name"], "password": password}
+        user = factories.User(password=self.password, state="deleted")
+        identity = {"login": user["name"], "password": self.password}
         assert (
             UsernamePasswordAuthenticator().authenticate({}, identity) is None
         )
 
     def test_fails_if_user_is_pending(self):
-        password = "somepass"
-        user = factories.User(password=password, state="pending")
-        identity = {"login": user["name"], "password": password}
+        user = factories.User(password=self.password, state="pending")
+        identity = {"login": user["name"], "password": self.password}
         assert (
             UsernamePasswordAuthenticator().authenticate({}, identity) is None
         )
@@ -51,3 +50,11 @@ def test_fails_if_received_no_login_or_pass(self, identity):
         assert (
             UsernamePasswordAuthenticator().authenticate({}, identity) is None
         )
+
+    def test_succeeds_if_email_and_password_are_correct(self):
+        user = factories.User(password=self.password)
+        identity = {"login": user["email"], "password": self.password}
+        assert (
+            UsernamePasswordAuthenticator().authenticate({}, identity)
+            == user["name"]
+        )
diff --git a/ckan/tests/lib/test_signals.py b/ckan/tests/lib/test_signals.py
--- a/ckan/tests/lib/test_signals.py
+++ b/ckan/tests/lib/test_signals.py
@@ -102,3 +102,8 @@ def test_login(self, app):
                 app.post(url, data=data)
                 assert success.call_count == 1
                 assert fail.call_count == 3
+
+                data = {u"login": user["email"], u"password": "correct123"}
+                app.post(url, data=data)
+                assert success.call_count == 2
+                assert fail.call_count == 3