From cd9ea4f7e3bcc891324d0d3c16db817e1ed80e82 Mon Sep 17 00:00:00 2001
From: Paul Marillonnet <pmarillonnet@entrouvert.com>
Date: Tue, 26 Oct 2021 10:48:06 +0200
Subject: [PATCH] journal: add ldap down info on failed user login (#58151)

---
 src/authentic2/authenticators.py          | 11 +++++--
 src/authentic2/backends/ldap_backend.py   | 28 ++++++++++++++++--
 src/authentic2/backends/models_backend.py |  4 +--
 src/authentic2/journal_event_types.py     | 10 +++++--
 tests/test_ldap.py                        | 36 +++++++++++++++++++++++
 5 files changed, 78 insertions(+), 11 deletions(-)

diff --git a/src/authentic2/authenticators.py b/src/authentic2/authenticators.py
index daf08618..07173f73 100644
--- a/src/authentic2/authenticators.py
+++ b/src/authentic2/authenticators.py
@@ -108,7 +108,7 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
         data = request.POST if is_post else None
         initial = {}
         preferred_ous = []
-        request.failed_logins = set()
+        request.failed_logins = {}
 
         # Special handling when the form contains an OU selector
         if app_settings.A2_LOGIN_FORM_OU_SELECTOR:
@@ -151,8 +151,13 @@ class LoginPasswordAuthenticator(BaseAuthenticator):
             else:
                 username = form.cleaned_data.get('username', '').strip()
                 if request.failed_logins:
-                    for user in request.failed_logins:
-                        request.journal.record('user.login.failure', user=user, username=username)
+                    for user, failure_data in request.failed_logins.items():
+                        request.journal.record(
+                            'user.login.failure',
+                            user=user,
+                            reason=failure_data.get('reason', None),
+                            username=username,
+                        )
                 elif username:
                     request.journal.record('user.login.failure', username=username)
         context['form'] = form
diff --git a/src/authentic2/backends/ldap_backend.py b/src/authentic2/backends/ldap_backend.py
index 4ae1864c..58e3b22e 100644
--- a/src/authentic2/backends/ldap_backend.py
+++ b/src/authentic2/backends/ldap_backend.py
@@ -708,8 +708,12 @@ class LDAPBackend:
                             if success:
                                 attributes = self.get_ldap_attributes(block, conn, authz_id)
                                 user = self.lookup_existing_user(authz_id, block, attributes)
-                                if user and hasattr(request, 'failed_logins'):
-                                    request.failed_logins.add(user)
+                                if (
+                                    user
+                                    and hasattr(request, 'failed_logins')
+                                    and user not in request.failed_logins
+                                ):
+                                    request.failed_logins.update({user: {}})
                             else:
                                 log.warning(
                                     'could not rebind after a bind failure, unable to attach the error to the'
@@ -735,10 +739,13 @@ class LDAPBackend:
                     ldap_uri,
                     block['url'],
                 )
+                self._record_failure_for_user(request, 'ldap connect error', authz_id, block, conn)
             except ldap.TIMEOUT:
                 log.error('connection to %r timed out', block['url'])
+                self._record_failure_for_user(request, 'ldap timeout', authz_id, block, conn)
             except ldap.SERVER_DOWN:
                 log.error('ldap authentication error: %r is down', block['url'])
+                self._record_failure_for_user(request, 'ldap server down', authz_id, block, conn)
             finally:
                 del conn
         return None
@@ -1278,7 +1285,7 @@ class LDAPBackend:
         return '(&{})'.format(''.join(filters))
 
     def build_external_id(self, external_id_tuple, attributes):
-        """Build the exernal id for the user, use attribute that eventually
+        """Build the external id for the user, use attribute that eventually
         never change like GUID or UUID.
         """
         parts = []
@@ -1370,6 +1377,21 @@ class LDAPBackend:
                         user=user, source=force_text(block['realm'])
                     ).delete()
 
+    def _record_failure_for_user(self, request, reason, user_id, block, conn, attributes=None):
+        user = None
+        if not reason or not hasattr(request, 'failed_logins'):
+            return
+        attributes = attributes or self.get_ldap_attributes(block, conn, user_id)
+        for lookup_type in block['lookups']:
+            if lookup_type == 'external_id':
+                user = self.lookup_by_external_id(block, attributes)
+            elif lookup_type == 'username':
+                user = self.lookup_by_username(user_id)
+            if user:
+                break
+        if user:
+            request.failed_logins.update({user: {'username': user_id, 'reason': reason}})
+
     def _return_user(self, dn, password, conn, block, attributes=None):
         attributes = attributes or self.get_ldap_attributes(block, conn, dn)
         if attributes is None:
diff --git a/src/authentic2/backends/models_backend.py b/src/authentic2/backends/models_backend.py
index ea8532b1..c4b175dc 100644
--- a/src/authentic2/backends/models_backend.py
+++ b/src/authentic2/backends/models_backend.py
@@ -83,8 +83,8 @@ class ModelBackend(BaseModelBackend):
                 return user
             else:
                 user_login_failure(user.get_username())
-                if hasattr(request, 'failed_logins'):
-                    request.failed_logins.add(user)
+                if hasattr(request, 'failed_logins') and user not in request.failed_logins:
+                    request.failed_logins.update({user: {}})
 
     def get_user(self, user_id):
         UserModel = get_user_model()
diff --git a/src/authentic2/journal_event_types.py b/src/authentic2/journal_event_types.py
index b0c2a2ae..8634aa91 100644
--- a/src/authentic2/journal_event_types.py
+++ b/src/authentic2/journal_event_types.py
@@ -150,13 +150,17 @@ class UserLoginFailure(EventTypeWithService):
     label = _('login failure')
 
     @classmethod
-    def record(cls, service, username, user):
-        super().record(user=user, service=service, data={'username': username})
+    def record(cls, service, username, user, reason=None):
+        super().record(user=user, service=service, data={'username': username, 'reason': reason})
 
     @classmethod
     def get_message(cls, event, context):
         username = event.get_data('username')
-        return _('login failure with username "{username}"').format(username=username)
+        reason = event.get_data('reason')
+        msg = _('login failure with username "{username}"').format(username=username)
+        if reason:
+            msg.append(_(' (reason: {reason})').format(reason=reason))
+        return msg
 
 
 class UserRegistrationRequest(EventTypeDefinition):
diff --git a/tests/test_ldap.py b/tests/test_ldap.py
index fa7dc46a..3ad60537 100644
--- a/tests/test_ldap.py
+++ b/tests/test_ldap.py
@@ -2073,3 +2073,39 @@ def test_manager_user_sidebar(slapd, settings, client, db, app, superuser):
     user.userexternalid_set.all().delete()
     resp = app.get('/manage/users/%s/' % user.pk)
     assert 'LDAP' not in resp.text
+
+
+def test_user_journal_login_failure(slapd, settings, client, db, monkeypatch):
+    settings.LDAP_AUTH_SETTINGS = [
+        {
+            'url': [slapd.ldap_url],
+            'basedn': 'o=ôrga',
+            'use_tls': False,
+            'attributes': ['jpegPhoto'],
+        }
+    ]
+
+    # create ldap user
+    result = client.post(
+        '/login/', {'login-password-submit': '1', 'username': USERNAME, 'password': PASS}, follow=True
+    )
+
+    for exception, reason in {
+        ldap.CONNECT_ERROR: 'ldap connect error',
+        ldap.TIMEOUT: 'ldap timeout',
+        ldap.SERVER_DOWN: 'ldap server down',
+    }.items():
+
+        def patched_process_controls(cls, request, block, conn, authz_id, ctrls):
+            raise exception('oops')
+
+        monkeypatch.setattr(
+            ldap_backend.LDAPBackend,
+            'process_controls',
+            patched_process_controls,
+        )
+        result = client.post(
+            '/login/', {'login-password-submit': '1', 'username': USERNAME, 'password': PASS}, follow=True
+        )
+        user = ldap_backend.LDAPUser.objects.get(username='%s@ldap' % UID)
+        utils.assert_event('user.login.failure', user=user, username=UID, reason=reason)
-- 
2.33.1

